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​​中反向传播,最终写回给客户端

image

 

 

 

以下是总结: 

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线程​​​

posted on 2025-06-19 01:23  silyvin  阅读(54)  评论(0)    收藏  举报