ZooKeeper-客户端连接ServerCnxn

匿名 (未验证) 提交于 2019-12-03 00:34:01

ServerCnxn代表了一个客户端与一个server的连接,其有两种实现,分别是NIOServerCnxnNettyServerCnxn,类图如下:

本文介绍ZooKeeper是如何通过NIOServerCnxn实现网络IO的.

SocketChannel上有数据可读时,worker thread调用NIOServerCnxn.doIO()进行读操作

处理读事件比较麻烦的问题就是通过TCP发送的报文会出现粘包拆包问题,Zookeeper为了解决此问题,在设计通信协议时将报文分为3个部分:

  1. 请求头和请求体的长度(4个字节)
  2. 请求头
  3. 请求体

注:(1)请求头和请求体也细分为更小的部分,但在此不做深入研究,只需知道请求的前4个字节是请求头和请求体的长度即可.(2)将请求头和请求体称之为payload
在报文头增加了4个字节的长度字段,表示整个报文除长度字段之外的长度.服务端可根据该长度将粘包拆包的报文分离或组合为完整的报文.NIOServerCnxn读取数据流程如下:

  1. NIOServerCnxn中有两个属性,一个是lenBuffer,容量为4个字节,用于读取长度信息.一个是incomingBuffer,其初始化时即为lenBuffer,但是读取长度信息后,就为incomingBuffer分配对应的空间用于读取payload
  2. 根据请求报文的长度分配incomingBuffer的大小
  3. 将读到的字节存放在incomingBuffer中,直至读满(由于第2步中为incomingBuffer分配的长度刚好是报文的长度,此时incomingBuffer中刚好时一个报文)
  4. 处理报文

代码如下:

 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操作,在此简单介绍下DirectByteBufferHeapByteBuffer的区别.
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();     }
  1. 粘包拆包问题的解决
  2. 直接内存的使用
  3. 异步的思想:在请求处理链线程中构造响应,在worker thread中发送响应,线程间通过outgoingBuffers通信,将构造响应发送响应异步化
  1. 【Zookeeper】源码分析之网络通信(一)
  2. 【Zookeeper】源码分析之网络通信(二)之NIOServerCnxn
  3. zk源码阅读32:Server与Client的网络I/O(一):ServerCnxn
  4. zk源码阅读33:Server与Client的网络I/O(二):ServerCnxn子类NIOServerCnxn
  5. 堆外内存之 DirectByteBuffer 详解
  6. java-nio之HeapByteBuffer与DirectByteBuffer详解
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!