【原创作品:转载请注明出处】
zookeeper(zk)集群需要支撑大量客户端的接入,很多场景都是大量客户端同时接入,比如客户端集群重启、集群扩容等场景。zk管理客户端连接有两种方式可以选择:NIOServerCnxnFactory、NettyServerCnxnFactory。
static public ServerCnxnFactory createFactory() throws IOException {
//获取配置
String serverCnxnFactoryName =
System.getProperty(ZOOKEEPER_SERVER_CNXN_FACTORY);
//默认原生NIO
if (serverCnxnFactoryName == null) {
serverCnxnFactoryName = NIOServerCnxnFactory.class.getName();
}
try {
//加载指定的类工厂
ServerCnxnFactory serverCnxnFactory = (ServerCnxnFactory) Class.forName(serverCnxnFactoryName)
.getDeclaredConstructor().newInstance();
LOG.info("Using {} as server connection factory", serverCnxnFactoryName);
return serverCnxnFactory;
} catch (Exception e) {
//ignore
}
}
上面是创建ServerCnxnFactory的源码,默认会使用NIOServerCnxnFactory,想使用NettyServerCnxnFactory可以通过配置zookeeper.serverCnxnFactory实现。
NIOServerCnxnFactory是通过java原生的NIO实现的,下图是NIOServerCnxnFactory整体的工作流程
第一眼看到图可能会稍微有点复杂,稍加梳理就会发现非常简单和优雅。管理链接无非就以下几件事:
- 创建ServerSocket(open)
- 监听新连接的接入(accepet)
- 从连接中读取/向连接中写入数据(selector)
- 销毁连接(clean)
这个过程像极了很多客人来你家做客的的场景,首先你需要向客人敞开大门(open);客人来了你起码要迎接一下吧(accept);客人也不好意思空手来吧,你也不能不让客人吃点东西就走吧(selector);吃吃喝喝差不多,就该送客了,你就要清理战场了(clean)。
上图就是这上面的前三个流程,我们结合源码来看下具体的实现。
一、创建ServerSocket(open)
public void configure(InetSocketAddress addr, int maxcc, boolean secure) throws IOException {
....
this.ss = ServerSocketChannel.open();
ss.socket().setReuseAddress(true);
LOG.info("binding to port " + addr);
ss.socket().bind(addr);
ss.configureBlocking(false);
//创建appeptThread
acceptThread = new AcceptThread(ss, addr, selectorThreads);
}
二、 监听socket接入(accepet)
zk使用单线程(AcceptThread)来处理新连接的接入,AcceptThread本身其实就是一个Selector,ServerSocketChannel的OP_ACCEPT事件注册在了AcceptThread上。看下AcceptThread的run方法,在循环select(就是一直站在门口等客人)
public void run() {
try {
while (!stopped && !acceptSocket.socket().isClosed()) {
try {
select();
} catch (RuntimeException e) {
LOG.warn("Ignoring unexpected runtime exception", e);
} catch (Exception e) {
LOG.warn("Ignoring unexpected exception", e);
}
}
} finally {
...
}
}
**select()方法, 关键的动作还在doAccept()**方法里
selector.select();
Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator();
while (!stopped && selectedKeys.hasNext()) {
SelectionKey key = selectedKeys.next();
selectedKeys.remove();
if (!key.isValid()) {
continue;
}
if (key.isAcceptable()) {
if (!doAccept()) {
pauseAccept(10);
}
}
}
**doAccept()**方法,把代码贴出来,完全不需要额外的解释。接下来就看selectorThread的了,看图就知道核心的处理就在selectorThread中。
SocketChannel sc = null;
try {
sc = acceptSocket.accept();
InetAddress ia = sc.socket().getInetAddress();
//省略了一些限制连接数量的代码
// 轮询选择一个selectorThread
if (!selectorIterator.hasNext()) {
selectorIterator = selectorThreads.iterator();
}
SelectorThread selectorThread = selectorIterator.next();
//把socket分发给selectorThread
if (!selectorThread.addAcceptedConnection(sc)) {
throw new IOException();
}
} catch (IOException e) {
...
}
三、 socket读写事件处理(selector)
其实重点也就是这个Selector模块,也就是数据交换部分,NIO通过Selector单个线程来管理多个socket(nio部分不多做解释)。我们重点关注zk的设计细节
-
SelectorThread是多实例,zk认为监听数据的读写可能会成为性能的瓶颈,所以SelectorThread是多个实例。需要强调的是,SelectorThread只是负责socket的读写事件,具体的io处理会交给IOHandle线程池。
-
Selector结构,两个socket队列,新接入的sockket队列是acceptedQueue ,处理IO之后的队列是updateQueue;一个大的循环事件、大循环中包含了三个子循环。我们对照源码看一下。
/** * 新接入的socket队列,selector实例的属性,每个selector都有自己的属性 */ private final Queue<SocketChannel> acceptedQueue; private final Queue<SelectionKey> updateQueue; //run中的方法 while (!stopped) { try { //处理注册的事件 select(); //处理新连接 processAcceptedConnections(); //处理兴趣集更新 processInterestOpsUpdateRequests(); } catch (RuntimeException e) { LOG.warn("Ignoring unexpected runtime exception", e); } catch (Exception e) { LOG.warn("Ignoring unexpected exception", e); } } //省略部分代码 -
acceptedQueue队列的消费者。我们没有必要一行一行的看代码,不妨从数据的流转顺序来看 ,socket生产到了新接入的队列中,我们看看哪里消费了这个队列。从函数名很容易看出是processAcceptedConnections(),做的事情就是拉去队列中的socket、将socket的读事件注册到当前selector、分装socket。
这里有个细节,使用的是队列的非阻塞方法poll() 我们待会从整体看这里为什么可以这样设计
private void processAcceptedConnections() {
SocketChannel accepted;
while (!stopped && (accepted = acceptedQueue.poll()) != null) {
SelectionKey key = null;
try {
//注册读事件
key = accepted.register(selector, SelectionKey.OP_READ);
//分装socket
NIOServerCnxn cnxn = createConnection(accepted, key, this);
//绑定到key上
key.attach(cnxn);
//添加到连接map
addCnxn(cnxn);
} catch (IOException e) {
// register, createConnection
cleanupSelectionKey(key);
fastCloseSock(accepted);
}
}
}
- updateQueue的生产者。既然消费了新链接并且注册了读事件,那就一定要调用select()吧,正是循环事件1 select()方法,其实这里很简单,就是获取有读写事件的链接,然后交给handleIO()。但是我们注意下图中循环事件1,貌似还有生产socketChannel的操作,其实这部分是在handleIO 里,为啥还要把处理后的socket再次抛给一个新的队列?不着急,我们再看看谁在消费socketChannel队列不就完事了!
try {
selector.select();
Set<SelectionKey> selected = selector.selectedKeys();
ArrayList<SelectionKey> selectedList = new ArrayList<>(selected);
//why shuffle?
Collections.shuffle(selectedList);
Iterator<SelectionKey> selectedKeys = selectedList.iterator();
while (!stopped && selectedKeys.hasNext()) {
SelectionKey key = selectedKeys.next();
selected.remove(key);
if (!key.isValid()) {
cleanupSelectionKey(key);
continue;
}
if (key.isReadable() || key.isWritable()) {
handleIO(key);
} else {
LOG.warn("Unexpected ops in select " + key.readyOps());
}
}
} catch (IOException e) {
//ignore
}
- updateQueue的消费者。我猜你一定想到了是processInterestOpsUpdateRequests()在消费。没错,就是重新订阅了socket的读写事件。等等... IOhandle把socket抛给一个新队列就是为了执行key.interestOps(cnxn.getInterestOps())这一个操作?有必要?不能在handleIO之后直接执行key.interestOps(cnxn.getInterestOps())?答案是可以的,但是zk设计者出于性能的考虑,而将这个操作异步化了。
private void processInterestOpsUpdateRequests() {
SelectionKey key;
while (!stopped && (key = updateQueue.poll()) != null) {
if (!key.isValid()) {
cleanupSelectionKey(key);
}
NIOServerCnxn cnxn = (NIOServerCnxn) key.attachment();
if (cnxn.isSelectable()) {
key.interestOps(cnxn.getInterestOps());
}
}
}
- 总结。 我相信现在再回头看这张图,一定清晰了很多。最后提一下Selector处理中的一些细节
- 主循环的阻塞是依赖子循环1select()方法,而不是子循环2或者3的阻塞队列,思考一下是否可以使用阻塞队列。
- 在轮询分发时,有个wakeUp()的调用,这里是让子循环1立刻返回。
public boolean addAcceptedConnection(SocketChannel accepted) {
if (stopped || !acceptedQueue.offer(accepted)) {
return false;
}
wakeupSelector();
return true;
}