ServerCnxn
代表了一个客户端与一个server的连接,其有两种实现,分别是NIOServerCnxn
和NettyServerCnxn
,类图如下:
本文介绍ZooKeeper是如何通过NIOServerCnxn
实现网络IO的.
当SocketChannel
上有数据可读时,worker thread调用NIOServerCnxn.doIO()
进行读操作
处理读事件比较麻烦的问题就是通过TCP发送的报文会出现粘包拆包问题,Zookeeper为了解决此问题,在设计通信协议时将报文分为3个部分:
- 请求头和请求体的长度(4个字节)
- 请求头
- 请求体
注:(1)请求头和请求体也细分为更小的部分,但在此不做深入研究,只需知道请求的前4个字节是请求头和请求体的长度即可.(2)将请求头和请求体称之为payload
在报文头增加了4个字节的长度字段,表示整个报文除长度字段之外的长度.服务端可根据该长度将粘包拆包的报文分离或组合为完整的报文.NIOServerCnxn
读取数据流程如下:
- NIOServerCnxn中有两个属性,一个是lenBuffer,容量为4个字节,用于读取长度信息.一个是incomingBuffer,其初始化时即为lenBuffer,但是读取长度信息后,就为incomingBuffer分配对应的空间用于读取payload
- 根据请求报文的长度分配incomingBuffer的大小
- 将读到的字节存放在incomingBuffer中,直至读满(由于第2步中为incomingBuffer分配的长度刚好是报文的长度,此时incomingBuffer中刚好时一个报文)
- 处理报文
代码如下:
void doIO(SelectionKey k) throws InterruptedException { try { ... /* 处理读操作的流程 1.最开始incomingBuffer就是lenBuffer,容量为4.第一次读取4个字节,即此次请求报文的长度 2.根据请求报文的长度分配incomingBuffer的大小 3.将读到的字节存放在incomingBuffer中,直至读满 (由于第2步中为incomingBuffer分配的长度刚好是报文的长度,此时incomingBuffer中刚好时一个报文) 4.处理报文 */ if (k.isReadable()) { //若是客户端请求,此时触发读事件 //初始化时incomingBuffer即时lengthBuffer,只分配了4个字节,供用户读取一个int(此int值就是此次请求报文的总长度) int rc = sock.read(incomingBuffer); if (rc < 0) { throw new EndOfStreamException( "Unable to read additional data from client sessionid 0x" + Long.toHexString(sessionId) + ", likely client has closed socket"); } /* 只有incomingBuffer.remaining() == 0,才会进行下一步的处理,否则一直读取数据直到incomingBuffer读满,此时有两种可能: 1.incomingBuffer就是lenBuffer,此时incomingBuffer的内容是此次请求报文的长度. 根据lenBuffer为incomingBuffer分配空间后调用readPayload(). 在readPayload()中会立马进行一次数据读取,(1)若可以将incomingBuffer读满,则incomingBuffer中就是一个完整的请求,处理该请求; (2)若不能将incomingBuffer读满,说明出现了拆包问题,此时不能构造一个完整的请求,只能等待客户端继续发送数据,等到下次socketChannel可读时,继续将数据读取到incomingBuffer中 2.incomingBuffer不是lenBuffer,说明上次读取时出现了拆包问题,incomingBuffer中只有一个请求的部分数据. 而这次读取的数据加上上次读取的数据凑成了一个完整的请求,调用readPayload() */ if (incomingBuffer.remaining() == 0) { boolean isPayload; if (incomingBuffer == lenBuffer) { // start of next request //解析上文中读取的报文总长度,同时为"incomingBuffer"分配len的空间供读取全部报文 incomingBuffer.flip(); //为incomeingBuffer分配空间时还包括了判断是否是"4字命令"的逻辑 isPayload = readLength(k); incomingBuffer.clear(); } else { //2.incomingBuffer不是lenBuffer,此时incomingBuffer的内容是payload // continuation isPayload = true; } if (isPayload) { // not the case for 4letterword //处理报文 readPayload(); } else { // four letter words take care // need not do anything else return; } } } ... } catch (CancelledKeyException e) { ... } }
/** * 有两种情况会调用此方法: * 1.根据lengthBuffer的值为incomingBuffer分配空间后,此时尚未将数据从socketChannel读取至incomingBuffer中 * 2.已经将数据从socketChannel中读取至incomingBuffer,且读取完毕 * <p> * Read the request payload (everything following the length prefix) */ private void readPayload() throws IOException, InterruptedException { // have we read length bytes? if (incomingBuffer.remaining() != 0) { // sock is non-blocking, so ok //对应情况1,此时刚为incomingBuffer分配空间,incomingBuffer为空,进行一次数据读取 //(1)若将incomingBuffer读满,则直接进行处理; //(2)若未将incomingBuffer读满,则说明此次发送的数据不能构成一个完整的请求,则等待下一次数据到达后调用doIo()时再次将数据 //从socketChannel读取至incomingBuffer int rc = sock.read(incomingBuffer); if (rc < 0) { throw new EndOfStreamException( "Unable to read additional data from client sessionid 0x" + Long.toHexString(sessionId) + ", likely client has closed socket"); } } // have we read length bytes? if (incomingBuffer.remaining() == 0) { //不管是情况1还是情况2,此时incomingBuffer已读满,其中内容必是一个request,处理该request //更新统计值 packetReceived(); incomingBuffer.flip(); if (!initialized) { //处理连接请求 readConnectRequest(); } else { //处理普通请求 readRequest(); } //请求处理结束,重置lenBuffer和incomingBuffer lenBuffer.clear(); incomingBuffer = lenBuffer; } }
解决粘包拆包的思路如上所述,代码中增加了很多注释.
个人认为,上述数据读取过程一次至多读取一个请求,即使在此次可读取的数据中包含多个请求也是如此.而TCP报文的MSS一般为1460,客户端的请求为50~100字节,在客户端请求非常频繁时,一个TCP报文完全可以包含多个请求.
为了解决该问题,可以增加一个属性outgoingIncomingBuffer
,其数据类型为List<ByteBuffer>
用于存放此次读取的完整的请求,这样就可将此次可读取的数据全部读取完毕,无需等到下一次selector.select()
,减轻了selector.select()
的负担.
当SocketChannel
可写时,worker thread调用NIOServerCnxn.doIO()
进行写操作
DirectByteBuffer
由于Zookeeper中使用了DirectByteBuffer进行IO操作,在此简单介绍下DirectByteBuffer
和HeapByteBuffer
的区别. HeapByteBuffer
是在堆上分配的内存,使用HeapByteBuffer
进行IO时,比如调用FileChannel.write(HeapByteBuffer)
将数据写到File中时,操作系统会将HeapByteBuffer
的数据拷贝到堆外内存,再从堆外内存拷贝到文件中.
并不是说操作系统无法直接访问jvm中分配的内存区域,显然操作系统是可以访问所有的本机内存区域的,但是为什么对io的操作都需要将jvm内存区的数据拷贝到堆外内存呢?是因为jvm需要进行GC,如果io设备直接和jvm堆上的数据进行交互,这个时候jvm进行了GC,那么有可能会导致没有被回收的数据进行了压缩,位置被移动到了连续的存储区域,这样会导致正在进行的io操作相关的数据全部乱套,显然是不合理的,所以对io的操作会将jvm的数据拷贝至堆外内存,然后再进行处理,将不会被jvm上GC的操作影响。
而如果使用DirectByteBuffer
,就减少了将数据从堆内内存拷贝到堆外内存这一步骤.
注:在网上的资料中,有些说在进行IO时,还会将数据从用户空间拷贝到内核空间,由于本人对相关知识不了解,不能判定此消息是否准确.但即使存在将数据从用户空间拷贝到内核空间这一步骤,由于堆内内存需要进行GC,因此用户空间指的也是将HeapByteBuffer
拷贝到堆外内存之后堆外内存空间,不影响DirectByteBuffer
相较于HeapByteBuffer
少了一次内存拷贝的性能优势
/** * 使用其执行高效的socket I/O,由于I/O由worker thread执行,因此将直接内存设置为ThreadLocal的. * 各连接可以在共享直接内存的同时无需担心并发问题. * <p> * We use this buffer to do efficient socket I/O. Because I/O is handled * by the worker threads (or the selector threads directly, if no worker * thread pool is created), we can create a fixed set of these to be * shared by connections. */ private static final ThreadLocal<ByteBuffer> directBuffer = new ThreadLocal<ByteBuffer>() { @Override protected ByteBuffer initialValue() { return ByteBuffer.allocateDirect(directBufferBytes); } };
在NIOServerCnxnFactory
中,设置了ThreadLocal
类型的DirectByteBuffer
,其容量由系统属性zookeeper.nio.directBufferBytes
控制,默认为64K.
/** * 当{@link #sock}可写时调用该方法 * * @param k {@link #sock}关联的SelectionKey */ void handleWrite(SelectionKey k) throws IOException, CloseRequestException { if (outgoingBuffers.isEmpty()) { return; } /* * 尝试获取直接内存 */ ByteBuffer directBuffer = NIOServerCnxnFactory.getDirectBuffer(); if (directBuffer == null) { //不使用直接内存 ByteBuffer[] bufferList = new ByteBuffer[outgoingBuffers.size()]; sock.write(outgoingBuffers.toArray(bufferList)); // Remove the buffers that we have sent ByteBuffer bb; while ((bb = outgoingBuffers.peek()) != null) { if (bb == ServerCnxnFactory.closeConn) { throw new CloseRequestException("close requested"); } if (bb.remaining() > 0) { break; } packetSent(); outgoingBuffers.remove(); } } else { //使用直接内存 directBuffer.clear(); for (ByteBuffer b : outgoingBuffers) { if (directBuffer.remaining() < b.remaining()) { /* * 若directBuffer的剩余可写空间不足以容纳b的所有数据,则修改b的limit为directBuffer的剩余可写空间. * 这样下面的复制代码刚好将directBuffer的可写空间写满 */ b = (ByteBuffer) b.slice().limit(directBuffer.remaining()); } /* * put()会修改b和directBuffer的position值,但是我们不能修改b的position值, * 因为下文需要position的值将已发送的数据移出outgoingBuffers,因此在复制结束后重置position值. * */ int p = b.position(); //将b中的数据复制到directBuffer中 directBuffer.put(b); b.position(p); if (directBuffer.remaining() == 0) { break; } } /* * Do the flip: limit becomes position, position gets set to * 0. This sets us up for the write. */ directBuffer.flip(); //返回发送的字节数,下文据此移除已发送的数据 int sent = sock.write(directBuffer); ByteBuffer bb; // 将已发送的buffers从outgoingBuffers中移除 while ((bb = outgoingBuffers.peek()) != null) { if (bb == ServerCnxnFactory.closeConn) { throw new CloseRequestException("close requested"); } if (sent < bb.remaining()) { /* * 只发送了此Buffer的部分数据,因此修改position的值并退出循环 */ bb.position(bb.position() + sent); break; } packetSent(); //该buffer的数据已经全部发送,将buffer从outgoingBuffers中移除 sent -= bb.remaining(); outgoingBuffers.remove(); } } }
从代码中可以看出,若分配了直接内存,则优先使用直接内存发送数据.此外,从outgoingBuffers
中获取待发送的数据,outgoingBuffers
作用是将构造响应和发送响应解耦(即处理请求获取响应和将响应发送给客户端两个操作异步执行).响应构造成功后就添加至outgoingBuffers
中,当可以发送数据时,就从outgoingBuffers
中获取数据发送.
通过sendBuffer()
将待发送的数据添加至outgoingBuffers
中,很多方法都会调用sendBuffer()
,如NIOServerCnxn.sendResponse()
,NIOServerCnxn.sendCloseSession()
,ZookeeperServer.finishSessionInit()
等,其中FinalRequestProcessor
处理完请求后调用NIOServerCnxn.sendResponse()
.
/** * sendBuffer pushes a byte buffer onto the outgoing buffer queue for * asynchronous writes. */ @Override public void sendBuffer(ByteBuffer bb) { if (LOG.isTraceEnabled()) { LOG.trace("Add a buffer to outgoingBuffers, sk " + sk + " is valid: " + sk.isValid()); } outgoingBuffers.add(bb); requestInterestOpsUpdate(); }
- 粘包拆包问题的解决
- 直接内存的使用
- 异步的思想:在请求处理链线程中构造响应,在worker thread中发送响应,线程间通过
outgoingBuffers
通信,将构造响应和发送响应异步化