背景
Zookeeper作为一个服务器,自然要与客户端进行网络通信,如何高效的与客户端进行通信,让网络IO不成为ZooKeeper的瓶颈是ZooKeeper急需解决的问题,ZooKeeper中使用ServerCnxnFactory
管理与客户端的连接,其有两个实现,一个是NIOServerCnxnFactory
,使用Java原生NIO实现;一个是NettyServerCnxnFactory
,使用netty实现;使用ServerCnxn
代表一个客户端与服务端的连接.
ServerCnxnFactory
注:下文或注释中的连接就是客户端发起的TCP连接,也即SocketChannel
类
ZooKeeper可以通过设置系统属性zookeeper.serverCnxnFactory
配置ServerCnxnFactory的实现类,默认使用NIOServerCnxnFactory
NIOServerCnxnFactory
实现思路
一般使用Java NIO的思路为使用1个线程组监听OP_ACCEPT
事件,负责处理客户端的连接;使用1个线程组监听客户端连接的OP_READ
和OP_WRITE
事件,处理IO事件(netty便是如此实现).
但ZooKeeper并不是如此划分线程功能的,NIOServerCnxnFactory
启动时会启动四类线程
- accept thread:该线程接收来自客户端的连接,并将其分配给selector thread(启动一个线程)
- selector thread:该线程执行select(),由于在处理大量连接时,select()会成为性能瓶颈,因此启动多个selector thread,使用系统属性
zookeeper.nio.numSelectorThreads
配置该类线程数,默认个数为 核心数/2−−−−−−−√ 核 心 数 / 2 (至少一个) - worker thread:该线程执行基本的套接字读写,使用系统属性
zookeeper.nio.numWorkerThreads
配置该类线程数,默认为 核心数∗2 核 心 数 ∗ 2 .如果该类线程数为0,则另外启动一线程进行IO处理,见下文worker thread介绍 - connection expiration thread:若连接上的session已过期,则关闭该连接
可以看出,ZooKeeper中对线程需要处理的工作做了更细的拆分.其认为在有大量客户端连接的情况下,selector.select()
会成为性能瓶颈,因此其将selector.select()
拆分出来,交由selector thread处理.
线程间通信
上述各类线程之间通过同步队列通信.这一小节我们看下各类线程通信使用哪几个同步队列?各有什么用处
SelectorThread.acceptedQueue
acceptedQueue
是LinkedBlockingQueue<SocketChannel>
类型的,在selector thread中.其中包含了accept thread接收的客户端连接,由selector thread负责将客户端连接注册到selector上,监听OP_READ
和OP_WRITE
.
SelectorThread.updateQueue
updateQueue
和acceptedQueue
一样,也是LinkedBlockingQueue<SocketChannel>
类型的,在selector thread中.但是要说明白该队列的作用,就要对Java NIO的实现非常了解了.
Java NIO使用epoll
系统调用,且是水平触发,也即若selector.select()
发现socketChannel
中有事件发生,比如有数据可读,只要没有将这些数据从socketChannel
读取完毕,下一次selector.select()
还是会检测到有事件发生,直至数据被读取完毕.
ZooKeeper一直认为selector.select()
是性能的瓶颈,为了提高selector.select()
的性能,避免上述水平触发模式的缺陷,ZooKeeper在处理IO的过程中,会让socketChannel
不再监听OP_READ
和OP_WRITE
事件,这样就可以减轻selector.select()
的负担.
此时便出现一个问题,IO处理完毕后,如何让socketChannel
再监听OP_READ
和OP_WRITE
事件?
有的小伙伴可能认为这件事情非常容易,worker thread处理IO结束后,直接调用key.interestOps(OP_READ & OP_WRITE)
不就可以了吗?事情并没有这简单,是因为selector.select()
是在selector thread中执行的,若在selector.select()
的过程中,worker thread调用了key.interestOps(OP_READ & OP_WRITE)
,可能会阻塞selector.select()
.ZooKeeper为了追求性能的极致,设计为由selector thread调用key.interestOps(OP_READ & OP_WRITE)
,因此worker thread就需在IO处理完毕后告诉selector thread该socketChannel
可以去监听OP_READ
和OP_WRITE
事件了,updateQueue
就是存放那些需要监听OP_READ
和OP_WRITE
事件的socketChannel
.
NIOServerCnxn.outgoingBuffers
outgoingBuffers
存放待发送给客户端的响应数据.
注:个人推测,既然key.interestOps(OP_READ & OP_WRITE)
会阻塞selector.select()
,那么accepted.register(selector, SelectionKey.OP_READ)
也会阻塞selector.select()
,因此接收到的客户端连接注册到selector
上也要在selector thread上执行,这也是acceptedQueue
存在的理由
accept thread
/**
* 接收新的socket连接,每个IP地址有其连接个数上限.使用轮询为新连接分配selector thread
* @return whether was able to accept a connection or not
*/
private boolean doAccept() {
boolean accepted = false;
SocketChannel sc = null;
try {
sc = acceptSocket.accept();
accepted = true;
//防止来自一个IP地址的连接过多
InetAddress ia = sc.socket().getInetAddress();
int cnxncount = getClientCnxnCount(ia);
//判断是否最大客户端连接的限制
if (maxClientCnxns > 0 && cnxncount >= maxClientCnxns) {
throw new IOException("Too many connections from " + ia
+ " - max is " + maxClientCnxns);
}
LOG.info("Accepted socket connection from "
+ sc.socket().getRemoteSocketAddress());
sc.configureBlocking(false);
// Round-robin assign this connection to a selector thread
//使用轮询将连接分派给某个selector线程
if (!selectorIterator.hasNext()) {
selectorIterator = selectorThreads.iterator();
}
SelectorThread selectorThread = selectorIterator.next();
//将新连接加入selector thread 的acceptedQueue中
if (!selectorThread.addAcceptedConnection(sc)) {
throw new IOException(
"Unable to add connection to selector queue"
+ (stopped ? " (shutdown in progress)" : ""));
}
acceptErrorLogger.flush();
} catch (IOException e) {
// accept, maxClientCnxns, configureBlocking
acceptErrorLogger.rateLimitLog(
"Error accepting new connection: " + e.getMessage());
fastCloseSock(sc);
}
return accepted;
}
}
在accept thread 的run()
中,其执行selector.select()
,并调用doAccept()
接收客户端连接,将其添加至SelectorThread.acceptedQueue()
selector thread
@Override
public void run() {
try {
while (!stopped) {
try {
//1.调用select()读取就绪的IO事件,交由worker thread处理
select();
//2.处理accept线程新分派的连接,
// (1)将新连接注册到selector上;(2)包装为NIOServerCnxn后注册到NIOServerCnxnFactory中
processAcceptedConnections();
//3.更新updateQueue中连接的监听事件
processInterestOpsUpdateRequests();
} catch (RuntimeException e) {
LOG.warn("Ignoring unexpected runtime exception", e);
} catch (Exception e) {
LOG.warn("Ignoring unexpected exception", e);
}
}
//执行清理操作,关闭所有在selector上等待的连接
...
} finally {
...
//清理工作
}
}
在selector thread的run()
中,主要执行3件事情
- 调用select()读取就绪的IO事件,交由worker thread处理(在交由worker thread 处理之前会调用
key.interestOps(0)
) - 处理accept线程新分派的连接,
(1)将新连接注册到selector上;
(2)包装为NIOServerCnxn后注册到NIOServerCnxnFactory中 - 更新updateQueue中连接的监听事件
worker thread
ZooKeeper中通过WorkerService
管理一组worker thread线程,其有两种管理模式:
模式名 | 解释 | 使用场景 | 实现 |
---|---|---|---|
可指定线程模式 | 将任务指定由某一线程完成,若一系列任务需有序完成,可使用此种模式,将需按序完成的任务指定到同一线程 | 同一会话下的一系列请求 | 生成N个ExecutorService,每个ExecutorService只包含一个线程 |
不可指定线程模式 | 任务提交后,由WorkerService随机指定线程完成,任务之间无顺序要求则使用该模式 | 执行网络IO | 生成1个ExecutorService,其中有N个线程 |
由于各连接的网络IO任务之间无顺序要求,NIOServerCnxnFactory
使用的WorkerService
采用不可指定线程模式.
/**
* Schedule work to be done by the thread assigned to this id. Thread
* assignment is a single mod operation on the number of threads. If a
* worker thread pool is not being used, work is done directly by
* this thread.
* 根据id取模将workRequest分配给对应的线程.如果没有使用worker thread
* (即numWorkerThreads=0),则启动ScheduledWorkRequest线程完成任务,当前
* 线程阻塞到任务完成.
*
* @param workRequest 待处理的IO请求
* @param id 根据此值选择使用哪一个thread处理workRequest
*/
public void schedule(WorkRequest workRequest, long id) {
if (stopped) {
workRequest.cleanup();
return;
}
ScheduledWorkRequest scheduledWorkRequest =
new ScheduledWorkRequest(workRequest);
// If we have a worker thread pool, use that;
// otherwise, do the work directly.
int size = workers.size();
if (size > 0) {
try {
// make sure to map negative ids as well to [0, size-1]
int workerNum = ((int) (id % size) + size) % size;
ExecutorService worker = workers.get(workerNum);
worker.execute(scheduledWorkRequest);
} catch (RejectedExecutionException e) {
LOG.warn("ExecutorService rejected execution", e);
workRequest.cleanup();
}
} else {
// When there is no worker thread pool, do the work directly
// and wait for its completion
scheduledWorkRequest.start();
try {
scheduledWorkRequest.join();
} catch (InterruptedException e) {
LOG.warn("Unexpected exception", e);
Thread.currentThread().interrupt();
}
}
}
在上文介绍worker thread时,说”如果该类线程数为0,则使用selector thread 直接执行IO读写”,但从上面源码可以看出,若worker thread个数为0,为每个网络IO启动一个线程去执行,且主线程阻塞都到网络IO执行完毕,这简直是浪费资源,既然要阻塞到网络IO执行完毕,为何还要单独启动一个线程?个人认为可能是遗留代码或为日后扩展做准备,才会有如此不合理的代码.因此一定不能将worker thread的个数设置为0.
我们继续看ScheduledWorkRequest
是如何处理网络IO的
@Override
public void run() {
try {
// Check if stopped while request was on queue
if (stopped) {
workRequest.cleanup();
return;
}
workRequest.doWork();
} catch (Exception e) {
LOG.warn("Unexpected exception", e);
workRequest.cleanup();
}
}
@Override
public void doWork() throws InterruptedException {
//如果Channel已经关闭则清理该SelectionKey
if (!key.isValid()) {
selectorThread.cleanupSelectionKey(key);
return;
}
//1.如果可读或可写,则调用NIOServerCnxn.doIO()方法,通知NIOServerCnxn连接对象进行IO读写及处理
if (key.isReadable() || key.isWritable()) {
//调用NIOServerCnxn的doIO()完成IO处理
cnxn.doIO(key);
// Check if we shutdown or doIO() closed this connection
//如果已经shutdown则关闭连接
if (stopped) {
cnxn.close();
return;
}
//如果Channel已经关闭则清理该SelectionKey
if (!key.isValid()) {
selectorThread.cleanupSelectionKey(key);
return;
}
//2.更新该会话的过期时间
touchCnxn(cnxn);
}
//3.已经处理完读写,重新标记该连接已准备好新的select事件监听
cnxn.enableSelectable();
//把该连接重新放到selectThread的updateQueue中,selectThread会在处理处理完所有Channel的读写和新连接后,更新此Channel的注册监听事件
if (!selectorThread.addInterestOpsUpdateRequest(key)) {
cnxn.close();
}
}
除去一些健壮性代码,主要完成3件事:
- NIOServerCnxn.doIO()方法,通知NIOServerCnxn连接对象进行IO读写及处理
- 更新该连接的过期时间
- 网络IO已处理完毕,修改
selectable
标志位和将socketChannel
添加至selector thread的updateQueue
中,其作用已在前文说明.
在selector thread处理accept thread接收的连接时,除了将新连接注册到selector上之外,还将连接包装为NIOServerCnxn
后注册到NIOServerCnxnFactory
中.NIOServerCnxn
是对客户端连接的封装,worker thread中调用NIOServerCnxn.doIO()
处理网络IO.详见ZooKeeper-客户端连接ServerCnxn之NIOServerCnxn
connection expiration thread
此线程用于清理过期的连接,主要方法如下:
@Override
public void run() {
try {
while (!stopped) {
long waitTime = cnxnExpiryQueue.getWaitTime();
if (waitTime > 0) {
Thread.sleep(waitTime);
continue;
}
for (NIOServerCnxn conn : cnxnExpiryQueue.poll()) {
conn.close();
}
}
} catch (InterruptedException e) {
LOG.info("ConnnectionExpirerThread interrupted");
}
}
此线程的工作原理详见Zookeeper-连接和会话的过期清理策略(ExpiryQueue)
NettyServerCnxnFactory
NettyServerCnxnFactory
使用netty进行网络IO,但是其使用netty3.*
版本,与4.*
版本的实现思路虽然一致,但API差别很大,为此不再深入研究NettyServerCnxnFactory
,简单介绍下其与NIOServerCnxnFactory
的不同.
不同点 | NIO | Netty |
---|---|---|
accept事件 | 启动1个accept thread | boss group处理accept事件,默认启动1个线程 |
select() | 启动select thread | 添加handler时调用addLast(EventExecutorGroup, ChannelHandler…),则handler处理IO事件会在EventExecutorGroup中进行 |
网络IO | 启动worker thread | 启动work group处理网络IO,默认启动 核心数∗2 核 心 数 ∗ 2 个线程 |
处理读事件 | 在worker thread中调用NIOServerCnxn.doIO()处理 | 在handler中处理读事件 |
粘包拆包 | 通过lenBuffer和incomingBuffer解决该问题,代码很复杂 | 插入处理粘包拆包的handler即可 |
处理写事件 | 执行FinalRP.processRequest()的线程与worker thread通过NIOServerCnxn.outgoingBuffers进行通信,由worker thread批量写 | netty天生支持异步写,若当前线程为EventLoop线程,则将待写入数据存放到ChannelOutboundBuffer中.若当前线程不是EventLoop线程,构造写任务添加至EventLoop任务队列中 |
直接内存 | 使用ThreadLocal的直接内存 | 记不太清楚netty中如何使用直接内存了,但netty支持直接内存,且使用较为方便 |
处理连接关闭 | 启动connection expiration thread管理连接 | 在handler中处理连接 |
注:上述区别是将netty4.*
版本与NIOServerCnxnFactory
的对比,由于ZooKeeper使用netty3.*
,因此其NettyServerCnxnFactory
中存在一些无用代码,比如处理粘包拆包的代码
从上述的比较中可以看出使用netty处理网络IO比基于Java NIO自己编码方便太多了,netty大法好~~
总结
总结下线程通信所用的三个队列:
- SelectorThread.acceptedQueue:accept thread和selector thread通信
- SelectorThread.updateQueue:worker thread和selector thread通信
- NIOServerCnxn.outgoingBuffers:worker thread和请求处理线程通信