Netty 消息的读取和发送都是非阻塞模式,这是它相比于传统 BIO 最大的优势,下面我们一起分析下 Netty 是如何异步的处理读写操作的。
4.1 异步读取操作
NioEvnetLoop 作为 Reactor 线程,负责轮询多路复用器,获取就绪的通道执行网络的连接、客户端请求接入、读和写。当多路复用器检测到读操作后,执行如下方法:不同的 Channel 对应不同的NioUnsafe:
此 处 对 应 的 是 NioByteUnsafe, 下 面 我 们 进 入 它 的 父 类AbstractNioByteChannel 类进行详细分析:
@Override
public final void read() {
final ChannelConfig config = config();
if (!config.isAutoRead() && !isReadPending()) {
// ChannelConfig.setAutoRead(false) was called in the meantime
removeReadOp();
return;
}
final ChannelPipeline pipeline = pipeline();
final ByteBufAllocator allocator = config.getAllocator();
final int maxMessagesPerRead = config.getMaxMessagesPerRead();
RecvByteBufAllocator.Handle allocHandle = recvBufAllocHandle();
ByteBuf byteBuf = null;
int messages = 0;
boolean close = false;
try {
int totalReadAmount = 0;
boolean readPendingReset = false;
do {
byteBuf = allocHandle.allocate(allocator);
int writable = byteBuf.writableBytes();
int localReadAmount = doReadBytes(byteBuf);
if (localReadAmount <= 0) {
// not was read release the buffer
byteBuf.release();
byteBuf = null;
close = localReadAmount < 0;
break;
}
if (!readPendingReset) {
readPendingReset = true;
setReadPending(false);
}
pipeline.fireChannelRead(byteBuf);
byteBuf = null;
if (totalReadAmount >= Integer.MAX_VALUE - localReadAmount) {
// Avoid overflow.
totalReadAmount = Integer.MAX_VALUE;
break;
}
totalReadAmount += localReadAmount;
// stop reading
if (!config.isAutoRead()) {
break;
}
if (localReadAmount < writable) {
// Read less than what the buffer can hold,
// which might mean we drained the recv buffer completely.
break;
}
} while (++ messages < maxMessagesPerRead);
pipeline.fireChannelReadComplete();
allocHandle.record(totalReadAmount);
if (close) {
closeOnRead(pipeline);
close = false;
}
} catch (Throwable t) {
handleReadException(pipeline, byteBuf, t, close);
} finally {
// Check if there is a readPending which was not processed yet.
// This could be for two reasons:
// * The user called Channel.read() or ChannelHandlerContext.read() in channelRead(...) method
// * The user called Channel.read() or ChannelHandlerContext.read() in channelReadComplete(...) method
//
// See https://github.com/netty/netty/issues/2254
if (!config.isAutoRead() && !isReadPending()) {
removeReadOp();
}
}
}
首先,获取 NioSocketChannel 的 SocketChannelConfig,它主要用于设置客户端连接的 TCP 参数,接口如下:
如果首次调用,从 SocketChannelConfig的 RecvByteBufAllocator 中创建 Handle。下面我们对 RecvByteBufAllocator做 下 简 单 的 代 码 分 析:RecvByteBufAllocator 默 认 有 两 种 实 现, 分 别是:AdaptiveRecvByteBufAllocator 和 FixedRecvByteBufAllocator。
由 于 FixedRecvByteBufAllocator 的 实 现 比 较 简 单, 我 们 重 点 分 析AdaptiveRecvByteBufAllocator 的实现。
顾名思义,AdaptiveRecvByteBufAllocator 指的是缓冲区大小可以动态调整的 ByteBuf 分配器。下面看下它的成员变量:
public class AdaptiveRecvByteBufAllocator implements RecvByteBufAllocator {
static final int DEFAULT_MINIMUM = 64;
static final int DEFAULT_INITIAL = 1024;
static final int DEFAULT_MAXIMUM = 65536;
private static final int INDEX_INCREMENT = 4;
private static final int INDEX_DECREMENT = 1;
private static final int[] SIZE_TABLE;
它分别定义了三个系统默认值:最小缓冲区长度 64 字节、初始容量 1024 字
节、最大容量 65536 字节。还定义了两个动态调整容量时的步进参数:扩张的步进索引为 4、收缩的步
进索引为 1。最后,定义了长度的向量表 SIZE_TABLE 并初始化它,它的初始值如下:
0-->16 1-->32 2-->48 3-->64 4-->80 5-->96 6-->112 7-->128 8-->144
9-->160
10-->176 11-->192 12-->208 13-->224 14-->240 15-->256 16-->272 17-
->288 18-->304
19-->320 20-->336 21-->352 22-->368 23-->384 24-->400 25-->416 26-
->432 27-->448
28-->464 29-->480 30-->496 31-->512 32-->1024 33-->2048 34-->4096
35-->8192 36-->16384
37-->32768 38-->65536 39-->131072 40-->262144 41-->524288 42--
>1048576 43-->2097152 44-->4194304 45-->8388608
46-->16777216 47-->33554432 48-->67108864 49-->134217728 50--
>268435456 51-->536870912 52-->1073741824
向量数组的每个值都对应一个 Buffer 容量,当容量小于 512 的时候,由于缓冲区已经比较小,需要降低步进值,容量每次下调的幅度要小些;当大于 512时,说明需要解码的消息码流比较大,这时采用调大步进幅度的方式减少动态扩张的频率,所以它采用 512 的倍数进行扩张。
接下来我们重点分析下 AdaptiveRecvByteBufAllocator 的方法:方法一:getSizeTableIndex(final int size),代码如下
private static int getSizeTableIndex(final int size) {
for (int low = 0, high = SIZE_TABLE.length - 1;;) {
if (high < low) {
return low;
}
if (high == low) {
return high;
}
int mid = low + high >>> 1;
int a = SIZE_TABLE[mid];
int b = SIZE_TABLE[mid + 1];
if (size > b) {
low = mid + 1;
} else if (size < a) {
high = mid - 1;
} else if (size == a) {
return mid;
} else {
return mid + 1;
}
}
}
根据容量 Size 查找容量向量表对应的索引:这是个典型的二分查找法,由于它的算法非常经典,也比较简单,此处不再赘述。
private static final class HandleImpl implements Handle {
private final int minIndex;
private final int maxIndex;
private int index;
private int nextReceiveBufferSize;
private boolean decreaseNow;
它有五个成员变量,分别是:对应向量表的最小索引、最大索引、当前索引、下一次预分配的 Buffer 大小和是否立即执行容量收缩操作。
我 们 重 点 分 析 它 的 record(int actualReadBytes) 方 法: 当NioSocketChannel 执行完读操作后,会计算获得本次轮询读取的总字节数,它就是参数 actualReadBytes,执行 record 方法,根据实际读取的字节数对ByteBuf 进行动态伸缩和扩张,代码如下:
@Override
public void record(int actualReadBytes) {
if (actualReadBytes <= SIZE_TABLE[Math.max(0, index - INDEX_DECREMENT - 1)]) {
if (decreaseNow) {
index = Math.max(index - INDEX_DECREMENT, minIndex);
nextReceiveBufferSize = SIZE_TABLE[index];
decreaseNow = false;
} else {
decreaseNow = true;
}
} else if (actualReadBytes >= nextReceiveBufferSize) {
index = Math.min(index + INDEX_INCREMENT, maxIndex);
nextReceiveBufferSize = SIZE_TABLE[index];
decreaseNow = false;
}
}
首先,对当前索引做步进缩减,然后获取收缩后索引对应的容量,与实际读取的字节数进行比对,如果发现小于收缩后的容量,则重新对当前索引进行赋值,取收缩后的索引和最小索引中的较大者作为最新的索引,然后,为下一次缓冲区容量分配赋值 -- 新的索引对用容量向量表中的容量。相反,如果当前实际读取的字节数大于之前预分配的初始容量,则说明实际分配的容量不足,需要动态扩
张。重新计算索引,选取当前索引 + 扩张步进 和 最大索引中的较小作为当前索引值,然后对下次缓冲区的容量值进行重新分配,完成缓冲区容量的动态扩张。
通过上述分析我们得知,AdaptiveRecvByteBufAllocator 就是根据本次读取的实际字节数对下次接收缓冲区的容量进行动态分配。
使用动态缓冲区分配器的优点如下:
1. Netty作为一个通用的NIO框架,并不对客户的应用场景进行假设,你可能使用它做流媒体传输,也可能用它做聊天工具,不同的应用场景,传输的码流大小千差万别;无论初始化分配的是32K还是1M,都会随着应用场景的变化而变得不适应,因此,Netty根据上次实际读取的码流大小对下次的接收Buffer缓冲区进行预测和调整,能够最大限度的满足不同行业的应用场景;
2. 性能更高,容量过大会导致内存占用开销增加,后续的Buffer处理性能会下降;容量过小时需要频繁的内存扩张来接收大的请求消息,同样会导致性能下降;
3. 更节约内存:设想,假如通常情况下请求消息平均值为1M左右,接收缓冲区大小为1.2M;突然某个客户发送了一个10M的流媒体附件,接收缓冲区扩张为10M以接纳该附件,如果缓冲区不能收缩,每次缓冲区创建都会分配10M的内存,但是后续所有的消息都是1M左右,这样会导致内存的浪费,如果并发客户端过多、Reactor线程个数过多,可能会发生内存溢出,最终宕机。
分析完 AdaptiveRecvByteBufAllocator,我们继续分析读操作:
try {
int totalReadAmount = 0;
boolean readPendingReset = false;
do {
byteBuf = allocHandle.allocate(allocator);
int writable = byteBuf.writableBytes();
int localReadAmount = doReadBytes(byteBuf);
doReadBytes(byteBuf)它是个抽象方法,具体实现在 NioSocketChannel 中,代码如下:
@Override
protected int doReadBytes(ByteBuf byteBuf) throws Exception {
return byteBuf.writeBytes(javaChannel(), byteBuf.writableBytes());
}
其中 javaChannel() 返回的是 SocketChannel,byteBuf.writableBytes() 返回本次可读的最大长度,我们继续展开看最终是如何从 Channel 中读取码流的,代码如下:
@Override
public int writeBytes(ScatteringByteChannel in, int length) throws IOException {
ensureAccessible();
ensureWritable(length);
int writtenBytes = setBytes(writerIndex, in, length);
if (writtenBytes > 0) {
writerIndex += writtenBytes;
}
return writtenBytes;
}
对 setBytes 方法展开代码如下:
@Override
public int setBytes(int index, ScatteringByteChannel in, int length) throws IOException {
ensureAccessible();
ByteBuffer tmpBuf = internalNioBuffer();
tmpBuf.clear().position(index).limit(index + length);
try {
return in.read(tmpBuf);
} catch (ClosedChannelException ignored) {
return -1;
}
}
由于 SocketChannel 的 read 方法参数是 JAVA NIO 的 ByteBuffer, 所以,需要先将 Netty 的 ByteBuf 转换成 JDK 的 ByteBuffer,随后调用 ByteBuffer的 clear 方法对指针进行重置用于新消息的读取,随后将 position 指针指到初始读的 index,读取的上限设置为 index + 读取的长度。最后调用 read 方法将SocketChannel 中就绪的码流读取到 ByteBuffer 中,完成消息的读取,返回读取的字节数。
完成消息的异步读取后,需要对本次读取的字节数进行判断,有三种可能:
1. 返回0,表示没有就绪的消息可读;
2. 返回值大于0,读到了消息;
3. 返回值-1,表示发生了IO异常,读取失败。
下面我们继续看 Netty 的处理逻辑,首先对读取的字节数进行判断,如果等于或者小于 0,表示没有就绪的消息可读或者发生了 IO 异常,此时需要释放接收缓冲区,如果读取的字节数小于 0,则需要将 close 状态位置位,用于关闭连接,释放句柄资源。置位完成之后,退出循环 :
if (localReadAmount <= 0) {
// not was read release the buffer
byteBuf.release();
byteBuf = null;
close = localReadAmount < 0;
break;
}
完成一次异步读之后,就会触发一次 ChannelRead 事件,这里特别需要提醒大家的是,完成一次读操作,并不意味着读到了一条完整的消息,因为 TCP 底层存在组包和粘包,所以,一次读操作可能包含多条消息,也可能是一条不完整的消息,所以,不要把它跟读取的消息个数等同起来。我曾经发现有同事在没有做任何半包处理的情况下,以 ChannelRead 的触发次数做计数器来进行性能分析和
统计,是完全错误的。当然,如果你使用了针对半包的 Decode 类或者自己做了特殊封装,对 ChannelRead 事件进行拦截,屏蔽 Netty 的默认机制,也能够实现一次 ChannelRead 对应一条完整消息的效果,此处也不再展开说明了,当你掌握了 Netty 的编解码技巧之后,自然就知道如何实现这种效果了。触发和完成ChannelRead 事件调用之后,将接收缓冲区释放。
因为一次读操作未必能够完成 TCP 缓冲区的全部读取工作,所以,读操作在循环体中进行,每次读取操作完成之后,会对读取的字节数进行累加,代码如下:
if (totalReadAmount >= Integer.MAX_VALUE - localReadAmount) {
// Avoid overflow.
totalReadAmount = Integer.MAX_VALUE;
break;
}
totalReadAmount += localReadAmount;
// stop reading
if (!config.isAutoRead()) {
break;
}
if (localReadAmount < writable) {
// Read less than what the buffer can hold,
// which might mean we drained the recv buffer completely.
break;
}
在累加之前,需要对长度上限做保护,如果累计读取的字节数已经发生溢出,则将读取到的字节数设置为整形的最大值,然后退出循环,原因是本次循环已经读取过多的字节,需要退出。否则会影响后面排队的 Task 任务和写操作的执行。如果没有溢出,则执行累加操作。
最后,对本次读取的字节数进行判断,如果小于缓冲区可写的容量,说明TCP 缓冲区已经没有就绪的字节可读,读取操作已经完成,需要退出循环。如果仍然有未读的消息,则继续执行读操作。连续的读操作会阻塞排在后面的任务队列中待执行的 Task,以及写操作,所以,对连续读操作做了上限控制,默认值为 16 次,无论 TCP 缓冲区有多少码流需要读取,只要连续 16 次没有读完,都需
要强制退出,等待下次 selector 轮询周期再执行。
} while (++ messages < maxMessagesPerRead);
完成多路复用器本轮读操作之后,触发 ChannelReadComplete 事件。随后调用接收缓冲区容量分配器的 Hanlder 的记录方法,将本次读取的总字节数传入到record() 方法中进行缓冲区的动态分配,为下一次读取选取更加合适的缓冲区容量,代码如下:
allocHandle.record(totalReadAmount);
上面我们提到,如果读到的返回值为 -1,表明发生了 I/O异常,需要关闭连接,释放资源,代码如下:
if (close) {
closeOnRead(pipeline);
close = false;
}
4.2 异步消息发送
Netty 的写操作和将消息真正刷新到 SocketChannel 中是分开的,因此我们
分成两个小结来介绍,首先介绍消息的写操作。
Netty 的写操作和将消息真正刷新到 SocketChannel 中是分开的,因此我们 分成两个小结来介绍,首先介绍消息的写操作。
4.2.1 异步消息发送 下面我们从 ChannelHandlerContext 开始分析,首先调用它的 write 方法, 异步发送消息,代码如下:
它由子类 AbstractUnsafe 实现,代码如下:
首先对链路的状态进行判断,如果已经断开连接,则需要设置回调结果异 常信息,同时,释放需要发送的消息。注意:此处的消息通常是经过编码后的 ByteBuf,因此,需要释放。 如果链路正常,则将需要发送的 ByteBuf 加入到 outboundBuffer 中,下面, 我们重点分析 ChannelOutboundBuffer 的 addMessage 方法。代码如下:
首先,我们获取 ByteBuf 的可读字节数,实际上也就是需要发送的字节数。 然后,从环形 Entry 数组中获取可用的 Entry,将指针 +1,接着进行一系列 的赋值操作,例如将 Entry 的 Message 设置为需要发送的 ByteBuf 等。设置完 成后需要进行一次判断,如果当前指针已经达到唤醒数组的尾部,即:tail = buffer.length; 此时需要重新将指针调整为起始位置 0。由于环形数组的初始 容量为 32,后面容量的扩张是 32 的 N 倍,所以通过 & 操作就能将指针重新指到 起始位置,实现环形队列,代码如下:
指针重绕后,需要对尾部指针 tail 和需要刷新的位置 flushed 进行判断, 如果两者相等,说明指针重绕后已经到达需要刷新的位置,再继续使用就会覆盖 尚未发送的消息,因此,需要对环形队列进行动态扩容,动态扩展的代码如下:
首先,保存需要刷新的位置索引,计算还有多少个消息没有被刷新,然后执 行扩容操作,将环形数组的 Size 扩展为原来的 2 倍。扩容以后,需要对新的环 形数组进行填充,填充分为三步:
1. 将尚未刷新的消息拷贝到数组的首部;
2. 原来数组中已经刷新并释放的Entry可以重用,所以,将其拷贝到尚未刷 新消息的后面;
3. 最后扩容的数组全部重新初始化。 对扩容后的数组初始化后,需要对指针进行重新置位,具体如下: 由于尚未刷新的消息在数组首部,所以 flushed 为 0; 由于未刷新的消息从 0 开始,所以 unflushed = unflushed - flushed & buffer.length - 1; 下次新的消息写入需要放入扩容后的数组中,所以 tail = buffer.length 将需要发送的消息写入环形发送数组之后,计算当前需要发送消息的总字节 数是否达到一次发送的高水位线,如果达到,触发 hannelWritabilityChanged 事件,代码如下:
它仿照了 JDK1.5 以后新增的原子类的自旋操作解决多线程并发操作问题, 循环判断,如果需要更新的变量值没有发生变化并且更新成功退出,否则取其它 线程更新后的新值重新计算并重新赋值,这个就是自旋,通过它可以解决多线程并发修改一个变量的无锁化问题。
至此,我们完成了消息异步发送的代码分析,接下来,我们继续分析消息的 刷新操作,flush 负责将发送环形数组中缓存的消息写入到 SocketChannel 中发 送给对方。
4.2.2 Flush 操作
Flush 操作负责将 ByteBuffer 消息写入到 SocketChannel 中发送给对方, 下面我们首先从发起 Flush 操作的类入口,进行详细分析。 DefaultChannelHandlerContext 的 flush 方法,最终会调用的 HeadHandler 的 flush 操作,代码如下:
重点分析 AbstractUnsafe 的 flush 操作,代码如下:
首先将发送环形数组的 unflushed 指针修改为 tail,标识本次要发送的消 息范围。然后调用 flush0 进行发送,由于 flush0 代码非常简单,我们重点分析 doWrite 方法,代码如下:
首先计算需要发送的消息个数(unflushed - flush), 如果只有 1 个消 息需要发送,则调用父类的写操作,我们分析 AbstractNioByteChannel 的 doWrite() 方法,代码如下:
因为只有一条消息需要发送,所以直接从 ChannelOutboundBuffer 中获取当 前需要发送的消息,代码如下:
首先,获取需要发送的消息,如果消息为 ByteBuf 且它分配的是 JDK 的非堆 内存,则直接返回。 对返回的消息进行判断,如果为空,说明该消息已经发送完成并被回收,然 后执行清空 OP_WRITE 操作位的 clearOpWrite 方法,代码如下:
继续向下分析,如果需要发送的 ByteBuf 已经没有可写的字节,说明已 经发送完成,将该消息从环形队列中删除,然后继续循环。下面我们分析下 ChannelOutboundBuffer 的 remove 方法 :
首 先 判 断 环 形 队 列 中 是 否 还 有 需 要 发 送 的 消 息, 如 果 没 有, 则 直 接 返 回。 如 果 非 空, 则 首 先 获 取 Entry, 然 后 对 其 进 行 资 源 释 放, 同 时 把 需 要 发 送 的 索 引 flushed 进 行 更 新。 所 有 操 作 执 行 完 之 后, 调 用 decrementPendingOutboundBytes 减 去 已 经 发 送 的 字 节 数, 该 方 法 跟 incrementPendingOutboundBytes 类似,会进行发送低水位的判断和事件通知, 此处不再赘述。
我们接着继续对消息的发送进行分析,代码如下:首先将半包标致设置为 false:
从 DefaultSocketChannelConfig 中获取循环发送的次数,进行循环发送, 对发送方法 doWriteBytes 展开分析,如下:
对于红框中的代码说明如下:ByteBuf 的 readBytes() 方法的功能是将 当前 ByteBuf 中的可写字节数组写入到指定的 Channel 中。方法的第一个参 数是 Channel,此处就是 SocketChannel,第二个参数是写入的字节数组长 度,它等于 ByteBuf 的可读字节数,返回值是写入的字节个数。由于我们将 SocketChannel 设置为异步非阻塞模式,所以写操作不会阻塞。
从写操作中返回,需要对写入的字节数进行判断,如果为 0,说明 TCP 发送 缓冲区已满,不能继续再向里面写入消息,因此,将写半包标致设置为 true, 然后退出循环,执行后续排队的其它任何或者读操作,等待下一次 Selector 的 轮询继续触发写操作。
对写入的字节数进行累加,判断当前的 ByteBuf 中是否还有没有发送的字节, 如果没有可发送的字节,则将 done 设置为 true,退出循环。
从循环发送状态退出后,首先根据实际发送的字节数更新发送进度,实际就 是发送的字节数和需要发送的字节数的一个比值。执行完成进度更新后,判断本 轮循环是否将需要发送的消息中所有需要发送的字节全部发送完成,如果发送完 成,则将该消息从循环队列中删除;否则,将设置多路复用器的 OP_WRITE 操作位, 用于通知 Reactor 线程还有没有发送完成的消息,需要继续发送,直到全部发送 完成。
好,到此我们分析完了单条消息的发送,现在我们重新将注意力转回到 NioSocketChannel,看看多条消息的发送过程,代码如下:
从 ChannelOutboundBuffer 获取需要发送的 ByteBuffer 列表,由于 Netty 使用的是 ByteBuf,因此,需要做下内部类型转换,代码如下:
声明各种局部变量并赋值,从 flushed 开始循环获取需要发送的 ByteBuf。 首先对要发送的 Message 进行判断,如果不是 Netty 的 ByteBuf,则返回空。
获取可写的字节个数,如果大于 0,对需要发送的缓冲区字节总数进行累加。 然后从当前 Entry 中获取 ByteBuf 包含的最大 ByteBuffer 个数。 对包含的 ByteBuffer 个数进行累加,如果超过 ChannelOutboundBuffer 预 先分配的数组上限,则进行数组扩张。扩张的代码如下:
由于频繁的数组扩张会导致频繁的数组拷贝,影响性能,所以,Netty 采用 了翻倍扩张的方式,新的数组创建之后,将老的数据内容拷贝到新创建的数组中 返回。 ByteBuffer 创建完成之后,需要将要刷新的 ByteBuf 转换成 ByteBuffer 并 存到发送数据中。由于 ByteBuf 的实现不同,所以,它们内部包含的 ByteBuffer 个数是不同的,例如 UnpooledHeapByteBuf,它基于 JVM 堆内存的字节数组 实现,只包含 1 个 ByteBuffer。对 Entry 中缓存的 ByteBuffer 进行判断,如果 为空,则调用 ByteBuf 的 internalNioBuffer 方法,将当前的 ByteBuf 转换为 JDK 的 ByteBuffer,我们以 UnpooledHeapByteBuf 为例看下 internalNioBuffer 的实现:
获取 ByteBuffer 实例,然后调用它的 clear() 方法对它的指针进行初始化, 随后将 Position 指针设置为 index, limit 指针设置为 index + length。这些 初始化操作完成之后 ByteBuffer 就可以被正确的读写。
下面我们看另一个分支,如果 ByteBuf 包含 NIO ByteBuffer 数组,那就获 取 Entry 缓存的 ByteBuffer 数组,如果为空,则从当前需要刷新的 ByteBuf 中获取它的 ByteBuffer 数组。完成赋值操作后,调用 fillBufferArray 进行赋值。 对循环变量 i 赋值,完成本轮循环,代码如下:
当 i = unflushed 时,说明需要刷新的消息全部赋值完成,循环执行结束。 对 ByteBuffer 数组进行判断,看是否还有单个需要发送的消息,如果没有 则直接返回,有则发送:
在批量发送缓冲区的消息之前,先对一系列的局部变量进行赋值, 首 先, 获 取 需 要 发 送 的 ByteBuffer 数 组 个 数 nioBufferCnt, 然 后, 从 ChannelOutboundBuffer 中获取需要发送的总字节数,从 NioSocketChannel 中 获取 NIO 的 SocketChannel, 是否发送完成标识设置为 false,是否有写半包标 致设置为 false。
继续分析循环发送的代码,代码如下:
就像循环读一样,我们需要对一次 Selector 轮询的写操作次数进行上限控 制,因为如果 TCP 的发送缓冲区满,TCP 处于 KEEP-ALIVE 状态,消息是发送不 出去的,如果不对上限进行控制,就会常时间的处于发送状态,Reactor 线程无 法及时读取其它消息和执行排队的 Task。所以,我们必须对循环次数上限做控制。
调用 NIO SocketChannel 的 write 方法,它有三个参数:第一个是需要发送 的 ByteBuffer 数组,第二个是数组的偏移量,第三个参数是发送的 ByteBuffer 个数。返回值是写入 SocketChannel 的字节个数。
下面对写入的字节进行判断,如果为 0,说明 TCP 发送缓冲区已满,再写很 有可能还是写不进去,因此从循环中跳出,同时将写半包标识设置为 True, 用于 向多路复用器注册写操作位,告诉多路复用器有没发完的半包消息,你要继续轮 询出就绪的 SocketChannel 继续发送:
发送操作完成后进行两个计算:需要发送的字节数要减去已经发送的字节数; 发送的字节总数 + 已经发送的字节数。更新完这两个变量后,判断缓冲区中所有 的消息是否已经发送完成,如果是,则把发送完成标识设置为 True同时退出循环。 如果没有发送完成,则继续循环。
从循环发送中退出之后,首先对发送完成标识 done进行判断,如果发送完成, 则循环释放已经发送的消息,代码如红框中标识所示:
环形数组的发送缓冲区释放完成后,取消半包标识,告诉多路复用器消息已 经全部发送完成。
当缓冲区中的消息没有发送完成,甚至某个 ByteBuffer 只发送了一半,出 现了半包发送,该怎么办?下面我们继续看看 Netty 是如何处理的。
首先,我们循环遍历发送缓冲区,对消息的发送结果进行分析,下面具体展 开进行说明:
1. 从ChannelOutboundBuffer弹出第一条发送的ByteBuf; 然后获取该 ByteBuf的可读索引和可读字节数;
2. 对可读字节数和发送的总字节数进行判断,如果发送的字节数大于可读的 字节说,说明它已经被完全发送出去,更新ChannelOutboundBuffer的发 送进度信息,将已经发送的ByteBuf删除,释放相关资源,最后,发送的 字节数要减去第一条发送的字节数,就是后面消息发送的总字节数;然后 继续循环判断第二条消息、第三条消息......
3. 如果可读的消息大于已经发送的总消息数,说明这条消息没有被完全发送 成功,也就是出现了所谓的“写半包”,此时,需要更新可读的索引为当 前索引 + 已经发送的总字节数,然后更新ChannelOutboundBuffer的进度 信息,退出循环;
4. 如果可读字节数等于已经发送的字节数总和,则说明最后一次发送的消息 是个全包消息,更新发送进度信息,将最后一条完全发送的消息从缓冲区中删除,最后退出循环。
最后,因为缓冲区中待刷新的消息没有全部发送完成,所以需要更新 SocketChannel 的注册监听位,将其修改为 OP_WRITE, 在下一次轮询中继续发送 没有发送出去的消息。
作者:李林锋