ZooKeeper-客户端连接ServerCnxn之NIOServerCnxn

    • 背景
    • 处理read事件
      • 发生时机
      • 粘包拆包问题
      • 思考
    • 处理write事件
      • 发生时机
      • DirectByteBuffer
      • NIOServerCnxnFactory中的直接内存
      • 源码
    • 总结
    • 参考

背景

ServerCnxn代表了一个客户端与一个server的连接,其有两种实现,分别是NIOServerCnxnNettyServerCnxn,类图如下:
ZooKeeper-客户端连接ServerCnxn之NIOServerCnxn_第1张图片
本文介绍ZooKeeper是如何通过NIOServerCnxn实现网络IO的.

处理read事件

发生时机

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,且读取完毕
     * 

* 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用于存放此次读取的完整的请求,这样就可将此次可读取的数据全部读取完毕,无需等到下一次selector.select(),减轻了selector.select()的负担.

处理write事件

发生时机

SocketChannel可写时,worker thread调用NIOServerCnxn.doIO()进行写操作

DirectByteBuffer

由于Zookeeper中使用了DirectByteBuffer进行IO操作,在此简单介绍下DirectByteBufferHeapByteBuffer的区别.
HeapByteBuffer是在堆上分配的内存,而DirectByteBuffer是在堆外分配的内存,又称直接内存.使用HeapByteBuffer进行IO时,比如调用FileChannel.write(HeapByteBuffer)将数据写到File中时,有两个步骤:

  1. HeapByteBuffer的数据拷贝到DirectByteBuffer
  2. 再从堆外内存将数据写入到文件中.

问题1:为什么要将HeapByteBuffer的数据拷贝到DirectByteBuffer呢?不能将数据直接从HeapByteBuffer拷贝到文件中吗?

并不是说操作系统无法直接访问jvm中分配的内存区域,显然操作系统是可以访问所有的本机内存区域的,但是为什么对io的操作都需要将jvm内存区的数据拷贝到堆外内存呢?是因为jvm需要进行GC,如果io设备直接和jvm堆上的数据进行交互,这个时候jvm进行了GC,那么有可能会导致没有被回收的数据进行了压缩,位置被移动到了连续的存储区域,这样会导致正在进行的io操作相关的数据全部乱套,显然是不合理的,所以对io的操作会将jvm的数据拷贝至堆外内存,然后再进行处理,将不会被jvm上GC的操作影响。

问题2:DirectByteBuffer是相当于固定的内核buffer还是JVM进程内的堆外内存?
不管是Java堆还是直接内存,都是JVM进程通过malloc申请的内存,其都是用户空间的内存,只不过是JVM进程将这两块用户空间的内存用作不同的用处罢了.Java内存模型如下:
ZooKeeper-客户端连接ServerCnxn之NIOServerCnxn_第2张图片

问题3:将HeapByteBuffer的数据拷贝到DirectByteBuffer这一过程是操作系统执行还是JVM执行?
在问题2中已经回答,DirectByteBuffer是JVM进程申请的用户空间内存,其使用和分配都是由JVM进程管理,因此这一过程是JVM执行的.也正是因为JVM知道堆内存会经常GC,数据地址经常移动,而底层通过write,read,pwrite,pread等函数进行系统调用时,需要传入buffer的起始地址和buffer count作为参数,因此JVM在执行读写时会做判断,若是HeapByteBuffer,就将其拷贝到直接内存后再调用系统调用执行步骤2.
代码在sun.nio.ch.IOUtil.write()sun.nio.ch.IOUtil.read()中,我们看下write()的代码:
知乎不能复制,代码地址如下:Java NIO direct buffer的优势在哪儿?,第一个答案中有代码.

问题4:在将数据写到文件的过程中需要将数据拷贝到内核空间吗?
需要.在步骤3中,是不能直接将数据从直接内存拷贝到文件中的,需要将数据从直接内存->内核空间->文件,因此使用DirectByteBuffer代替HeapByteBuffer也只是减少了数据拷贝的一个步骤,但对性能已经有提升了.

问题5:还有其他减少数据拷贝的方法吗?
有,我目前知道的有两种,分别是sendFile系统调用内存映射.
比如想要将数据从磁盘文件发送到socket,使用read/write系统调用需要将数据从磁盘文件->read buffer(内核空间中)->用户空间->socket buffer(也在内核空间中)->NIC buffer(网卡),而使用sendFile(即FileChannel.transferTo())系统调用就可减少复制到用户空间的过程,变为数据从磁盘文件->read buffer(内核空间中)->socket buffer(也在内核空间中)->NIC buffer(网卡),当然,还会有其他的优化手段,详见什么是Zero-Copy?
内存映射我也不是很清楚,详见JAVA NIO之浅谈内存映射文件原理与DirectMemory

问题6:netty中使用了哪几种方式实现高效IO?
netty中使用了3种方式实现其zero-copy机制,如下:

  1. 使用DirectByteBuffer
  2. 使用FileChannel.transferTo()
  3. ntty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer

NIOServerCnxnFactory中的直接内存

    /**
     * 使用其执行高效的socket I/O,由于I/O由worker thread执行,因此将直接内存设置为ThreadLocal的.
     * 各连接可以在共享直接内存的同时无需担心并发问题.
     * 

* 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 directBuffer = new ThreadLocal() { @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详解
  7. 什么是Zero-Copy?
  8. Java NIO direct buffer的优势在哪儿?
  9. Netty 中的零拷贝(Zero-Copy)机制
  10. JAVA NIO之浅谈内存映射文件原理与DirectMemory

你可能感兴趣的:(ZooKeeper)