前情提要
上一篇【Zookeeper 源码解读系列, 单机模式(一)】我们介绍了在单机模式下ZK是如何连接,如何初始化的,然后我们遗留一个问题:什么时候取出outgoingQueue里的数据呢?以及我们为了展示连接的过程而在SendThread.run()中略过了一大部分的代码。所以这一篇就是重点讲解连接之后做了什么,既然如此我们还是回到SendThread.run()里面接着探究连接之后做什么。本篇也会被收录到【Zookeeper 源码解读系列目录】中。
探究SendThread
上篇我们讲到客户端是要等待服务端返回的结果的,那么我们现在要做的就是解决问题:什么时候消费outgoingQueue这个保存packet的队列呢?带着这个问题,我们再来看SendThread.run(),这个类早就初始化完毕,而且已经start(),所以我们就可以接着执行后面的语句了。这里可能会有同学问,不是说被阻塞掉了等待服务端返回的吗,怎么就又运行了。其实是这样的,多线程编程,我们要看它阻塞的是什么,这里很明显阻塞住的是main.run()
,而不是SendTread.run()
,这个务必要区分出来,后面还会有不少同步执行的线程,这个如果理不清楚的话,看代码是比较难懂的,Sendthread.run()
在阻塞之前就已经运行了,是没有关系的,那我们看代码Sendthread.run()
,我们直接到if (state.isConnected())
这里面,如果连接成功了,会怎么样。
public void run() {
/**初始化,略**/
while (state.isAlive()) {
try {
/**连接,已经讲过略过**/
if (state.isConnected()) { //如果连接成功了
if (zooKeeperSaslClient != null) {
/**Sasl验证模式,不是主逻辑,略过**/
}
to = readTimeout - clientCnxnSocket.getIdleRecv();//to读取超时的变量
} else {
to = connectTimeout - clientCnxnSocket.getIdleRecv();//to连接超时的变量
}
if (to <= 0) {//如果超时了
String warnInfo;
warnInfo = "Client session timed out, have not heard from server in "
+ clientCnxnSocket.getIdleRecv() + "ms" + " for sessionid 0x"
+ Long.toHexString(sessionId);
LOG.warn(warnInfo);
//会报SessionTimeoutException的异常,被外面的try捕获
throw new SessionTimeoutException(warnInfo);
}
if (state.isConnected()) { //没有超时,连接成功
/**暂时略,后面讲这部分**/
}
if (state == States.CONNECTEDREADONLY) {
/**只读模式,略过**/
}
//这个方法就是发送数据用的
clientCnxnSocket.doTransport(to, pendingQueue, outgoingQueue, ClientCnxn.this);
} catch (Throwable e) {
/**catch异常,略,后面单独贴出来**/
}
}
cleanup();
clientCnxnSocket.close();
if (state.isAlive()) {
eventThread.queueEvent(new WatchedEvent(Event.EventType.None,
Event.KeeperState.Disconnected, null));
}
ZooTrace.logTraceMessage(LOG, ZooTrace.getTextTraceLevel(),
"SendThread exited loop for session: 0x"
+ Long.toHexString(getSessionId()));
}
进入if (state.isConnected())
语句以后,我们先忽略掉Sasl认证问题因为这部分不是主逻辑,看到了一个变量to
,这个是干嘛的呢?这个变量就是判断是不是连接超时了。getIdleRecv()
这个方法是计算距离上次读取数据的时间,那么这句话就很好理解了readTimeout - clientCnxnSocket.getIdleRecv()
拿我们 “初始化的超时时间” - “已经读取数据的时间” 算出来一个是否超时的标记,如果计算出来的to<0
,那就是读取数据超时了,如果to>0
,说明数据正常读取了。紧接着的else
里面的to
也是一样,如果没有连接成功,那么to = connectTimeout - clientCnxnSocket.getIdleRecv()
也会算一下是不是连接超时了,如果计算出来的to<0
,就说明连接超时了,如果to>0
,说明连接没有问题。如果小于0就会抛出SessionTimeoutException
异常,然后又下面的catch捕获处理。
重试机制
我们都知道Zookeeper有重试机制,那它是怎么做的呢,我们走到catch{**}块里:
try {
/**略**/
} catch (Throwable e) {
if (closing) {
/**如果Closing,break跳出**/
} else {
if (e instanceof SessionExpiredException) {
LOG.info(e.getMessage() + ", closing socket connection");
} else if (e instanceof SessionTimeoutException) {
//超时异常打一个log,继续走最外面的while语句,这里就是重试机制
LOG.info(e.getMessage() + RETRY_CONN_MSG);
} else if (e instanceof EndOfStreamException) {
LOG.info(e.getMessage() + RETRY_CONN_MSG);
} else if (e instanceof RWServerFoundException) {
LOG.info(e.getMessage());
} else if (e instanceof SocketException) {
LOG.info("Socket error occurred: {}: {}", serverAddress, e.getMessage());
} else {
LOG.warn("Session 0x{} for server {}, unexpected error{}", ***);
}
cleanup();
if (state.isAlive()) {
eventThread.queueEvent(new WatchedEvent(
Event.EventType.None,
Event.KeeperState.Disconnected,
null));
}
clientCnxnSocket.updateNow();
clientCnxnSocket.updateLastSendAndHeard();
}
}
可以看到,所有的Exception
其实都没有中断程序,只是打了一个log,然后就会跳回到最外层的while (state.isAlive())
继续循环,尝试进行第二次连接,然后在连接的部分,由于之前已经连结果一次,所以isFirstConnect
已经被修改为false,就会进入下面这个sleep里,睡眠一下就可以避免不停的去重试,没错这一系列操作就是Zookeeper的重试机制。
/****/
if(!isFirstConnect){
try {//随机一个睡眠时间,避免不停的去重试
Thread.sleep(r.nextInt(1000));
} catch (InterruptedException e) {
LOG.warn("Unexpected exception", e);
}
}
/****/
总结下重试机制:在SendThread里面通过不断地while循环,使用try-catch会把异常捕捉到,记录log之后,跳出进入下次循环进行再次连接。
outgoingQueue
过了这一小点,我们回到主流程里,连接失败的流程已经说过了,那么后面就开始介绍连接成功了会怎么样:
public void run() {
/**初始化,略**/
while (state.isAlive()) {
try {
/**连接,已经讲过略过**/
if (state.isConnected()) { //如果连接成功了
/**略过**/
}
if (to <= 0) {
/**如果超时了,会报SessionTimeoutException的异常,被外面的try捕获**/
}
if (state.isConnected()) { //没有超时,连接成功
int timeToNextPing = readTimeout / 2 - clientCnxnSocket.getIdleSend() -
((clientCnxnSocket.getIdleSend() > 1000) ? 1000 : 0);
if (timeToNextPing <= 0 || clientCnxnSocket.getIdleSend() > MAX_SEND_PING_INTERVAL) {
sendPing();//发送ping给服务端
clientCnxnSocket.updateLastSend();
} else {
if (timeToNextPing < to) {
to = timeToNextPing;
}
}
}
/**只读模式,略过**/
//这个方法就是发送数据用的
clientCnxnSocket.doTransport(to, pendingQueue, outgoingQueue, ClientCnxn.this);
} catch (Throwable e) {
/**catch异常,略**/
}
}
cleanup();
clientCnxnSocket.close();
if (state.isAlive()) {
eventThread.queueEvent(new WatchedEvent(Event.EventType.None,
Event.KeeperState.Disconnected, null));
}
/**LOG模式,略过**/
}
首先我们看到的就是sendPing()
,这里就是在一直发送ping给服务器,我们的客户端会一直发送ping
出去,发送ping
的过程也是在整个while
里面做的,因为只要连接成功整个while就相当于while(true)
,所以每次都会走到这个逻辑里面,就会不断地发送ping
出去,有兴趣的同学可以点进去看下,其实这里发送ping
的代码和输入的命令行(比如create命令
)一模一样,也是new了一个RequestHeader h = new RequestHeader(-2, OpCode.ping);
只不过这次的参数变了而已,然后调用queuePacket(h, null, null, null, null, null, null, null, null);
加到了outgoingQueue
里。
既然我们的ping
也发送出去了,现在要干什么?我们先逻辑上分析一下,到了这里我们的连接已经建立成功了以及我们想发送的数据都在outgoingQueue
里面,那么下一步很容易就想到:现在要从其中取数据,通过socket发送发出。所以说终于到了我们的重点发送数据clientCnxnSocket.doTransport(to, pendingQueue, outgoingQueue, ClientCnxn.this);
,从参数上看,这个方法也用到了我们一直再说的outgoingQueue
,但是这里又出现了一个pendingQueue
,这个queue也非常重要,我们慢慢分析。点进入以后发现是一个接口,我们去它是实现方法里看ClientCnxnSocketNIO.doTransport(***),好熟悉是不是,我们又回到了这个NIO的类里面来了:
void doTransport(int waitTimeOut, List<Packet> pendingQueue, LinkedList<Packet> outgoingQueue,
ClientCnxn cnxn)
throws ***Exception {
selector.select(waitTimeOut);
Set<SelectionKey> selected;
synchronized (this) {
selected = selector.selectedKeys();
}
updateNow();
//NIO selector的逻辑
for (SelectionKey k : selected) {
SocketChannel sc = ((SocketChannel) k.channel());
if ((k.readyOps() & SelectionKey.OP_CONNECT) != 0) {
if (sc.finishConnect()) {//查看哪个channel连接成功了,一旦发现一个连接成功的则进入if
updateLastSendAndHeard();//更新连接时间
sendThread.primeConnection();//发送连接,进入
}
} else if ((k.readyOps() & (SelectionKey.OP_READ | SelectionKey.OP_WRITE)) != 0) {
//如果连接成功,可以OP_READ读,可以写OP_WRITE
doIO(pendingQueue, outgoingQueue, cnxn);
}
}
if (sendThread.getZkState().isConnected()) {
synchronized(outgoingQueue) {
if (findSendablePacket(outgoingQueue,
cnxn.sendThread.clientTunneledAuthenticationInProgress()) != null) {
enableWrite();
}
}
}
selected.clear();
}
进入这个代码以后首先就看到一个for循环,这循环就是在实现NIO这个思想的Selector。因为Zookeeper使用了NIO的思想,所以其实这里的连接其实不是像我们想的一样立即就可以连接上的,他只是给连接绑定了一个事件,可以理解为异步的,我如果需要连接,只需要读取一个事件就可以了,这个事件就是在我们上一篇中讲的ClientCnxnSocketNIO.registerAndConnect()
中注册的:
void registerAndConnect(SocketChannel sock, InetSocketAddress addr)
throws IOException {
sockKey = sock.register(selector, SelectionKey.OP_CONNECT);//注册事件
boolean immediateConnect = sock.connect(addr);//有没有立刻成功
if (immediateConnect) {//成功了此处是true,执行连接
sendThread.primeConnection();
}
}
所以如果说,我当时没有立刻处理这个事件,那么我就相当于没有连接成功,那immediateConnect
就是false
,这也导致了 sendThread.primeConnection();
就不会被执行,如果没有被执行,我们后面的逻辑就没办法走通了,所以回到doTransport(***)
这个方法里面,这个条件就会走到if ((k.readyOps() & SelectionKey.OP_CONNECT) != 0)
这个语句块里,去检查哪个channel连接成功了,一旦发现一个连接成功的则进入if (sc.finishConnect())
再次连接一下。
doIO方法 与 outgoingQueue
如果说我们立刻连接成功了,并且发现既可以OP_READ读,也可以写OP_WRITE,那么就会跳过直接执行doIO(pendingQueue, outgoingQueue, cnxn)
,这里先简单说明下这个方法,顾名思义就是做IO操作的,参数outgoingQueue
存要执行的命令,pendingQueue
存等待服务器响应的命令,而且要注意,每一个连接有一对queue,不是一个,是一对,上代码:
void doIO(List<Packet> pendingQueue, LinkedList<Packet> outgoingQueue, ClientCnxn cnxn)
throws ***Exception {
SocketChannel sock = (SocketChannel) sockKey.channel();
if (sock == null) {
throw new IOException("Socket is null!");
}
if (sockKey.isReadable()) {//如果读就绪
/**暂时略过,稍后讲解**/
}
if (sockKey.isWritable()) {//写就绪
synchronized(outgoingQueue) {
//取出outgoingQueue里的数据存入packet中
Packet p = findSendablePacket(outgoingQueue,
cnxn.sendThread.clientTunneledAuthenticationInProgress());
if (p != null) {
updateLastSend();
if (p.bb == null) {//BB:ByteBuffer
/**判空,略过**/
p.createBB();//创建ByteBuffer,NIO中的buffer概念
}
sock.write(p.bb);//发送数据给服务端
if (!p.bb.hasRemaining()) {//如果BB中取不到了,也就是没有剩余的数据了,
sentCount++;
outgoingQueue.removeFirstOccurrence(p);//取出的命令移除
if (p.requestHeader != null
&& p.requestHeader.getType() != OpCode.ping
&& p.requestHeader.getType() != OpCode.auth) {
synchronized (pendingQueue) {
pendingQueue.add(p);//需要等结果存到pendingQueue
}
}
}
}
if (outgoingQueue.isEmpty()) {
/**空验证,略**/
}
}
}
}
我们到这个方法以后,发现里面有两个判断if (sockKey.isReadable())
和if (sockKey.isWritable())
。前一个是读就绪,sockKey.isReadable()==true
那么就能从channel里面读取数据了,就相当于服务端给我数据了我可以读了。后面一个是写就绪,sockKey.isWritable()==true
那么就能向channel里面写数据了,就相当于给服务端发送数据了。那么我们的outgoingQueue
肯定是就是发送数据了,所以我们就先来看写就绪,转到if (sockKey.isWritable())
这里来,立刻就可以发现用到了outgoingQueue
,而且还是被synchronized
锁住了。按照我们上面的逻辑这里就应该要取出数据发送了,往下看果不然findSendablePacket(***)
这个方法传入了outgoingQueue而且把返回参数封装成了packet,那么我们进入看下:
private Packet findSendablePacket(LinkedList<Packet> outgoingQueue,
boolean clientTunneledAuthenticationInProgress) {
synchronized (outgoingQueue) {
if (outgoingQueue.isEmpty()) {//空就返回空
return null;
}
if (outgoingQueue.getFirst().bb != null
|| !clientTunneledAuthenticationInProgress) {
//不是空取出第一个返回
return outgoingQueue.getFirst();
}
/**SASL相关的逻辑,略过**/
}
}
果然这里就是从outgoingQueue
里取出了数据,如果是isEmpty
就return null
,如果不是就返回第一个数据出去,这就符合我们刚刚分析的逻辑了。出去以后包装成packet
,继续走doIO(***)
的 流程,如果拿出来的数据不是null,就p.createBB()
创建ByteBuffer(这个就NIO思想里面Buffer的概念),然后通过sock.write(p.bb)
发送socket数据出去,这里就相当于把取出的packet写到了socket的buffer里面去,NIO的思想就是写数据写到buffer里,读数据也同样从buffer里面读,这里以后遇到类似的写法就不再详细解释了。往下走if (!p.bb.hasRemaining())
是不是packet里面的数据都已经发送出去了,如果packet里面没有剩余的数据都已经发送完了,进入这个if里,把取出的数据移除outgoingQueue.removeFirstOccurrence(p);
,这里必须要有移除,如果不移除,那我们在上面一直getFirst()取的全都是重复的数据了。再往下走又到了一个if判断如果requestHeader
不是null
,并且不是ping
类型(getType() != OpCode.ping)
,同时也不是验证类型(getType() != OpCode.auth)
,那么就把这个packet
加入到pendingQueue
里面。到这里我们已经解决了outgoingQueue
是干嘛的,怎么又出来了一个pendingQueue
,这个是干嘛的呢?
pendingQueue
其实我们分析到现在,outgoingQueue
是一个一个发送给服务端的指令queue
,那么pending从名字上来看肯定就是等待什么东西的,逻辑上看pendingQueue
可能是等待服务端返回指令结果的queue
,而且这两个里面存的都是packet。其实pendingQueue
就是存放已经发送出去了但是还没有结果的packect,所以进一步推断pendingQueue
就是在读结果的时候用的。这里就要到我们刚才没有讲解的读就绪的那里了:
void doIO(List<Packet> pendingQueue, LinkedList<Packet> outgoingQueue, ClientCnxn cnxn)
throws InterruptedException, IOException {
/**拿到socket,已经贴过,略**/
if (sockKey.isReadable()) {//如果读就绪
int rc = sock.read(incomingBuffer);//从buffer里面读出来
if (rc < 0) {
/**读出来的数据不合法,抛异常,略**/
}
if (!incomingBuffer.hasRemaining()) {//如果数据已经全部取到了
incomingBuffer.flip();
if (incomingBuffer == lenBuffer) {
recvCount++;
readLength();
} else if (!initialized) {//连接有没有初始化
readConnectResult();//读取连接请求的结果
enableRead();
if (findSendablePacket(outgoingQueue,
cnxn.sendThread.clientTunneledAuthenticationInProgress()) != null) {
enableWrite();
}
lenBuffer.clear();
incomingBuffer = lenBuffer;
updateLastHeard();
initialized = true;
} else {//如果初始化已经完成
//读取响应
sendThread.readResponse(incomingBuffer);//解析服务端发来的数据流
lenBuffer.clear();
incomingBuffer = lenBuffer;
updateLastHeard();
}
}
}
if (sockKey.isWritable()) {
/**写就绪,已经讲过,略**/
}
}
我们开始分析,首先incomingBuffer就是从NIO-channel中读取出来的数据,到下面如果说数据都取到了if (!incomingBuffer.hasRemaining())
进入这个判断往下走,发现又要判断初始化else if (!initialized)
这里的初始化是什么呢?这个其实是判断连接有没有初始化,我们知道NIO是个异步的,连接请求也只是一个事件,这就里是在看我又没有读到这个连接的事件,有没有初始化我当前的客户端。这个连接的请求是在哪发给客户端的呢?是在primeConnection()
这个方法里,在这个方法最后有一句outgoingQueue.addFirst(new Packet(null, null, conReq, null, null, readOnly));
有没有觉得很熟悉,没错,这个方法把连接请求作为一个事件,放在outgoingQueue
里发送连接请求给服务端。然后SendThread
这里如果发现客户端还没有initialized
,那么只要去把连接请求的结果读取出来readConnectResult()
就可以了,有兴趣的同学可以点进入看下,这里就不做赘述了。
那么我们接着走,如果初始化已经完成,走到下面的else
里,读取响应并且解析服务端发来的数据流sendThread.readResponse(incomingBuffer);
,我们发现还是sendThread
线程,说明这个线程是接收发送两用,并不只是发送,那我们进入readResponse(incomingBuffer)
看下这里面又是怎么写的:
void readResponse(ByteBuffer incomingBuffer) throws IOException {
ByteBufferInputStream bbis = new ByteBufferInputStream(
incomingBuffer);//序列化服务端的返回结果
BinaryInputArchive bbia = BinaryInputArchive.getArchive(bbis);
ReplyHeader replyHdr = new ReplyHeader();
//反序列化构建ReplyHeader,由xid决定执行哪个结果,此处拿到的已经是服务端返回的数据了
replyHdr.deserialize(bbia, "header");
if (replyHdr.getXid() == -2) {//xid=-2,ping命令
/** -2 is the xid for pings**/
}
if (replyHdr.getXid() == -4) {//xid=-4,auth命令,验证命令
/** -2 is the xid for AuthPacket**/
}
if (replyHdr.getXid() == -1) {//xid=-1,WatcherEvent的逻辑,执行事件
/** -1 means notification**/
}
/** SASL authentication 略过 **/
//如果上述xid没有配置上,那么说明这个命令是执行命令,比如create,delete等等
Packet packet;
synchronized (pendingQueue) {
if (pendingQueue.size() == 0) {//本地没有数据,说明服务端发错数据了报错
/**抛出IOException 略过**/
}
packet = pendingQueue.remove();//取出pendingQueue中的packet
}
try {
//如果这个本地的xid不等于返回的xid,报错
if (packet.requestHeader.getXid() != replyHdr.getXid()) {
/**抛出IOException 略过**/
}
//如果一样,就要从响应中读取数据,给本地数据赋值
packet.replyHeader.setXid(replyHdr.getXid());
packet.replyHeader.setErr(replyHdr.getErr());
packet.replyHeader.setZxid(replyHdr.getZxid());
if (replyHdr.getZxid() > 0) {
lastZxid = replyHdr.getZxid();
}
if (packet.response != null && replyHdr.getErr() == 0) {
//取出服务端的response给本地packet赋值,此时packet就包含了响应的数据
packet.response.deserialize(bbia, "response");
}
/**打log 略过**/
} finally {
finishPacket(packet);
}
}
进入以后首先从buffer里面读取出服务端返回的消息,然后解析成ReplyHeader
,这个里面存放的就是相应返回的数据,这里我们其实就碰到了一个很重要的属性xid
,这里我们先不详细的说,接着就有判断这个拿出来的返回是什么类型的命令,xid=-2
,ping命令;xid=-4
,auth命令,验证命令;xid=-1
,WatcherEvent的逻辑,执行订阅的事件等等。如果说拿出来的xid没有匹配上,那么就可以认为这个是从命令行那里输入进来的命令了。往下走,首先取出pendingQueue
里面的数据packet = pendingQueue.remove();
包装成packet,然后如果说,当前从pendingQueue
中取出的packet和服务端响应返回replyHdr
里面的packet的xid不一样if (packet.requestHeader.getXid() != replyHdr.getXid())
,那么就抛出异常。如果是一样的话,就要从响应中读取数据了,接下来连续的set()
方法就是再给本地数据赋值,首先从replyHdr
里面get()
到数据,然后原封不动的set()
到packet
里面。这里要提醒一点,这个packet是我们很早之前就已经生成好了,在哪里呢,就是在Zookeeper这个原生客户端被初始化的时候就已经生成好了,然后转移到了pendingQueue
里面,现在只是从pendingQueue
里面取出来给赋值而已。然后下面可以看到服务端返回的数据bbia
也被反序列化放到了本地的packet
的response
里面packet.response.deserialize(bbia, "response");
,此时这个本地的packet
也就包含了从客户端发来的响应数据了。全部走完以后,最总会走到finally
语句块中,我们进入finishPacket(packet)
看看里面的代码:
private void finishPacket(Packet p) {
//此时这个p已经是服务端传回来的packet
if (p.watchRegistration != null) {
/**事件注册,暂时略**/
}
if (p.cb == null) {
synchronized (p) {
p.finished = true;
p.notifyAll(); //唤醒
}
} else {/**eventThread相关,暂时略,下一篇会详细讲解**/
p.finished = true;
eventThread.queuePacket(p);
}
}
看看我们发现了什么p.notifyAll();
唤醒,这个时候各位可还记最开始有一个阻塞?如果能想起来的同学,肯定会恍然大悟,其实到了这里整个客户端的逻辑就已经通了。我们之前说过Zookeeper在ClientCnxn.submitRequest(***)
提交请求以后是会阻塞的,阻塞的正是packet:packet.wait();
,那么我们在这里拿到结果回来以后,在这里就被唤醒了。
总结
到这里我们客户端发送接收代码的讲解其实已经结束了,接下来就开始要看服务端怎么处理这些来自客户端发送的数据了,总结一下zk客户端启动,键入命令后做了什么:
启动ZK
- Zookeeper:初始化socket
- Zookeeper:命令->Request->Packet->outgoingQueue
- ClientCnxnSocketNIO.SendThread启动:
SendThread线程run():
while(true){- 如果socket没有连接,就回去连接,和重试机制,ping等等
- 如果socket链接成功,服务端会发送一个ConnectRequest,接受服务器返回的ConnectResponse(Event, None)返回一个event=None的事件 doTransport()
- 从outgoingQueue取packet,通过Socket发送出去,阻塞,同时如果说有需要等待结果的packet放到pendingQueue里 doIO()-写就绪
- 从服务端收到数据,唤醒doIO()-读就绪
}
SendThread流程图
而SendThread就做了这么一系列的事情,下面的图是一个SendThread完整的流程图,大部分都已经在本篇讲解,有一些以后会在下一篇【Zookeeper 源码解读系列, 单机模式(三)】涉及到: