恶劣的网络环境下,Netty是如何处理写事件的?

 

更多技术分享可关注我 

前言

前面,在Netty在接收完新连接后,默认为何要为其注册读事件,其处理I/O事件的优先级是什么?这篇文章,分析到了Netty处理I/O事件的优先级——读事件优先,写事件仅仅是需要写的时候才注册,为什么要这样设计呢?下面抛出两个问题,可以带着这两个问题阅读本篇文章:恶劣的网络环境下,Netty是如何处理写事件的?

1、假设服务器在成功接收到一个客户端新连接后,就给它注册了OP_WRITE事件,此时可能会发生什么问题?

2、有人说,JDK不是已经提供了一个往Socket写数据的方法么,在客户端直接调用它,给服务器发送数据不就OK了么,还注册什么事件,费这个劲呢,对此你怎么理解?

另外本文后续引起的几篇文章可以参考:

Netty在接收完新连接后,默认为何要为其注册读事件,其处理I/O事件的优先级是什么?

Netty在接收完新连接后,默认为何要为其注册读事件,其处理I/O事件的优先级是什么?

NIO的I/O事件都有哪些,它们的本质是什么,处理它们有哪些坑需要注意?

NIO的I/O事件都有哪些,它们的本质是什么,处理它们有哪些坑需要注意?

​Netty为何在Channel里设计Unsafe,且针对不同类型的Channel设计了两大类实现?

Netty为何在Channel里设计Unsafe,且针对不同类型的Channel设计了两大类实现?

​NIO的connect方法有什么坑,Netty是如何解决的?

NIO的connect方法有什么坑,Netty是如何解决的?

​Reactor主从模型你理解对了么?

Reactor主从模型你理解对了么?

总结Netty的新连接接入和数据读写相关的面试题

总结Netty的新连接接入和数据读写相关的面试题

NIO处理写事件的坑和正确做法

先抛出结论:

1、JDK NIO的OP_WRITE事件处理不对,很容易发生“无限”循环的问题!

2、在网络不给力的情况下,往处于非阻塞模式下的连接上调用写方法容易导致CPU被浪费,服务器性能会陡然下降!

首先,知道JDK NIO的OP_WRITE事件何时会被触发,前提是必须在注册了Channel的I/O多路复用器上注册了OP_WRITE事件,之后该连接上:

1、Socket的缓冲区有空闲位置

2、对端关闭了该连接

3、该连接自己内部出现了错误

发生以上三个场景,都可以触发I/O多路复用器上注册的写事件。

 

带着上述结论,回答开头提到的第一个问题:

1、假设一个服务器在成功接收到一个客户端新连接后,就给它注册了OP_WRITE事件,此时可能会发生什么问题呢?

答案是可能导致“死”循环发生,最终结果就是CPU利用率达到100%,服务被拖垮!因为一个Channel上写事件的就绪条件为TCP写缓冲区有空闲位置,根据常识我们也知道TCP写缓冲区在大多场景下,都是有空闲位置的,所以直接给新连接注册写事件,那么这个写事件在大多数时间下会一直被触发,处理这个过程的I/O线程就会被长时间拖累,直到占用整个CPU资源。这样干说可能不太好理解,看一个demo,这是早些年我写的一个NIO框架里面的一段I/O事件循环处理的代码,当然很挫,和Netty的run方法没得比,但是大概思路是通的:

 

 

看最外层do-while循环里的while子循环代码,红线处有一个当前轮询出的Channel是否可写的判断,如果上来就给该Channel注册写事件,那么此时该判断在大多数时间下都是ture,接着反复执行doIOCoreOperation这个非阻塞的方法,此时并没有数据要写出,所以一直在做无用功,更根本的原因在于最开始的selector.select()大多数时间都不会阻塞,一直能让do-while循环跑起来。。。

为此,一种合理的做法是:

1、JDK NIO的OP_WRITE事件只有在有数据需要写出的场景,才注册到对应Channel上

2、大前提是这个Channel必须活跃

3、在触发OP_WRITE事件后,业务层应该及时处理这个事件,一般交给I/O线程处理,并且处理完立即取消OP_WRITE事件的注册,然后做判断:

  • 当前需要写出的数据,一次发送不完,那么需要重新注册OP_WRITE事件,即循环的注册-写-取消-判断-。。。

  • 当前需要写出的数据,已经发送完,那么就无需再次注册写事件

注册、触发写事件和什么时候写出没有直接关系

可能一些人初学NIO编程都会有这样一个认识误区:想当然的认为NIO的WRITE事件是调用了channel.write后发生的,因为调用Channel的write方法会执行把缓冲区里的数据真正写出去的操作。其实这是完全两个不同的东西,没有必然联系。

要知道,给组件注册XXX事件,仅仅是事件驱动模型的一种编程思想,不代表xxx事件一定会发生。比如写事件,写事件被触发,不代表有数据在此时此刻已经写出,它仅仅是告诉I/O多路复用器,此时某些连接上的缓冲区有空闲位置可放(写)数据。即这个注册写事件的过程是I/O多路复用器需要的,当某个Channel上注册了相关的I/O事件,就可以通过Selector的select(xxx)方法轮询出发生该事件的那些Channel,之后业务上做相应判断和处理即可。

还有一个可能的疑问,也就是开头提到的第二个问题:

有人说,JDK不是已经提供了一个往Socket连接写数据的方法么,在客户端直接调用它,给服务器发送数据不就OK了么,还注册什么写事件,还得检测,异步处理,各种坑。。。费这个劲呢!对此你怎么理解?

首先,理解为什么需要注册写事件,其它I/O事件同理。以写事件为例,给某个Channel注册写事件的目的是为了查看当前Channel的缓冲区是否可以写数据,这个触发时机前面说了就是底层缓冲区有空闲位置。如果写的数据非常非常少,那么完全可以不搞注册监听这一套逻辑,直接调用write方法也行,也能正常通信,但如果数据稍微多一些,那么就需要用户自己判断好连接底层的可读、可写、以及是否关闭等状态。即单纯的通信跟是否注册I/O事件没有直接关系。

其次,Channel的write方法并不可靠,即不一定真的会写出数据,比如在非阻塞模式下,该方法不会阻塞。假设网络环境很差,业务层一直在发数据,TCP的发送缓冲区很快会满,这一般是由滑动窗口等流量控制机制决定的,缓冲区满就会拒绝新数据写入。此时调用Channel的write方法就会立即返回0,口说无凭,咱们看JDK的注释:

 /**
     * Writes a sequence of bytes to this channel from the given buffer.
     *
     * <p> An attempt is made to write up to <i>r</i> bytes to the channel,
     * where <i>r</i> is the number of bytes remaining in the buffer, that is,
     * <tt>src.remaining()</tt>, at the moment this method is invoked.
     *
     * <p> Suppose that a byte sequence of length <i>n</i> is written, where
     * <tt>0</tt>&nbsp;<tt>&lt;=</tt>&nbsp;<i>n</i>&nbsp;<tt>&lt;=</tt>&nbsp;<i>r</i>.
     * This byte sequence will be transferred from the buffer starting at index
     * <i>p</i>, where <i>p</i> is the buffer's position at the moment this
     * method is invoked; the index of the last byte written will be
     * <i>p</i>&nbsp;<tt>+</tt>&nbsp;<i>n</i>&nbsp;<tt>-</tt>&nbsp;<tt>1</tt>.
     * Upon return the buffer's position will be equal to
     * <i>p</i>&nbsp;<tt>+</tt>&nbsp;<i>n</i>; its limit will not have changed.
     *
     * <p> Unless otherwise specified, a write operation will return only after
     * writing all of the <i>r</i> requested bytes.  Some types of channels,
     * depending upon their state, may write only some of the bytes or possibly
     * none at all.  A socket channel in non-blocking mode, for example, cannot
     * write any more bytes than are free in the socket's output buffer.
     *
     * <p> This method may be invoked at any time.  If another thread has
     * already initiated a write operation upon this channel, however, then an
     * invocation of this method will block until the first operation is
     * complete. </p>
     *
     * @param  src
     *         The buffer from which bytes are to be retrieved
     *
     * @return The number of bytes written, possibly zero
     *
     * @throws  NonWritableChannelException
     *          If this channel was not opened for writing
     *
     * @throws  ClosedChannelException
     *          If this channel is closed
     *
     * @throws  AsynchronousCloseException
     *          If another thread closes this channel
     *          while the write operation is in progress
     *
     * @throws  ClosedByInterruptException
     *          If another thread interrupts the current thread
     *          while the write operation is in progress, thereby
     *          closing the channel and setting the current thread's
     *          interrupt status
     *
     * @throws  IOException
     *          If some other I/O error occurs
     */
    public int write(ByteBuffer src) throws IOException;

大概意思是:尝试向该Channel中写入最多r个字节,r是调用此方法时缓冲区中剩余的字节数,即src.remaining()返回值,假设写入了长度为n的字节序列,其中0<=n<=r。从缓冲区的索引p处开始传输该字节,其中p是调用此方法时该缓冲区的位置;最后写入的字节索引是p+n-1。返回时该缓冲区的位置将等于p+n;限制不会更改。除非另行指定,否则仅在写入所有请求的r个字节后write操作才会返回。有些类型的Channel(取决于它们的状态)可能仅写入某些字节或者可能根本不写入。例如处于非阻塞模式的SocketChannel只能写入该套接字输出缓冲区中的字节。可在任意时间调用此方法。但是如果另一个线程已经在此Channel上发起了一个写操作,则在该操作完成前此方法的调用被阻塞。

 

注释说的非常细致了,需要正确理解这个过程。简单说,在发送缓冲区空间不够时,write方法返回的字节数可能只是需要写出数据的一部分,比如写缓冲区只剩100字节空间,写入200字节,write返回100,如果缓冲区满,那么write返回0。在正常情况下不太可能发生上述问题,就怕网络不好的时候,此时数据包重传率非常高,发送数据的I/O线程会一直被拖累在这里,这样干说可能不太形象,下面看一个demo,这是我之前自己写的一个NIO框架里,服务器发送消息的方法,当初就没有考虑这种情况:

 

 

假设此时网络较差,调用socketChannel.write方法可能会返回0,而且是在非阻塞模型下编程,故socketChannel.write会立刻返回,且while判断条件会一直为true,在网络较差的这一段时间内,while循环快速转动。。。消耗大量CPU,且什么也没做,导致服务器性能会马上下降。

 

这时候注册OP_WRITE事件就有用了!NIO编程中比较常用的套路如下:

1、在socketChannel.write返回0时,给此Channel注册OP_WRITE事件,然后马上退出循环,让I/O线程去做别的事情

2、当网络恢复正常后,该Channel的底层写缓冲区会变为非满,此时触发Channel上的写事件,通知Selector,业务上就可以让I/O线程来处理写数据的操作,这样就能节约大量CPU资源,服务器也能适应恶劣的网络环境,非常健壮了。

 

说了很多理论,看看Netty是怎么做的。由此也感慨,有时候你觉得简单,是因为你不知道你不懂的东西还很多,共勉。

Netty处理写事件的过程分析

1、首先知道,Netty优先处理读事件,不会主动注册写事件,参考:

Netty在接收完新连接后,默认为何要为其注册读事件,其处理I/O事件的优先级是什么?如下是Netty的事件循环机制里,轮询到写事件后的处理逻辑,注释写到必须在处理完OP_WRITE事件后,在forceFlush方法里取消(clear)注册。

 

2、下面看Netty如何取消注册的I/O事件:

跟进forceFlush方法,中间的过程省略,会在写到Netty编解码的时候详细拆解,最终会调用到doWrite方法:

在for(;;)循环里,判断是否已经写完全部的消息,如果是,那么就调用clearOpWrite方法,清理注册的写事件:

如果以后有自己写NIO代码的时候,那么学会这种用法——使用位与运算判断并清理注册的I/O事件。

 

3、在看一下Netty的发消息的方法,还是只看本文相关的代码,其余过程省略,在写到编解码的时候在详细拆解。

在使用Netty时,往对端发消息,往往都是调用pipeline的writeAndFlush方法,如下:

最终调用到invokeFlush0方法是真正刷新消息到Channel里:

重点看这个方法,它最终调用到该客户端Channel的pipeline的头结点的flush方法,前面也提到过flsuh,write等都属于出站方法,而pipeline的头结点本身就是出站处理器,如下:

最终调用到内部类——unsafe的flush方法,内部最终会调用到doWrite方法,前面说取消注册的写事件时,简单提到过,看里面一段核心代码:

首先调用客户端Channel——ch的write方法,往Channel里写出数据,如果返回为0,说明可能遇到了网络较差的情况,此时Netty会立即break出循环写数据的逻辑,设置标记位setOpWrite为true,后面会进入如下方法:

此时setOpWrite为true,故会进入if条件,执行setOpWrite方法,顾名思义就是给当前Channel注册写事件:

注册完毕后,会退出整个writeAndFlush方法,等该NIO线程的事件循环处理器——run方法里再次轮询到写事件时,说明网络OK了,NIO线程再回头执行写操作。

总结

1、NIO的写事件不能随便注册,必须在写数据时才注册

2、写完数据,需要及时取消写事件的注册

3、知道为什么会有写事件,以及它在何时使用

4,学习Netty是如何适用恶劣的网络环境的

欢迎关注

dashuai的博客是终身学习践行者,大厂程序员,且专注于工作经验、学习笔记的分享和日常吐槽,包括但不限于互联网行业,附带分享一些PDF电子书,资料,帮忙内推,欢迎拍砖!