Netty——内核角度看IO模型
https://blog.51cto.com/u_15257216/5502485

当网络数据帧通过网络传输到达网卡时,网卡会将网络数据帧通过DMA的方式放到环形缓冲区RingBuffer中。
当DMA操作完成时,网卡会向CPU发起一个硬中断,告诉CPU有网络数据到达。CPU调用网卡驱动注册的硬中断响应程序。网卡硬中断响应程序会为网络数据帧创建内核数据结构sk_buffer,并将网络数据帧拷贝到sk_buffer中。然后发起软中断请求,通知内核有新的网络数据帧到达。
内核线程ksoftirqd发现有软中断请求到来,随后调用网卡驱动注册的poll函数,poll函数将sk_buffer中的网络数据包送到内核协议栈中注册的ip_rcv函数中。
每个CPU会绑定一个ksoftirqd内核线程专门用来处理软中断响应。2个 CPU 时,就会有 ksoftirqd/0 和 ksoftirqd/1这两个内核线程。
这里有个事情需要注意下: 网卡接收到数据后,当DMA拷贝完成时,向CPU发出硬中断,这时哪个CPU上响应了这个硬中断,那么在网卡硬中断响应程序中发出的软中断请求也会在这个CPU绑定的ksoftirqd线程中响应。所以如果发现Linux软中断,CPU消耗都集中在一个核上的话,那么就需要调整硬中断的CPU亲和性,来将硬中断打散到不通的CPU核上去。
在ip_rcv函数中也就是上图中的网络层,取出数据包的IP头,判断该数据包下一跳的走向,如果数据包是发送给本机的,则取出传输层的协议类型(TCP或者UDP),并去掉数据包的IP头,将数据包交给上图中得传输层处理。
传输层的处理函数:TCP协议对应内核协议栈中注册的tcp_rcv函数,UDP协议对应内核协议栈中注册的udp_rcv函数。
当我们采用的是TCP协议时,数据包到达传输层时,会在内核协议栈中的tcp_rcv函数处理,在tcp_rcv函数中去掉TCP头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的Socket,如果找到对应的Socket则将网络数据包中的传输数据拷贝到Socket中的接收缓冲区中。如果没有找到,则发送一个目标不可达的icmp包。
现在我们把视角放到应用层,当我们程序通过系统调用read读取Socket接收缓冲区中的数据时,如果接收缓冲区中没有数据,那么应用程序就会在系统调用上阻塞,直到Socket接收缓冲区有数据,然后CPU将内核空间(Socket接收缓冲区)的数据拷贝到用户空间,最后系统调用read返回,应用程序读取数据。
有哪些性能开销:
应用程序通过系统调用从用户态转为内核态的开销以及系统调用返回时从内核态转为用户态的开销。
网络数据从内核空间通过CPU拷贝到用户空间的开销。
内核线程ksoftirqd响应软中断的开销。
CPU响应硬中断的开销。
DMA拷贝网络数据包到内存中的开销。
当我们在应用程序中调用send系统调用发送数据时,由于是系统调用所以线程会发生一次用户态到内核态的转换
为什么不直接使用Socket发送队列中的sk_buffer而是需要拷贝一份呢?因为TCP协议是支持丢包重传的,在没有收到对端的ACK之前,这个sk_buffer是不能删除的。
内核每次调用网卡发送数据的时候,实际上传递的是sk_buffer的拷贝副本(网卡DMA,不经过CPU),当网卡把数据发送出去后,sk_buffer拷贝副本会被释放。当收到对端的ACK之后,Socket发送队列中的sk_buffer才会被真正删除。
当设置完TCP头后,内核协议栈传输层的事情就做完了,下面通过调用ip_queue_xmit内核函数,正式来到内核协议栈网络层的处理。
以上过程全部是用户线程的内核态在执行,占用的CPU时间是系统态时间(sy),
现在发送流程终于到了网卡真实发送数据的阶段,前边我们讲到无论是用户线程的内核态还是触发NET_TX_SOFTIRQ类型的软中断在发送数据的时候最终会调用到网卡的驱动程序函数dev_hard_start_xmit来发送数据。
在网卡驱动程序函数dev_hard_start_xmit中会将sk_buffer映射到网卡可访问的内存 DMA 区域,最终网卡驱动程序通过DMA的方式将数据帧通过物理网卡发送出去。
数据发送完毕后,网卡设备会向CPU发送一个硬中断,CPU调用网卡驱动程序注册的硬中断响应程序,在硬中断响应中触发NET_RX_SOFTIRQ类型的软中断,在软中断的回调函数igb_poll中清理释放 sk_buffer,清理网卡发送队列(RingBuffer),解除 DMA 映射。
无论硬中断是因为有数据要接收,还是说发送完成通知,从硬中断触发的软中断都是 NET_RX_SOFTIRQ。
这里释放清理的只是sk_buffer的副本,真正的sk_buffer现在还是存放在Socket的发送队列中。前面在传输层处理的时候我们提到过,因为传输层需要保证可靠性,所以 sk_buffer其实还没有删除。它得等收到对方的 ACK 之后才会真正删除。

数据准备阶段: 在这个阶段,网络数据包到达网卡,通过DMA的方式将数据包拷贝到内存中,然后经过硬中断,软中断,接着通过内核线程ksoftirqd经过内核协议栈的处理,最终将数据发送到内核Socket的接收缓冲区中。
数据拷贝阶段: 当数据到达内核Socket的接收缓冲区中时,此时数据存在于内核空间中,需要将数据拷贝到用户空间中,才能够被应用程序读取。
阻塞与非阻塞的区别主要发生在第一阶段:数据准备阶段。
当应用程序发起系统调用read时,线程从用户态转为内核态,读取内核Socket的接收缓冲区中的网络数据。
阻塞
如果这时内核Socket的接收缓冲区没有数据,那么线程就会一直等待,直到Socket接收缓冲区有数据为止。随后将数据从内核空间拷贝到用户空间,系统调用read返回。

从图中我们可以看出:阻塞的特点是在第一阶段和第二阶段都会等待。
阻塞和非阻塞主要的区分是在第一阶段:数据准备阶段。
在第一阶段,当Socket的接收缓冲区中没有数据的时候,阻塞模式下应用线程会一直等待。非阻塞模式下应用线程不会等待,系统调用直接返回错误标志EWOULDBLOCK。
当Socket的接收缓冲区中有数据的时候,阻塞和非阻塞的表现是一样的,都会进入第二阶段等待数据从内核空间拷贝到用户空间,然后系统调用返回。

从上图中,我们可以看出:非阻塞的特点是第一阶段不会等待,但是在第二阶段还是会等待。
同步与异步主要的区别发生在第二阶段:数据拷贝阶段。
前边我们提到在数据拷贝阶段主要是将数据从内核空间拷贝到用户空间。然后应用程序才可以读取数据。
当内核Socket的接收缓冲区有数据到达时,进入第二阶段。
同步
同步模式在数据准备好后,是由用户线程的内核态来执行第二阶段。所以应用程序会在第二阶段发生阻塞,直到数据从内核空间拷贝到用户空间,系统调用才会返回。
Linux下的 epoll和Mac 下的 kqueue都属于同步 IO。
异步
异步模式下是由内核来执行第二阶段的数据拷贝操作,当内核执行完第二阶段,会通知用户线程IO操作已经完成,并将数据回调给用户线程。所以在异步模式下 数据准备阶段和数据拷贝阶段均是由内核来完成,不会对应用程序造成任何阻塞。
基于以上特征,我们可以看到异步模式需要内核的支持,比较依赖操作系统底层的支持。
在目前流行的操作系统中,只有Windows 中的 IOCP才真正属于异步 IO,实现的也非常成熟。但Windows很少用来作为服务器使用。
而常用来作为服务器使用的Linux,异步IO机制实现的不够成熟,与NIO相比性能提升的也不够明显。
但Linux kernel 在5.1版本由Facebook的大神Jens Axboe引入了新的异步IO库io_uring 改善了原来Linux native AIO的一些性能问题。性能相比Epoll以及之前原生的AIO提高了不少,值得关注。

阻塞读
当用户线程发起read系统调用,用户线程从用户态切换到内核态,在内核中去查看Socket接收缓冲区是否有数据到来。
Socket接收缓冲区中有数据,则用户线程在内核态将内核空间中的数据拷贝到用户空间,系统IO调用返回。
Socket接收缓冲区中无数据,则用户线程让出CPU,进入阻塞状态。当数据到达Socket接收缓冲区后,内核唤醒阻塞状态中的用户线程进入就绪状态,随后经过CPU的调度获取到CPU quota进入运行状态,将内核空间的数据拷贝到用户空间,随后系统调用返回。
当Socket发送缓冲区空间不够,无法容纳下全部发送数据时,用户线程让出CPU,进入阻塞状态,直到Socket发送缓冲区能够容纳下全部发送数据时,内核唤醒用户线程,执行后续发送流程。
网络连接也并不总是有数据可读,那么在空闲的这段时间内,服务端线程就会一直处于阻塞状态,无法干其他的事情。CPU也无法得到充分的发挥,同时还会导致大量线程切换的开销。

当用户线程发起非阻塞read系统调用时,用户线程从用户态转为内核态,在内核中去查看Socket接收缓冲区是否有数据到来。
Socket接收缓冲区中无数据,系统调用立马返回,并带有一个 EWOULDBLOCK 或 EAGAIN错误,这个阶段用户线程不会阻塞,也不会让出CPU,而是会继续轮训直到Socket接收缓冲区中有数据为止。
Socket接收缓冲区中有数据,用户线程在内核态会将内核空间中的数据拷贝到用户空间,注意这个数据拷贝阶段,应用程序是阻塞的,当数据拷贝完成,系统调用返回。
非阻塞写的特点就比较佛系,当发送缓冲区中没有足够的空间容纳全部发送数据时,非阻塞写的特点是能写多少写多少,写不下了,就立即返回。并将写入到发送缓冲区的字节数返回给应用程序,方便用户线程不断的轮训尝试将剩下的数据写入发送缓冲区中。
我们可以利用一个线程或者很少的线程,去不断地轮询每个Socket的接收缓冲区是否有数据到达,如果没有数据,不必阻塞线程,而是接着去轮询下一个Socket接收缓冲区,直到轮询到数据后,处理连接上的读写,或者交给业务线程池去处理,轮询线程则继续轮询其他的Socket接收缓冲区。
但是它仍然有很大的性能问题,因为在非阻塞IO模型下,需要用户线程去不断地发起系统调用去轮训Socket接收缓冲区,这就需要用户线程不断地从用户态切换到内核态,内核态切换到用户态。随着并发量的增大,这个上下文切换的开销也是巨大的。
网络IO模型的演变都是围绕着---如何用尽可能少的线程去处理更多的连接这个核心需求开始展开的。
多路:我们的核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的多路指的就是我们需要处理的众多连接。
复用:核心需求要求我们使用尽可能少的线程,尽可能少的系统开销去处理尽可能多的连接(多路),那么这里的复用指的就是用有限的资源,比如用一个线程或者固定数量的线程去处理众多连接上的读写事件。换句话说,在阻塞IO模型中一个连接就需要分配一个独立的线程去专门处理这个连接上的读写,到了IO多路复用模型中,多个连接可以复用这一个独立的线程去处理这多个连接上的读写。
但是非阻塞IO模型最大的问题就是需要不断的发起系统调用去轮询各个Socket中的接收缓冲区是否有数据到来,频繁的系统调用随之带来了大量的上下文切换开销。随着并发量的提升,这样也会导致非常严重的性能问题。
那么如何避免频繁的系统调用同时又可以实现我们的核心需求呢?
这就需要操作系统的内核来支持这样的操作,我们可以把频繁的轮询操作交给操作系统内核来替我们完成,这样就避免了在用户空间频繁的去使用系统调用来轮询所带来的性能开销。
select
select是操作系统内核提供给我们使用的一个系统调用,它解决了在非阻塞IO模型中需要不断的发起系统IO调用去轮询各个连接上的Socket接收缓冲区所带来的用户空间与内核空间不断切换的系统开销。
select系统调用将轮询的操作交给了内核来帮助我们完成,从而避免了在用户空间不断的发起轮询所带来的的系统性能开销。
首先用户线程在发起select系统调用的时候会阻塞在select系统调用上。此时,用户线程从用户态切换到了内核态完成了一次上下文切换
用户线程将需要监听的Socket对应的文件描述符fd数组通过select系统调用传递给内核。此时,用户线程将用户空间中的文件描述符fd数组拷贝到内核空间。
当用户线程调用完select后开始进入阻塞状态,内核开始轮询遍历fd数组,查看fd对应的Socket接收缓冲区中是否有数据到来。如果有数据到来,则将fd对应BitMap的值设置为1。如果没有数据到来,则保持值为0。
内核遍历一遍fd数组后,如果发现有些fd上有IO数据到来,则将修改后的fd数组返回给用户线程。此时,会将fd数组从内核空间拷贝到用户空间。
select系统调用是在规定的超时时间内,监听(轮询)用户感兴趣的文件描述符集合上的可读,可写,异常三类事件。
在发起select系统调用以及返回时,用户线程各发生了一次用户态到内核态以及内核态到用户态的上下文切换开销。发生2次上下文切换
在发起select系统调用以及返回时,用户线程在内核态需要将文件描述符集合从用户空间拷贝到内核空间。以及在内核修改完文件描述符集合后,又要将它从内核空间拷贝到用户空间。发生2次文件描述符集合的拷贝
虽然由原来在用户空间发起轮询优化成了在内核空间发起轮询但select不会告诉用户线程到底是哪些Socket上发生了IO就绪事件,只是对IO就绪的Socket作了标记,用户线程依然要遍历文件描述符集合去查找具体IO就绪的Socket。时间复杂度依然为O(n)。
使用轮询的这种方式会随着连接数的增大,效率会越来越低。
以上select的不足所产生的性能开销都会随着并发量的增大而线性增长。
很明显select也不能解决C10K问题,只适用于1000个左右的并发连接场景。
poll只是改进了select只能监听1024个文件描述符的数量限制
通过上边对select,poll核心原理的介绍,我们看到select,poll的性能瓶颈主要体现在下面三个地方:
因为内核不会保存我们要监听的socket集合,所以在每次调用select,poll的时候都需要传入,传出全量的socket文件描述符集合。这导致了大量的文件描述符在用户空间和内核空间频繁的来回复制。
由于内核不会通知具体IO就绪的socket,只是在这些IO就绪的socket上打好标记,所以当select系统调用返回时,在用户空间还是需要完整遍历一遍socket文件描述符集合来获取具体IO就绪的socket。
在内核空间中也是通过遍历的方式来得到IO就绪的socket。
在我们进行网络程序的编写时会首先创建一个Socket,然后基于这个Socket进行bind,listen,我们先将这个Socket称作为监听Socket。
当我们调用accept后,内核会基于监听Socket创建出来一个新的Socket专门用于与客户端之间的网络通信。
这里需要注意的是,监听的 socket和真正用来网络通信的 Socket,是两个 Socket,一个叫作监听 Socket,一个叫作已连接的Socket。
而在epoll中为了综合性能的考虑,采用一颗红黑树来管理这些海量socket连接。所以struct epitem是一个红黑树节点。
epoll在内核中通过红黑树管理海量的连接,所以在调用epoll_wait获取IO就绪的socket时,不需要传入监听的socket文件描述符。从而避免了海量的文件描述符集合在用户空间和内核空间中来回复制。
select,poll每次调用时都需要传递全量的文件描述符集合,导致大量频繁的拷贝操作。
epoll仅会通知IO就绪的socket。避免了在用户空间遍历的开销。
epoll通过在socket的等待队列上注册回调函数ep_poll_callback通知用户程序IO就绪的socket。避免了在内核中轮询的开销。
大部分情况下socket上并不总是IO活跃的,在面对海量连接的情况下,select,poll采用内核轮询的方式获取IO活跃的socket,无疑是性能低下的核心原因。
根据以上epoll的性能优势,它是目前为止各大主流网络框架,以及反向代理中间件使用到的网络IO模型。
利用epoll多路复用IO模型可以轻松的解决C10K问题。
以上介绍的四种IO模型均为同步IO,它们都会阻塞在第二阶段数据拷贝阶段。
异步IO的系统调用需要操作系统内核来支持,目前只有Window中的IOCP实现了非常成熟的异步IO机制。
而Linux系统对异步IO机制实现的不够成熟,且与NIO的性能相比提升也不明显。
但Linux kernel 在5.1版本由Facebook的大神Jens Axboe引入了新的异步IO库io_uring 改善了原来Linux native AIO的一些性能问题。性能相比Epoll以及之前原生的AIO提高了不少,值得关注。
再加上信号驱动IO模型不适用TCP协议,所以目前大部分采用的还是IO多路复用模型。
Reactor是利用NIO对IO线程进行不同的分工:
使用前边我们提到的IO多路复用模型比如select,poll,epoll,kqueue,进行IO事件的注册和监听。
将监听到就绪的IO事件分发dispatch到各个具体的处理Handler中进行相应的IO事件处理。
通过IO多路复用技术就可以不断的监听IO事件,不断的分发dispatch,就像一个反应堆一样,看起来像不断的产生IO事件,因此我们称这种模式为Reactor模型。
单Reactor多线程
这种模式下,也是只有一个epoll对象来监听所有的IO事件,一个线程来调用epoll_wait获取IO就绪的Socket。
但是当IO就绪事件产生时,这些IO事件对应处理的业务Handler,我们是通过线程池来执行。这样相比单Reactor单线程模型提高了执行效率,充分发挥了多核CPU的优势。
Proactor
Proactor是基于AIO对IO线程进行分工的一种模型。前边我们介绍了异步IO模型,它是操作系统内核支持的一种全异步编程模型,在数据准备阶段和数据拷贝阶段全程无阻塞。
ProactorIO线程模型将IO事件的监听,IO操作的执行,IO结果的dispatch统统交给内核来做。
用户线程发起aio_read,并告诉内核用户空间中的读缓冲区地址,以便内核完成IO操作将结果放入用户空间的读缓冲区,用户线程直接可以读取结果(无任何阻塞)。
在Proactor中我们关心的IO完成事件:内核已经帮我们读好数据并放入我们指定的读缓冲区,用户线程可以直接读取。在Reactor中我们关心的是IO就绪事件:数据已经到来,但是需要用户线程自己去读取。
此时用户线程就可以做其他事情了,无需等待IO结果。而内核与此同时开始异步执行IO操作。当IO操作完成时会产生一个completion event事件,将这个IO完成事件放入completion event queue中。
Proactor从completion event queue中取出completion event,并回调与IO完成事件关联的completion handler。
在completion handler中完成业务逻辑处理。
Reactor是基于NIO实现的一种IO线程模型,Proactor是基于AIO实现的IO线程模型。
Reactor关心的是IO就绪事件,Proactor关心的是IO完成事件。
在Proactor中,用户程序需要向内核传递用户空间的读缓冲区地址。Reactor则不需要。这也就导致了在Proactor中每个并发操作都要求有独立的缓存区,在内存上有一定的开销。
Proactor 的实现逻辑复杂,编码成本较 Reactor要高很多。
在我们介绍Reactor IO线程模型的时候提到有三种Reactor模型:单Reactor单线程,单Reactor多线程,主从Reactor多线程。
这三种Reactor模型在netty中都是支持的,但是我们常用的是主从Reactor多线程模型。

Reactor在netty中是以group的形式出现的,netty中将Reactor分为两组,一组是MainReactorGroup也就是我们在编码中常常看到的EventLoopGroup bossGroup,另一组是SubReactorGroup也就是我们在编码中常常看到的EventLoopGroup workerGroup。
MainReactorGroup中通常只有一个Reactor,专门负责做最重要的事情,也就是监听连接accept事件。当有连接事件产生时,在对应的处理handler acceptor中创建初始化相应的NioSocketChannel(代表一个Socket连接)。然后以负载均衡的方式在SubReactorGroup中选取一个Reactor,注册上去,监听Read事件。
MainReactorGroup中只有一个Reactor的原因是,通常我们服务端程序只会绑定监听一个端口,如果要绑定监听多个端口,就会配置多个Reactor。
SubReactorGroup中有多个Reactor,具体Reactor的个数可以由系统参数 -D io.netty.eventLoopThreads指定。默认的Reactor的个数为CPU核数 * 2。SubReactorGroup中的Reactor主要负责监听读写事件,每一个Reactor负责监听一组socket连接。将全量的连接分摊在多个Reactor中。
一个Reactor分配一个IO线程,这个IO线程负责从Reactor中获取IO就绪事件,执行IO调用获取IO数据,执行PipeLine。
Socket连接在创建后就被固定的分配给一个Reactor,所以一个Socket连接也只会被一个固定的IO线程执行,
由于每个Reactor中只有一个IO线程,这个IO线程既要执行IO活跃Socket连接对应的PipeLine中的ChannelHandler,又要从Reactor中获取IO就绪事件,执行IO调用。所以PipeLine中ChannelHandler中执行的逻辑不能耗时太长,尽量将耗时的业务逻辑处理放入单独的业务线程池中处理,否则会影响其他连接的IO读写,从而近一步影响整个服务程序的IO吞吐。
当IO请求在业务线程中完成相应的业务逻辑处理后,在业务线程中利用持有的ChannelHandlerContext引用将响应数据在PipeLine中反向传播,最终写回给客户端。

以下是总结:
select
用户空间中的文件描述符fd数组拷贝到内核空间。
内核开始轮询遍历fd数组
将数组从内核空间拷贝到用户空间
用户态再遍历找出有新消息的fd
poll > 1024
epoll在内核中通过红黑树管理连接,避免拷贝
epoll通过在socket的等待队列上注册回调函数ep_poll_callback通知用户程序IO就绪的socket,避免了在内核中轮询的开销。
epoll仅会通知IO就绪的socket,避免用户空间遍历
MainReactorGroup中通常只有一个Reactor,监听accept事件,创建NioSocketChannel(代表一个Socket连接)。然后以负载均衡的方式在SubReactorGroup中选取一个Reactor,注册上去,监听Read事件。
ubReactorGroup中的Reactor监听读写事件,每一个Reactor监听一组socket连接。将全量的连接分摊在多个Reactor中。
一个Reactor分配一个IO线程,这个IO线程负责从Reactor中获取IO就绪事件,执行IO调用获取IO数据,执行PipeLine。
socket连接在创建后就被固定分配给一个Reactor,所以一个Socket连接在一个固定的IO线程
浙公网安备 33010602011771号