文章目录
本文只代表笔者一人的理解和叙述,笔者功力尚浅,如有错误,还请各位大神斧正。
阅读本篇文章前需阅读:
Netty4.1源码分析—— 服务端启动
一、引言与结论
Netty的服务启动、构建连接、读取数据都是入栈事件,也就是从HeadContext
的handle到TailContext
的handle链处理,并且服务启动为构建连接做准备、构建连接为读取数据做准备,这三者分别对应OP_ACCEPT
、OP_READ
事件的处理。而发送数据不同,它的数据是从TailContext
到HeadContext
的处理,也就是从尾部到头部,即出站事件。
Netty在写数据时,将写入步骤分为两个步骤,write和flush,并且write和flush中间还有个发送缓冲区ChannelOutboundBuffer
。write将数据组装在ChannelOutboundBuffer
内,而flush
则将ChannelOutboundBuffer
的数据真正的发送出去。
二、ChannelOutboundBuffer类
在深入了解发送过程之前,熟悉ChannelOutboundBuffer
类的精髓是很有必要的。只有理解了这个类,才能理解write和flush所做的事情。
2.1 ChannelOutboundBuffer的结构
ChannelOutboundBuffer
将每次write的消息包装成一个节点Entry
,并且采用链式的方式,将后write的消息接在前者Entry
的next
节点上:
static final class Entry {
//...
Entry next;//记录下一个消息Entry
Object msg;
// ...
long progress; //用作记录写入的进度,可能存在该Entry没写完但是对端不能在写的情况
long total;//消息的总字节数
//...
}
并且在ChannelOutboundBuffer
类的内部定义了如下几个Entry
节点,用于表示未flush
节点的位置、已经flush
的节点位置等:
public final class ChannelOutboundBuffer {
private Entry flushedEntry;
// The Entry which is the first unflushed in the linked-list structure
private Entry unflushedEntry;
// The Entry which represents the tail of the buffer
private Entry tailEntry;
}
再来看addMessage
方法:
public void addMessage(Object msg, int size, ChannelPromise promise) {
Entry entry = Entry.newInstance(msg, size, total(msg), promise);
if (tailEntry == null) {
flushedEntry = null;
} else {
Entry tail = tailEntry;
tail.next = entry;
}
tailEntry = entry;
if (unflushedEntry == null) {
unflushedEntry = entry;
}
//判断是否超过高水位线
incrementPendingOutboundBytes(entry.pendingSize, false);
}
代码很简单,将每一个新增的消息转变成Entry
后用单向链表串起来。继续查看addFlush
方法:
public void addFlush() {
Entry entry = unflushedEntry;
if (entry != null) {
if (flushedEntry == null) {
// 将flushedEntry的位置指向unflushedEntry节点的位置
flushedEntry = entry;
}
do {
// // 已刷新但数据未写入的节点数
flushed ++;
// 设置不可取消,设置失败则释放entry节点
if (!entry.promise.setUncancellable()) {
//释放掉Entry的内部消息空间,并返回所占字节数
int pending = entry.cancel();
//判断低水位线
decrementPendingOutboundBytes(pending, false, true);
}
entry = entry.next;
} while (entry != null);
unflushedEntry = null;
}
}
代码也很简单,将flushedEntry
的位置指向unflushedEntry
节点的位置,表示从这一个节点开始,后续节点都是未flush的节点。从addMessage
方法和addFlush
方法两者可以看出,ChannelOutboundBuffer
类内部维持着一个单线链表,采用着不同的标记来描述未flush
的数据以及已经被flush的数据位置,可以用如下图来描述:
2.2 写入的高低水位线
需要注意的是两个方法内的incrementPendingOutboundBytes
方法,该方法是判断未flush
的总字节数是不是大于设置的写入高水位线了,如果大于则将channel的可写标记位置为不可写的状态。如果没有高水位线的限制,当出于某种原因,比如网速慢造成flush
这一步真正发送的速率较慢时,假如此时又write
到ChannelOutboundBuffer
的数据太多,这样就极有可能造成内存溢出了。看下其源码:
private void incrementPendingOutboundBytes(long size, boolean invokeLater) {
if (size == 0) {
return;
}
long newWriteBufferSize = TOTAL_PENDING_SIZE_UPDATER.addAndGet(this, size);
// 判断是否待写入的数据已超过写入的高水位线,超过则停止写入
if (newWriteBufferSize > channel.config().getWriteBufferHighWaterMark()) {
// 设置不可写
setUnwritable(invokeLater);
}
}
private void setUnwritable(boolean invokeLater) {
for (;;) {
final int oldValue = unwritable;
final int newValue = oldValue | 1;
//将可写入标记位置为不可写
if (UNWRITABLE_UPDATER.compareAndSet(this, oldValue, newValue)) {
if (oldValue == 0) {
fireChannelWritabilityChanged(invokeLater);
}
break;
}
}
}
当超过高水位线时,会设置写入标记位为不可写状态,即不可写的状态,并通过fireChannelWritabilityChanged
将可写入状态改变通知给其它handler。需要注意的是,不可写状态表示Netty认为此状态下写入发送成功是不确定的,但是写不写入还是交由开发者决定,只是Netty不保证它的成功性。
有高水位线,自然就有低水位线,当ChannelOutboundBuffer
里待发送的数据总大小小于低水位线时,Netty将可写标记位置为可写状态:
private void decrementPendingOutboundBytes(long size, boolean invokeLater, boolean notifyWritability) {
if (size == 0) {
return;
}
long newWriteBufferSize = TOTAL_PENDING_SIZE_UPDATER.addAndGet(this, -size);
if (notifyWritability && newWriteBufferSize < channel.config().getWriteBufferLowWaterMark()) {
//设为可写
setWritable(invokeLater);
}
}
三、写出过程
3.1 write过程
平常在我们写入数据的时候用的最多的是两种写法:ChannelHandlerContext
的write方法和ChannelHandlerContext#channel
的write方法。前者和后者的处理其实是一样的,只不过前者每个消息都会从TailContext
到HeadContext
走一遍,而后者只会从当前的handle走到HeadContext
,所以本篇只着重分析ChannelHandlerContext
的write方法以及ChannelHandlerContext
的flush
方法。追踪ChannelHandlerContext
的write方法可以看到:
public final ChannelFuture write(Object msg) {
return tail.write(msg);
}
ChannelHandlerContext#write
方法在pipline#write
方法内调用的是TailContext
,也就是消息是从pipiline
的尾部开始处理。接着往下看:
private void write(Object msg, boolean flush, ChannelPromise promise) {
//....
//找出被标记write和flush的handleContext
final AbstractChannelHandlerContext next = findContextOutbound(flush ?
(MASK_WRITE | MASK_FLUSH) : MASK_WRITE);
//暂不清楚意义,还请大神告知。。。
final Object m = pipeline.touch(msg, next);
EventExecutor executor = next.executor();
//判断是否需要write和flush一并完成
if (executor.inEventLoop()) {
if (flush) {
next.invokeWriteAndFlush(m, promise);
} else {
next.invokeWrite(m, promise);
}
}
//.....
}
首先找出被标有WRITE
和FLUSH
标记的第一个HandlerContext
,如果你是用Netty官方定义的EchoServer
来启动Debug,那么此处就是EchoServerHandler
类。当然这里的HandlerContext
都是ChannelOutboundHandler
的实现,根据传参调用其write
方法或者writeAndFlush
方法,这两者的流程其实是一样的,后者是在前者的基础上完成后直接flush
,而不是让开发者来决定如何进行flush
的操作。本篇着重分析前者:
void invokeWrite(Object msg, ChannelPromise promise) {
//判断是否handler就绪
if (invokeHandler()) {
invokeWrite0(msg, promise);
} else {
write(msg, promise);
}
}
private void invokeWrite0(Object msg, ChannelPromise promise) {
try {
((ChannelOutboundHandler) handler()).write(this, msg, promise);
} catch (Throwable t) {
notifyOutboundHandlerException(t, promise);
}
}
代码在此处进行了判断,invokeHandler
方法的作用是判断该handler是否已经被完全加载就绪了。该判断是因为存在handler已被添加,但是可能handlerAdded
事件还未完全执行完的情况。如果没有就绪,那么继续执行write,进行一个循环,找到下一个ChannelOutboundHandler
;如果就绪,则调用该ChannelOutboundHandler
的write方法,将消息写入。
需要注意的是,在ChannelOutboundHandler
的实现当中,write
往往对应着消息的编码,可以查看一下常用ChannelOutboundHandler
的实现,比如ByteToMessageCodec
类的write
方法:
public abstract class ByteToMessageCodec<I> extends ChannelDuplexHandler {
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
encoder.write(ctx, msg, promise);
}
}
在pipiline
上的ChannelOutboundHandler
都会执行一遍write方法,主要是用于Netty自身的一些编码和开发者自定义的一些编码操作,执行到最后最终都会执行到pipiline
的头部HeadContext
的write方法内:
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
unsafe.write(msg, promise);
}
public final void write(Object msg, ChannelPromise promise) {
assertEventLoop();
ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
//...
int size;
try {
// 判断是哪种内存,然后转化为直接内存
msg = filterOutboundMessage(msg);
// 读取有多少可写入字节
size = pipeline.estimatorHandle().size(msg);
if (size < 0) {
size = 0;
}
}
//....
outboundBuffer.addMessage(msg, size, promise);
}
在HeadContext
内部调用的是AbstractChannel
的AbstractUnsafe
的方法,其最终调用的是本篇上文所说的ChannelOutboundBuffer
的addMessage
方法,也就是将消息临时存储在写出缓冲区ChannelOutboundBuffer
内部。
3.2 flush过程
在Netty中,提供了两种flush让开发者选择:writeAndFlush
或者先write
等待writeComplete
事件发生在flush数据。前者将所有的操作都交由框架,而后者将何时写入数据交由开发者来决定。
flush
的流程前半部分和write
相同,就不在此展开叙述了,所以直接从AbstractUnsafe
类的flush
方法看起:
public final void flush() {
assertEventLoop();
ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
if (outboundBuffer == null) {
return;
}
outboundBuffer.addFlush();
flush0();
}
在这一步,调用了ChannelOutboundBuffer
的addFlush
方法,得到了ChannelOutboundBuffer
内部从哪个节点开始flush
的信息(flushedEntry
节点)。而flush0
方法最终调用的是NioSocketChannel
的doWrite
方法。在分析该方法之前,先了解一下Netty对写出数据的处理机制。
3.2.1 写出数据大小的动态调整
首先来了解writeSpinCount
和maxBytesPerGatheringWrite
两个特殊变量。这两者其实都表示Netty在一次写入的过程中尽可能的写多一点的思想,也就是如果对端能接受的更多,那么我每次写入都尽可能的根据对端最大能接受的数据大小来写。
- writeSpinCount:默认为16,表示每次flush都会尝试进行16次的循环写出数据动作。
- maxBytesPerGatheringWrite:表示一次写出数据动作的最大数据大小,这个值是动态变化的,在每次写完后会根据具体写入了多少来判断是否需要增加该值还是缩小该值。
在doWrite
方法中,adjustMaxBytesPerGatheringWrite
方法就是maxBytesPerGatheringWrite
变量需要增加还是减小的判断:
private void adjustMaxBytesPerGatheringWrite(int attempted, int written, int oldMaxBytesPerGatheringWrite) {
// 尝试写出的量等于真实写出的量,表示可以下一次可以写入更多(因为全部写入了)
if (attempted == written) {
// 每次扩大一倍
if (attempted << 1 > oldMaxBytesPerGatheringWrite) {
((NioSocketChannelConfig) config).setMaxBytesPerGatheringWrite(attempted << 1);
}
} else if (attempted > MAX_BYTES_PER_GATHERING_WRITE_ATTEMPTED_LOW_THRESHOLD && written < attempted >>> 1) {
((NioSocketChannelConfig) config).setMaxBytesPerGatheringWrite(attempted >>> 1);
}
}
在adjustMaxBytesPerGatheringWrite
方法中,如果真实的写出数据大小等于尝试写出的数据大小(就是消息的数据大小,所以真正的意思就是全部写出去了),那么Netty认为对端可以接受更大的数据量而加大写出的数据大小,每次都是扩大至原来的两倍。当尝试写出的大小大于尝试写出的最低阈值(MAX_BYTES_PER_GATHERING_WRITE_ATTEMPTED_LOW_THRESHOLD = 4096
)并且真实的写出量小于尝试写出量的一半,那么缩小maxBytesPerGatheringWrite
的值为原来的一半。
3.2.2 写出数据时的特殊处理
再来看incompleteWrite
方法,该方法是对写出数据特殊的两种处理,即对端不能写入数据的情况和16次写出动作执行完,但是没有将数据全部写完的情况:
protected final void incompleteWrite(boolean setOpWrite) {
if (setOpWrite) {
//注册OP_WRITE事件
setOpWrite();
} else {
clearOpWrite();
//没写出的数据采用execute执行
eventLoop().execute(flushTask);
}
}
protected final void setOpWrite() {
final SelectionKey key = selectionKey();
// Check first if the key is still valid as it may be canceled as part of the deregistration
// from the EventLoop
// See https://github.com/netty/netty/issues/2104
if (!key.isValid()) {
return;
}
final int interestOps = key.interestOps();
if ((interestOps & SelectionKey.OP_WRITE) == 0) {
key.interestOps(interestOps | SelectionKey.OP_WRITE);
}
}
private final Runnable flushTask = new Runnable() {
@Override
public void run() {
// Calling flush0 directly to ensure we not try to flush messages that were added via write(...) in the
// meantime.
((AbstractNioUnsafe) unsafe()).flush0();
}
};
当setOpWrite
为true时,即对端不能写入数据的情况,此时注册OP_WRITE
事件到selector
上。需要注意的是OP_WRITE
事件表示的意义是对端如果可以写入,那么就会发送OP_WRITE
事件到本端,也就是OP_WRITE
事件起到的是监听对端是否可以写入的作用。而OP_WRITE
事件的处理就是在EventLoop
事件循环里处理:
if ((readyOps & SelectionKey.OP_WRITE) != 0) {
// Call forceFlush which will also take care of clear the OP_WRITE once there is nothing left to write
ch.unsafe().forceFlush();
}
在事件循环里,当收到OP_WRITE
事件时,通知调用channel
的AbStractUnsafe
类继续进行flush
的操作。
当setOpWrite
为false时,表示16次写出动作已经写完了,但是还有数据没有写出去,此时会调用EventLoop
的execute
方法来执行一个任务,任务的动作就是再次调用一遍flush0
方法,直到数据完全写出。
3.2.3 doWrite方法
了解完上述的思想和处理过程后,再来看doWrite
的整个流程:
protected void doWrite(ChannelOutboundBuffer in) throws Exception {
SocketChannel ch = javaChannel();
// 每次写入都试图写满16次
int writeSpinCount = config().getWriteSpinCount();
do {
// 判断是否有数据可写
if (in.isEmpty()) {
//....
return;
}
// 该变量表示一次尽可能写入的最大数据量
int maxBytesPerGatheringWrite = ((NioSocketChannelConfig) config).getMaxBytesPerGatheringWrite();
ByteBuffer[] nioBuffers = in.nioBuffers(1024, maxBytesPerGatheringWrite);
// 有多少个ByteBuf
int nioBufferCnt = in.nioBufferCount();
switch (nioBufferCnt) {
case 0:
// 暂不清楚该分支的意思。。。
// We have something else beside ByteBuffers to write so fallback to normal writes.
writeSpinCount -= doWrite0(in);
break;
case 1: {
ChannelOutboundBuffer, so there is no need
ByteBuffer buffer = nioBuffers[0];
//获取本次写入的数据
int attemptedBytes = buffer.remaining();
final int localWrittenBytes = ch.write(buffer);
// 对端不能写入数据了
if (localWrittenBytes <= 0) {
// 注册OP_WRITE事件,OP_WRITE事件表示对端可以写数据了
incompleteWrite(true);
return;
}
// 动态更改maxBytesPerGatheringWrite的大小,让每次都尽可能更多的写入
adjustMaxBytesPerGatheringWrite(attemptedBytes, localWrittenBytes, maxBytesPerGatheringWrite);
// 移除已被写入的数据,没有被写完则标记写入进度
in.removeBytes(localWrittenBytes);
--writeSpinCount;
break;
}
default: {
long attemptedBytes = in.nioBufferSize();
final long localWrittenBytes = ch.write(nioBuffers, 0, nioBufferCnt);
//后续代码与case:1分支相同
}
}
} while (writeSpinCount > 0);
// 写满了16次,但是还是没有写完,调用execute继续写
incompleteWrite(writeSpinCount < 0);
}
- 第一步,通过
nioBuffers
方法将ChannelOutboundBuffer
缓冲区里的数据转化为NIO的ByteBuf
数组。 - 第二步,判断
NIO ByteBuf
数组里有多少个ByteBuf
。根据不同的数量来决定走哪一个分支,需要注意的是数量为1和多个的情况其实是走的相同的代码,只不过前者对应NIO 单个ByteBuf
的写入,后者对应NIO多个ByteBuf
的写入,本篇只分析单个的写入流程。 - 第三步,分支1中,首先获得
ByteBuf
的真实数据量大小,也就是上文说的attemptedBytes
变量。调用NIO的write
写入数据到socket
中,并获得真实写入的数据量大小(localWrittenBytes
)。当该值小于等于0时,表示对端不能写入了,并调用incompleteWrite
方法注册一个OP_WRITE
事件,等待对端通知何时能写。 - 第四步,如果写入的数据量大于0,那么调用
adjustMaxBytesPerGatheringWrite
方法来动态的更改每次写入的数据量大小(maxBytesPerGatheringWrite
) - 第五步,移除
ChannelOutboundBuffer
内当前Entry
已经写入的数据,如果没有写完,则标记一下写入的进度(progress
),写入动作次数(writeSpinCount
)减一,进行下一次循环。 - 第六步,循环结束后,假如写入的动作次数小于0,意味着
ChannelOutboundBuffer
内还有缓存的数据没写完,就调用incompleteWrite
方法在内部起一个flush
的任务,继续调用flush0
方法接着写,直到写完。
四、总结
- 写的本质是调用了NIO的write方法,在数据写不进去的时候,会注册一个
OP_WRITE
事件,来监听什么时候可以写了。 OP_WRITE
事件表示对端可以写的情况,不是有数据可写。OP_WRITE
事件的处理是在事件循环内处理,其内部是调用flush0
方法继续写数据。- Netty会尽可能尝试写多数据,每一次写入数据的大小是会动态变化的,每一轮写入的次数是16次。
- 当16次数据还没写完的时候,会利用
EventLoop
起一个flush0
的任务,继续写入。 - Netty写入是分为两步骤的,第一步将数据缓存在
ChannelOutboundBuffer
内,第二步则将据缓存在ChannelOutboundBuffer
内的数据写入到socket
。 - 写入的时候会实时比较写入的高低水位线,来判断标记该
socketChannel
是否可写。但是不可写状态表示Netty
认为此状态下写入发送成功是不确定的,但是写不写入还是交由开发者决定,只是Netty
不保证它的成功性。