posts - 12, comments - 0, trackbacks - 0, articles - 7
  BlogJava :: 首页 :: 新随笔 :: 联系 :: 聚合  :: 管理

NIO trick and trap NIO的技巧与陷阱

Posted on 2011-12-20 20:41 cooperzh 阅读(2360) 评论(0)  编辑  收藏 所属分类: NIO
1 等待数据就绪
2 从内核缓冲区copy到进程缓冲区(从socket通过socketChannel复制到ByteBuffer)

non-direct ByteBuffer: HeapByteBuffer,创建开销小
direct ByteBuffer:通过操作系统native代码,创建开销大

基于block的传输通常比基于流的传输更高效

使用NIO做网络编程容易,但离散的事件驱动模型编程困难,而且陷阱重重

Reactor模式:经典的NIO网络框架
核心组件:
1 Synchronous Event Demultiplexer : Event loop + 事件分离
2 Dispatcher:事件派发,可以多线程
3 Request Handler:事件处理,业务代码


理想的NIO框架:
1 优雅地隔离IO代码和业务代码
2 易于扩展
3 易于配置,包括框架自身参数和协议参数
4 提供良好的codec框架,方便marshall/unmarshall
5 透明性,内置良好的日志记录和数据统计
6 高性能

NIO框架性能的关键因素
1 数据的copy
2 上下文切换(context switch)
3 内存管理
4 TCP选项,高级IO函数
5 框架设计

减少数据copy:
ByteBuffer的选择
View ByteBuffer
FileChannel.transferTo/transferFrom
FileChannel.map/MappedByteBuffer

ByteBuffer的选择:
不知道用哪种buffer时,用Non-Direct
没有参与IO操作,用Non-Direct
中小规模应用(<1K并发连接),用Non-Direct
长生命周期,较大的缓冲区,用Direct
测试证明Direct比Non-Direct更快,用Direct
进程间数据共享(JNI),用Direct
一个Buffer发给多个Client,考虑使用view ByteBuffer共享数据,buffer.slice()


HeapByteBuffer缓存

使用ByteBuffer.slice()创建view ByteBuffer:
ByteBuffer buffer2 = buffer1.slice();
则buffer2的内容和buffer1的从position到limit的数据内容完全共享
但是buffer2的position,limit是独立于buffer1的

传输文件的传统方式:
byte[] buf = new byte[8192];
while(in.read(buf)>0){
    out.write(buf);
}
使用NIO后:
FileChannel in = ...
WriteableByteChannel out = ...
in.transferTo(0,fsize,out);
性能会有60%的提升

FileChannel.map
将文件映射为内存区域——MappedByteBuffer
提供快速的文件随机读写能力
平台相关
适合大文件,只读型操作,如大文件的MD5校验等
没有unmap方法,什么时候被回收取决于GC

减少上下文切换
时间缓存
Selector.wakeup
提高IO读写效率
线程模型

时间缓存:
1网络服务器通常需要频繁获取系统时间:定时器,协议时间戳,缓存过期等
2 System.currentTimeMillis
   a linux调用gettimeofday需要切换到内核态
   b 普通机器上,1000万次调用需要12秒,平均一次1.3毫秒
   c 大部分应用不需要特别高的精度
3 SystemTimer.currentTimeMillis(自己创建)
   a 独立线程定期更新时间缓存
   b currentTimeMillis直接返回缓存值
   c 精度取决于定期间隔
   d 1000万次调用降低到59毫秒


Selector.wakeup() 主要作用:
解除阻塞在Selector.select()上的线程,立即返回
两次成功的select()之间多次调用wakeup等价于一次调用
如果当前没有阻塞在select()上,则本次wakeup将作用在下次select()上
什么时候wakeup() ?
注册了新的Channel或者事件
Channel关闭,取消注册
优先级更高的事件触发(如定时器事件),希望及时处理

wakeup的原理:
1 linux上利用pipe调用创建一个管道
2 windows上是一个loopback的tcp连接,因为win32的管道无法加入select的fd set
3 将管道或者tcp连接加入selected fd set
4 wakeup向管道或者连接写入一个字节
5 阻塞的select()因为有IO时间就绪,立即返回
可见wakeup的调用开销不可忽视

减少wakeup调用:
1 仅在有需要时才调用。如往连接发送数据,通常是缓存在一个消息队列,当且仅当队列为空时注册write并wakeup
booleanneedsWakeup=false;
synchronized(queue){
    if(queue.isEmpty())  needsWakeup=true;
    queue.add(session);
}
if(needsWakeup){
    registerOPWrite();
    selector.wakeup();
}
2 记录调用状态,避免重复调用,例如Netty的优化

读到或者写入0个字节:
不代表连接关闭
高负载或者慢速网络下很常见的情况
通常的处理方法是返回并继续注册read/write,等待下次处理,缺点是系统调用开销和线程切换开销
其他解决办法:循环一定次数写入(如Mina)或者yield一定次数
启用临时选择器Temporary Selector在当前线程注册并poll,例如Girzzy中

在当前线程写入:
当发送缓冲队列为空的时候,可以直接往channel写数据,而不是放入缓冲队列,interest了write等待IO线程写入,可以提高发送效率
优点是可以减少系统调用和线程切换
缺点是当前线程中断会引起channel关闭

线程模型
selector的三个主要事件:read,write,accept,都可以运行在不同的线程上

通常Reactor实现为一个线程,内部维护一个selector

1 Boss Thread + worker Thread
   boss thread处理accept,connect
   worker thread处理read,write

Reactor线程数目:
1 Netty 1 + 2 * cpu
2 Mina 1 + cpu + 1
3 Grizzly 1 + 1

常见线程模型:
1 read和accept都运行在reactor线程上
2 accept运行在reactor线程上,read运行在单独线程
3 read和accept都运行在单独线程
4 read运行在reactor线程上,accept运行在单独线程

选择适当的线程模型:
类echo应用,unmashall和业务处理的开销非常低,选择模型1
模型2,模型3,模型4的accept处理开销很低
最佳选择:模型2。unmashall一般是cpu-bound,而业务逻辑代码一般比较耗时,不要在reactor线程处理

内存管理
1 java能做的事情非常有限
2 缓冲区的管理
   a 池化。ThreadLocal cache,环形缓冲区
   b 扩展。putString,getString等高级API,缓冲区自动扩展和伸缩,处理不定长度字节
   c 字节顺序。跨语言通讯需要注意,默认字节顺序Big-Endian,java的IO库和class文件

数据结构的选择
1 使用简单的数据结构:链表,队列,数组,散列表
2 使用j.u.c框架引入的并发集合类,lock-free,spin lock
3 任何数据结构都要注意容量限制,OutOfMemoryError
4 适当选择数据结构的初始容量,降低GC带来的影响

定时器的实现
1 定时器在网络程序中频繁使用
    a 周期事件的触发
    b 异步超时的通知和移除
    c 延迟事件的触发
2 三个时间复杂度
    a 插入定时器
    b 删除定时器
    c PerTickBookkeeping,一次tick内系统需要执行的操作
3 Tick的方式
    Selector.select(timeout)
    Thread.sleep(timeout)

定时器的实现:链表
将定时器组织成链表结构
插入定时器,加入链表尾部
删除定时器
PerTickBookkeeping,遍历链表查找expire事件

定时器的实现:排序链表
将定时器组织成有序链表结构,按照expire截止时间升序排序
插入定时器,找到合适的位置插入
删除定时器
PerTickBookkeeping,直接从表头找起

定时器的实现:优先队列
将定时器组织成优先队列,按照expire截止时间作为优先级,优先队列一般采用最小堆实现
插入定时器
删除定时器
PerTickBookkeeping,直接取root判断

定时器的实现:Hash wheel timer
将定时器组织成时间轮
指针按照一定周期旋转,一个tick跳动一个槽位
定时器根据延时时间和当前指针位置插入到特定槽位
插入定时器
删除定时器
PerTickBookkeeping
槽位和tick决定了精度和延时

定时器的实现:Hierarchical Timing
Hours Wheel,Minutes Wheel,Seconds Wheel

连接IDLE的判断
1 连接处于IDLE状态:一段时间没有IO读写事件发生
2 实现方式:
    a 每次IO读写都记录IO读和写的时间戳
    b 定时扫描所有连接,判断当前时间和上一次读或写的时间差是否超过设定阀值,超过即认为连接处于IDLE状态,通知业务处理器
   c 定时的方式:基于select(timeout)或者定时器。Mina:select(timeout);Netty:HashWheelTimer

合理设置TCP/IP选项,有时会起到显著效果,需要根据应用类型、协议设计、网络环境、OS平台等因素做考量,以测试结果为准

Socket缓冲区设置选项:SO_RCVBUF 和 SO_SNDBUF
Socket.setReceiveBufferSize/setSendBufferSize 仅仅是对底层平台的提示,是否有效取决于底层平台。因此get返回的不是真实的结果。
设置原则:
1 以太网上,4k通常是不够的,增加到16k,吞吐量增加了40%
2 Socket缓冲区大小至少应该是连接的MSS的三倍,MSS=MTU+40,一般
以太网卡的MTU=1500字节。
    MSS:最大分段大小
    MTU:最大传输单元
3 send buffer最好与对端的receive buffer尺寸一致
4 对于一次性发送大量数据的应用,增加缓冲区到48k、64k可能是唯一最有效的提高性能的方式。
    为了最大化性能,
send buffer至少要跟BDP(带宽延迟乘积)一样大。
5 同样,对于大量接收数据的应用,提高接收缓冲区,能减少发送端的阻塞
6 如果应用既发送大量数据,又接收大量数据,则
send buffer和receive buffer应该同时增加
7 如果设置的ServerSocket的receive buffer超过RFC1323定义的64k,那么必须在绑定端口前设置,以后accept产生的socket将继承这一设置
8 无论缓冲区大小多少,你都应该尽可能地帮助TCP至少以那样大小的块写入

BDP(带宽延迟乘积)
为了优化TCP吞吐量,发送端应该发送足够的数据包以填满发送端和接收端之间的逻辑通道
BDP = 带宽 * RTT

Nagle算法:SO_TCPNODELAY
通过将缓冲区内的小包自动相连组成大包,阻止发送大量小包阻塞网络,提高网络应用效率对于实时性要求较高的应用(telnet、网游),需要关闭此算法
Socket.setTcpNoDelay(true) 关闭算法
Socket.setTcpNoDelay(false) 
打开算法,默认

SO_LINGER选项,控制socket关闭后的行为
Socket.setSoLinger(boolean linger,int timeout)
linger=false,timeout=-1
当socket主动close,调用的线程会马上返回,不会阻塞,然后进入CLOSING状态,残留在缓冲区中的数据将继续发送给对端,并且与对端进行FIN-ACK协议交换,最后进入TIME_WAIT状态
linger=true,timeout>0
调用close的线程将阻塞,发生两种可能的情况:一是剩余的数据继续发送,进行关闭协议交换,二是超时过期,剩余数据将被删除,进行FIN-ACK协议交换
linger=true,timeout=0
进行所谓“hard-close”,任何剩余的数据将被丢弃,并且FIN-ACK交换也不会发生,替代产生RST,让对端抛出“connection reset”的SocketException
4 慎重使用此选项,TIME_WAIT状态的价值:
    可靠实现TCP连接终止
    允许
老的分节在网络中流失,防止发给新的连接
   持续时间=2*MSL(MSL为最大分节生命周期,一般为30秒到2分钟)

SO_REUSEADDR:重用端口
Socket.setReuseAddress(boolean) 默认false
适用场景:
1 当一个使用本地地址和端口的socket1处于TIME_WAIT状态时,你启动的socket2要占用该地址和端口,就要用到此选项
SO_REUSEADDR允许同一端口上启动一个服务的多个实例(多个进程),但每个实例绑定的地址是不能相同的
3 SO_REUSEADDR允许完全相同的地址和端口的重复绑定。但这只用于UDP的多播,不适用TCP

SO_REUSEPORT
listen做四元组,多进程同一地址同一端口做accept,适合大量短连接的web server
Freebsd独有

其他选项:
Socket.setPerformancePreferences(connectionTime, latency, bandwidth) 设置连接时间、延迟、带宽的相对重要性
Socket.setKeepAlive(boolean) 这是TCP层的keep-alive概念,非HTTP协议的。用于TCP连接保活,默认间隔2小时,建议在应用层做心跳
Socket.sendUrgentData(data) 带外数据


技巧:
1 读写公平
    Mina限制一次写入的字节数不超过最大的读缓冲区的1.5倍
2 针对FileChannel.transferTo的bug
    Mina判断异常,如果是temporarily unavailable的IOException,则认为传输字节数为0
3 发送消息,通常是放入一个缓冲区队列注册write,等待IO线程去写
    线程切换,系统调用
    如果队列为空,直接在当前线程channel.write,隐患是当前线程的中断会引起连接关闭
4 事件处理优先级
    ACE框架推荐:accept > write > read (推荐)
     Mina 和 Netty:read > write
5 处理事件注册的顺序
    在select()之前
    在select()之后,处理wakeup竞争条件

Java Socket实现在不同平台上的差异
由于各种OS平台的socket实现不尽相同,都会影响到socket的实现
需要考虑性能和健壮性