ServerCnxn
代表了一个客户端与一个server的连接,其有两种实现,分别是NIOServerCnxn
和NettyServerCnxn
,类图如下:
本文介绍ZooKeeper是如何通过NIOServerCnxn
实现网络IO的.
当SocketChannel
上有数据可读时,worker thread调用NIOServerCnxn.doIO()
进行读操作
处理读事件比较麻烦的问题就是通过TCP发送的报文会出现粘包拆包问题,Zookeeper为了解决此问题,在设计通信协议时将报文分为3个部分:
注:(1)请求头和请求体也细分为更小的部分,但在此不做深入研究,只需知道请求的前4个字节是请求头和请求体的长度即可.(2)将请求头和请求体称之为payload
在报文头增加了4个字节的长度字段,表示整个报文除长度字段之外的长度.服务端可根据该长度将粘包拆包的报文分离或组合为完整的报文.NIOServerCnxn
读取数据流程如下:
代码如下:
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()
的负担.
当SocketChannel
可写时,worker thread调用NIOServerCnxn.doIO()
进行写操作
由于Zookeeper中使用了DirectByteBuffer
进行IO操作,在此简单介绍下DirectByteBuffer
和HeapByteBuffer
的区别.
HeapByteBuffer
是在堆上分配的内存,而DirectByteBuffer
是在堆外分配的内存,又称直接内存.使用HeapByteBuffer
进行IO时,比如调用FileChannel.write(HeapByteBuffer)
将数据写到File中时,有两个步骤:
HeapByteBuffer
的数据拷贝到DirectByteBuffer
问题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内存模型如下:
问题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
机制,如下:
DirectByteBuffer
FileChannel.transferTo()
/**
* 使用其执行高效的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();
}
outgoingBuffers
通信,将构造响应和发送响应异步化