深入讲解Netty那些事儿之从内核角度看IO模型(下)
接上文深入讲解Netty那些事儿之从内核角度看IO模型(上)
epoll
通过上边对select,poll
核心原理的介绍,我们看到select,poll
的性能瓶颈主要体现在下面三个地方:
因为内核不会保存我们要监听的
socket
集合,所以在每次调用select,poll
的时候都需要传入,传出全量的socket
文件描述符集合。这导致了大量的文件描述符在用户空间
和内核空间
频繁的来回复制。由于内核不会通知具体
IO就绪
的socket
,只是在这些IO就绪
的socket上打好标记,所以当select
系统调用返回时,在用户空间
还是需要完整遍历
一遍socket
文件描述符集合来获取具体IO就绪
的socket
。在
内核空间
中也是通过遍历的方式来得到IO就绪
的socket
。
下面我们来看下epoll
是如何解决这些问题的。在介绍epoll
的核心原理之前,我们需要介绍下理解epoll
工作过程所需要的一些核心基础知识。
Socket的创建
服务端线程调用accept
系统调用后开始阻塞
,当有客户端连接上来并完成TCP三次握手
后,内核
会创建一个对应的Socket
作为服务端与客户端通信的内核
接口。
在Linux内核的角度看来,一切皆是文件,Socket
也不例外,当内核创建出Socket
之后,会将这个Socket
放到当前进程所打开的文件列表中管理起来。
下面我们来看下进程管理这些打开的文件列表相关的内核数据结构是什么样的?在了解完这些数据结构后,我们会更加清晰的理解Socket
在内核中所发挥的作用。并且对后面我们理解epoll
的创建过程有很大的帮助。
进程中管理文件列表结构

struct tast_struct
是内核中用来表示进程的一个数据结构,它包含了进程的所有信息。本小节我们只列出和文件管理相关的属性。
其中进程内打开的所有文件是通过一个数组fd_array
来进行组织管理,数组的下标即为我们常提到的文件描述符
,数组中存放的是对应的文件数据结构struct file
。每打开一个文件,内核都会创建一个struct file
与之对应,并在fd_array
中找到一个空闲位置分配给它,数组中对应的下标,就是我们在用户空间
用到的文件描述符
。
对于任何一个进程,默认情况下,文件描述符
0
表示stdin 标准输入
,文件描述符1
表示stdout 标准输出
,文件描述符2
表示stderr 标准错误输出
。
进程中打开的文件列表fd_array
定义在内核数据结构struct files_struct
中,在struct fdtable
结构中有一个指针struct fd **fd
指向fd_array
。
由于本小节讨论的是内核网络系统部分的数据结构,所以这里拿Socket
文件类型来举例说明:
用于封装文件元信息的内核数据结构struct file
中的private_data
指针指向具体的Socket
结构。
struct file
中的file_operations
属性定义了文件的操作函数,不同的文件类型,对应的file_operations
是不同的,针对Socket
文件类型,这里的file_operations
指向socket_file_ops
。
我们在
用户空间
对Socket
发起的读写等系统调用,进入内核首先会调用的是Socket
对应的struct file
中指向的socket_file_ops
。比如:对Socket
发起write
写操作,在内核中首先被调用的就是socket_file_ops
中定义的sock_write_iter
。Socket
发起read
读操作内核中对应的则是sock_read_iter
。
Socket内核结构

在我们进行网络程序的编写时会首先创建一个Socket
,然后基于这个Socket
进行bind
,listen
,我们先将这个Socket
称作为监听Socket
。
当我们调用
accept
后,内核会基于监听Socket
创建出来一个新的Socket
专门用于与客户端之间的网络通信。并将监听Socket
中的Socket操作函数集合
(inet_stream_ops
)ops
赋值到新的Socket
的ops
属性中。
这里需要注意的是,
监听的 socket
和真正用来网络通信的Socket
,是两个 Socket,一个叫作监听 Socket
,一个叫作已连接的Socket
。
接着内核会为
已连接的Socket
创建struct file
并初始化,并把Socket文件操作函数集合(socket_file_ops
)赋值给struct file
中的f_ops
指针。然后将struct socket
中的file
指针指向这个新分配申请的struct file
结构体。
内核会维护两个队列:
一个是已经完成
TCP三次握手
,连接状态处于established
的连接队列。内核中为icsk_accept_queue
。一个是还没有完成
TCP三次握手
,连接状态处于syn_rcvd
的半连接队列。
然后调用
socket->ops->accept
,从Socket内核结构图
中我们可以看到其实调用的是inet_accept
,该函数会在icsk_accept_queue
中查找是否有已经建立好的连接,如果有的话,直接从icsk_accept_queue
中获取已经创建好的struct sock
。并将这个struct sock
对象赋值给struct socket
中的sock
指针。
struct sock
在struct socket
中是一个非常核心的内核对象,正是在这里定义了我们在介绍网络包的接收发送流程
中提到的接收队列
,发送队列
,等待队列
,数据就绪回调函数指针
,内核协议栈操作函数集合
根据创建
Socket
时发起的系统调用sock_create
中的protocol
参数(对于TCP协议
这里的参数值为SOCK_STREAM
)查找到对于 tcp 定义的操作方法实现集合inet_stream_ops
和tcp_prot
。并把它们分别设置到socket->ops
和sock->sk_prot
上。
这里可以回看下本小节开头的《Socket内核结构图》捋一下他们之间的关系。
socket
相关的操作接口定义在inet_stream_ops
函数集合中,负责对上给用户提供接口。而socket
与内核协议栈之间的操作接口定义在struct sock
中的sk_prot
指针上,这里指向tcp_prot
协议操作函数集合。
之前提到的对
Socket
发起的系统IO调用,在内核中首先会调用Socket
的文件结构struct file
中的file_operations
文件操作集合,然后调用struct socket
中的ops
指向的inet_stream_ops
socket操作函数,最终调用到struct sock
中sk_prot
指针指向的tcp_prot
内核协议栈操作函数接口集合。

将
struct sock
对象中的sk_data_ready
函数指针设置为sock_def_readable
,在Socket
数据就绪的时候内核会回调该函数。struct sock
中的等待队列
中存放的是系统IO调用发生阻塞的进程fd
,以及相应的回调函数
。记住这个地方,后边介绍epoll的时候我们还会提到!
当
struct file
,struct socket
,struct sock
这些核心的内核对象创建好之后,最后就是把socket
对象对应的struct file
放到进程打开的文件列表fd_array
中。随后系统调用accept
返回socket
的文件描述符fd
给用户程序。
【文章福利】小编推荐自己的Linux内核技术交流群:【749907784】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!(含视频教程、电子书、实战项目及代码)


阻塞IO中用户进程阻塞以及唤醒原理
在前边小节我们介绍阻塞IO
的时候提到,当用户进程发起系统IO调用时,这里我们拿read
举例,用户进程会在内核态
查看对应Socket
接收缓冲区是否有数据到来。
Socket
接收缓冲区有数据,则拷贝数据到用户空间
,系统调用返回。Socket
接收缓冲区没有数据,则用户进程让出CPU
进入阻塞状态
,当数据到达接收缓冲区时,用户进程会被唤醒,从阻塞状态
进入就绪状态
,等待CPU调度。
本小节我们就来看下用户进程是如何阻塞
在Socket
上,又是如何在Socket
上被唤醒的。理解这个过程很重要,对我们理解epoll的事件通知过程很有帮助
首先我们在用户进程中对
Socket
进行read
系统调用时,用户进程会从用户态
转为内核态
。在进程的
struct task_struct
结构找到fd_array
,并根据Socket
的文件描述符fd
找到对应的struct file
,调用struct file
中的文件操作函数结合file_operations
,read
系统调用对应的是sock_read_iter
。在
sock_read_iter
函数中找到struct file
指向的struct socket
,并调用socket->ops->recvmsg
,这里我们知道调用的是inet_stream_ops
集合中定义的inet_recvmsg
。在
inet_recvmsg
中会找到struct sock
,并调用sock->skprot->recvmsg
,这里调用的是tcp_prot
集合中定义的tcp_recvmsg
函数。
整个调用过程可以参考上边的《系统IO调用结构图》
熟悉了内核函数调用栈后,我们来看下系统IO调用在tcp_recvmsg
内核函数中是如何将用户进程给阻塞掉的

首先会在DEFINE_WAIT
中创建struct sock
中等待队列上的等待类型wait_queue_t
。
等待类型wait_queue_t
中的private
用来关联阻塞
在当前socket
上的用户进程fd
。func
用来关联等待项上注册的回调函数。这里注册的是autoremove_wake_function
。
调用
sk_sleep(sk)
获取struct sock
对象中的等待队列头指针wait_queue_head_t
。调用
prepare_to_wait
将新创建的等待项wait_queue_t
插入到等待队列中,并将进程设置为可打断INTERRUPTIBL
。调用
sk_wait_event
让出CPU,进程进入睡眠状态。
用户进程的阻塞过程
我们就介绍完了,关键是要理解记住struct sock
中定义的等待队列上的等待类型wait_queue_t
的结构。后面epoll
的介绍中我们还会用到它。
下面我们接着介绍当数据就绪后,用户进程是如何被唤醒的
在本文开始介绍《网络包接收过程》这一小节中我们提到:
当网络数据包到达网卡时,网卡通过
DMA
的方式将数据放到RingBuffer
中。然后向CPU发起硬中断,在硬中断响应程序中创建
sk_buffer
,并将网络数据拷贝至sk_buffer
中。随后发起软中断,内核线程
ksoftirqd
响应软中断,调用poll函数
将sk_buffer
送往内核协议栈做层层协议处理。在传输层
tcp_rcv 函数
中,去掉TCP头,根据四元组(源IP,源端口,目的IP,目的端口)
查找对应的Socket
。最后将
sk_buffer
放到Socket
中的接收队列里。
上边这些过程是内核接收网络数据的完整过程,下边我们来看下,当数据包接收完毕后,用户进程是如何被唤醒的。

当软中断将
sk_buffer
放到Socket
的接收队列上时,接着就会调用数据就绪函数回调指针sk_data_ready
,前边我们提到,这个函数指针在初始化的时候指向了sock_def_readable
函数。在
sock_def_readable
函数中会去获取socket->sock->sk_wq
等待队列。在wake_up_common
函数中从等待队列sk_wq
中找出一个
等待项wait_queue_t
,回调注册在该等待项上的func
回调函数(wait_queue_t->func
),创建等待项wait_queue_t
是我们提到,这里注册的回调函数是autoremove_wake_function
。
即使是有多个进程都阻塞在同一个 socket 上,也只唤醒 1 个进程。其作用是为了避免惊群。
在
autoremove_wake_function
函数中,根据等待项wait_queue_t
上的private
关联的阻塞进程fd
调用try_to_wake_up
唤醒阻塞在该Socket
上的进程。
记住
wait_queue_t
中的func
函数指针,在epoll
中这里会注册epoll
的回调函数。
现在理解epoll
所需要的基础知识我们就介绍完了,唠叨了这么多,下面终于正式进入本小节的主题epoll
了。
epoll_create创建epoll对象
epoll_create
是内核提供给我们创建epoll
对象的一个系统调用,当我们在用户进程中调用epoll_create
时,内核会为我们创建一个struct eventpoll
对象,并且也有相应的struct file
与之关联,同样需要把这个struct eventpoll
对象所关联的struct file
放入进程打开的文件列表fd_array
中管理。
熟悉了
Socket
的创建逻辑,epoll
的创建逻辑也就不难理解了。
struct eventpoll
对象关联的struct file
中的file_operations 指针
指向的是eventpoll_fops
操作函数集合。

wait_queue_head_t wq:
epoll中的等待队列,队列里存放的是阻塞
在epoll
上的用户进程。在IO就绪
的时候epoll
可以通过这个队列找到这些阻塞
的进程并唤醒它们,从而执行IO调用
读写Socket
上的数据。
这里注意与
Socket
中的等待队列区分!!!
struct list_head rdllist:
epoll中的就绪队列,队列里存放的是都是IO就绪
的Socket
,被唤醒的用户进程可以直接读取这个队列获取IO活跃
的Socket
。无需再次遍历整个Socket
集合。
这里正是
epoll
比select ,poll
高效之处,select ,poll
返回的是全部的socket
连接,我们需要在用户空间
再次遍历找出真正IO活跃
的Socket
连接。而epoll
只是返回IO活跃
的Socket
连接。用户进程可以直接进行IO操作。
struct rb_root rbr :
由于红黑树在查找
,插入
,删除
等综合性能方面是最优的,所以epoll内部使用一颗红黑树来管理海量的Socket
连接。
select
用数组
管理连接,poll
用链表
管理连接。
epoll_ctl向epoll对象中添加监听的Socket
当我们调用epoll_create
在内核中创建出epoll
对象struct eventpoll
后,我们就可以利用epoll_ctl
向epoll
中添加我们需要管理的Socket
连接了。
首先要在epoll内核中创建一个表示
Socket连接
的数据结构struct epitem
,而在epoll
中为了综合性能的考虑,采用一颗红黑树来管理这些海量socket连接
。所以struct epitem
是一个红黑树节点。

这里重点记住
struct epitem
结构中的rdllink
以及epoll_filefd
成员,后面我们会用到。
在内核中创建完表示
Socket连接
的数据结构struct epitem
后,我们就需要在Socket
中的等待队列上创建等待项wait_queue_t
并且注册epoll的回调函数ep_poll_callback
。
通过《阻塞IO中用户进程阻塞以及唤醒原理》
小节的铺垫,我想大家已经猜到这一步的意义所在了吧!当时在等待项wait_queue_t
中注册的是autoremove_wake_function
回调函数。还记得吗?
epoll的回调函数
ep_poll_callback
正是epoll
同步IO事件通知机制的核心所在,也是区别于select,poll
采用内核轮询方式的根本性能差异所在。

这里又出现了一个新的数据结构struct eppoll_entry
,那它的作用是干什么的呢?大家可以结合上图先猜测下它的作用!
我们知道socket->sock->sk_wq
等待队列中的类型是wait_queue_t
,我们需要在struct epitem
所表示的socket
的等待队列上注册epoll
回调函数ep_poll_callback
。
这样当数据到达socket
中的接收队列时,内核会回调sk_data_ready
,在阻塞IO中用户进程阻塞以及唤醒原理
这一小节中,我们知道这个sk_data_ready
函数指针会指向sk_def_readable
函数,在sk_def_readable
中会回调注册在等待队列里的等待项wait_queue_t -> func
回调函数ep_poll_callback
。在ep_poll_callback
中需要找到epitem
,将IO就绪
的epitem
放入epoll
中的就绪队列中。
而socket
等待队列中类型是wait_queue_t
无法关联到epitem
。所以就出现了struct eppoll_entry
结构体,它的作用就是关联Socket
等待队列中的等待项wait_queue_t
和epitem
。
这样在ep_poll_callback
回调函数中就可以根据Socket
等待队列中的等待项wait
,通过container_of宏
找到eppoll_entry
,继而找到epitem
了。
container_of
在Linux内核中是一个常用的宏,用于从包含在某个结构中的指针获得结构本身的指针,通俗地讲就是通过结构体变量中某个成员的首地址进而获得整个结构体变量的首地址。
这里需要注意下这次等待项
wait_queue_t
中的private
设置的是null
,因为这里Socket
是交给epoll
来管理的,阻塞在Socket
上的进程是也由epoll
来唤醒。在等待项wait_queue_t
注册的func
是ep_poll_callback
而不是autoremove_wake_function
,阻塞进程
并不需要autoremove_wake_function
来唤醒,所以这里设置private
为null
当在
Socket
的等待队列中创建好等待项wait_queue_t
并且注册了epoll
的回调函数ep_poll_callback
,然后又通过eppoll_entry
关联了epitem
后。剩下要做的就是将epitem
插入到epoll
中的红黑树struct rb_root rbr
中。
这里可以看到
epoll
另一个优化的地方,epoll
将所有的socket
连接通过内核中的红黑树来集中管理。每次添加或者删除socket连接
都是增量添加删除,而不是像select,poll
那样每次调用都是全量socket连接
集合传入内核。避免了频繁大量
的内存拷贝
。
epoll_wait同步阻塞获取IO就绪的Socket
用户程序调用
epoll_wait
后,内核首先会查找epoll中的就绪队列eventpoll->rdllist
是否有IO就绪
的epitem
。epitem
里封装了socket
的信息。如果就绪队列中有就绪的epitem
,就将就绪的socket
信息封装到epoll_event
返回。如果
eventpoll->rdllist
就绪队列中没有IO就绪
的epitem
,则会创建等待项wait_queue_t
,将用户进程的fd
关联到wait_queue_t->private
上,并在等待项wait_queue_t->func
上注册回调函数default_wake_function
。最后将等待项添加到epoll
中的等待队列中。用户进程让出CPU,进入阻塞状态
。

这里和
阻塞IO模型
中的阻塞原理是一样的,只不过在阻塞IO模型
中注册到等待项wait_queue_t->func
上的是autoremove_wake_function
,并将等待项添加到socket
中的等待队列中。这里注册的是default_wake_function
,将等待项添加到epoll
中的等待队列上。

前边做了那么多的知识铺垫,下面终于到了
epoll
的整个工作流程了:

当网络数据包在软中断中经过内核协议栈的处理到达
socket
的接收缓冲区时,紧接着会调用socket的数据就绪回调指针sk_data_ready
,回调函数为sock_def_readable
。在socket
的等待队列中找出等待项,其中等待项中注册的回调函数为ep_poll_callback
。在回调函数
ep_poll_callback
中,根据struct eppoll_entry
中的struct wait_queue_t wait
通过container_of宏
找到eppoll_entry
对象并通过它的base
指针找到封装socket
的数据结构struct epitem
,并将它加入到epoll
中的就绪队列rdllist
中。随后查看
epoll
中的等待队列中是否有等待项,也就是说查看是否有进程阻塞在epoll_wait
上等待IO就绪
的socket
。如果没有等待项,则软中断处理完成。如果有等待项,则回到注册在等待项中的回调函数
default_wake_function
,在回调函数中唤醒阻塞进程
,并将就绪队列rdllist
中的epitem
的IO就绪
socket信息封装到struct epoll_event
中返回。用户进程拿到
epoll_event
获取IO就绪
的socket,发起系统IO调用读取数据。
再谈水平触发和边缘触发
网上有大量的关于这两种模式的讲解,大部分讲的比较模糊,感觉只是强行从概念上进行描述,看完让人难以理解。所以在这里,笔者想结合上边epoll
的工作过程,再次对这两种模式做下自己的解读,力求清晰的解释出这两种工作模式的异同。
经过上边对epoll
工作过程的详细解读,我们知道,当我们监听的socket
上有数据到来时,软中断会执行epoll
的回调函数ep_poll_callback
,在回调函数中会将epoll
中描述socket信息
的数据结构epitem
插入到epoll
中的就绪队列rdllist
中。随后用户进程从epoll
的等待队列中被唤醒,epoll_wait
将IO就绪
的socket
返回给用户进程,随即epoll_wait
会清空rdllist
。
水平触发和边缘触发最关键的区别就在于当socket
中的接收缓冲区还有数据可读时。epoll_wait
是否会清空rdllist
。
水平触发:在这种模式下,用户线程调用
epoll_wait
获取到IO就绪
的socket后,对Socket
进行系统IO调用读取数据,假设socket
中的数据只读了一部分没有全部读完,这时再次调用epoll_wait
,epoll_wait
会检查这些Socket
中的接收缓冲区是否还有数据可读,如果还有数据可读,就将socket
重新放回rdllist
。所以当socket
上的IO没有被处理完时,再次调用epoll_wait
依然可以获得这些socket
,用户进程可以接着处理socket
上的IO事件。边缘触发: 在这种模式下,
epoll_wait
就会直接清空rdllist
,不管socket
上是否还有数据可读。所以在边缘触发模式下,当你没有来得及处理socket
接收缓冲区的剩下可读数据时,再次调用epoll_wait
,因为这时rdlist
已经被清空了,socket
不会再次从epoll_wait
中返回,所以用户进程就不会再次获得这个socket
了,也就无法在对它进行IO处理了。除非,这个socket
上有新的IO数据到达,根据epoll
的工作过程,该socket
会被再次放入rdllist
中。
如果你在
边缘触发模式
下,处理了部分socket
上的数据,那么想要处理剩下部分的数据,就只能等到这个socket
上再次有网络数据到达。
在Netty
中实现的EpollSocketChannel
默认的就是边缘触发
模式。JDK
的NIO
默认是水平触发
模式。
epoll对select,poll的优化总结
epoll
在内核中通过红黑树
管理海量的连接,所以在调用epoll_wait
获取IO就绪
的socket时,不需要传入监听的socket文件描述符。从而避免了海量的文件描述符集合在用户空间
和内核空间
中来回复制。
select,poll
每次调用时都需要传递全量的文件描述符集合,导致大量频繁的拷贝操作。
epoll
仅会通知IO就绪
的socket。避免了在用户空间遍历的开销。
select,poll
只会在IO就绪
的socket上打好标记,依然是全量返回,所以在用户空间还需要用户程序在一次遍历全量集合找出具体IO就绪
的socket。
epoll
通过在socket
的等待队列上注册回调函数ep_poll_callback
通知用户程序IO就绪
的socket。避免了在内核中轮询的开销。
大部分情况下
socket
上并不总是IO活跃
的,在面对海量连接的情况下,select,poll
采用内核轮询的方式获取IO活跃
的socket,无疑是性能低下的核心原因。
根据以上epoll
的性能优势,它是目前为止各大主流网络框架,以及反向代理中间件使用到的网络IO模型。
利用epoll
多路复用IO模型可以轻松的解决C10K
问题。
C100k
的解决方案也还是基于C10K
的方案,通过epoll
配合线程池,再加上 CPU、内存和网络接口的性能和容量提升。大部分情况下,C100K
很自然就可以达到。
甚至C1000K
的解决方法,本质上还是构建在 epoll
的多路复用 I/O 模型
上。只不过,除了 I/O 模型之外,还需要从应用程序到 Linux 内核、再到 CPU、内存和网络等各个层次的深度优化,特别是需要借助硬件,来卸载那些原来通过软件处理的大量功能(去掉大量的中断响应开销
,以及内核协议栈处理的开销
)。
信号驱动IO

大家对这个装备肯定不会陌生,当我们去一些美食城吃饭的时候,点完餐付了钱,老板会给我们一个信号器。然后我们带着这个信号器可以去找餐桌,或者干些其他的事情。当信号器亮了的时候,这时代表饭餐已经做好,我们可以去窗口取餐了。
这个典型的生活场景和我们要介绍的信号驱动IO模型
就很像。
在信号驱动IO模型
下,用户进程操作通过系统调用 sigaction 函数
发起一个 IO 请求,在对应的socket
注册一个信号回调
,此时不阻塞
用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个 SIGIO 信号
,通过信号回调通知进程进行相关 IO 操作。
这里需要注意的是:
信号驱动式 IO 模型
依然是同步IO
,因为它虽然可以在等待数据的时候不被阻塞,也不会频繁的轮询,但是当数据就绪,内核信号通知后,用户进程依然要自己去读取数据,在数据拷贝阶段
发生阻塞。
信号驱动 IO模型 相比于前三种 IO 模型,实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以
理论上
性能更佳。
但是实际上,使用TCP协议
通信时,信号驱动IO模型
几乎不会被采用
。原因如下:
信号IO 在大量 IO 操作时可能会因为信号队列溢出导致没法通知
SIGIO 信号
是一种 Unix 信号,信号没有附加信息,如果一个信号源有多种产生信号的原因,信号接收者就无法确定究竟发生了什么。而 TCP socket 生产的信号事件有七种之多,这样应用程序收到 SIGIO,根本无从区分处理。
但信号驱动IO模型
可以用在 UDP
通信上,因为UDP 只有一个数据请求事件
,这也就意味着在正常情况下 UDP 进程只要捕获 SIGIO 信号,就调用 read 系统调用
读取到达的数据。如果出现异常,就返回一个异常错误。
这里插句题外话,大家觉不觉得阻塞IO模型
在生活中的例子就像是我们在食堂排队打饭。你自己需要排队去打饭同时打饭师傅在配菜的过程中你需要等待。

IO多路复用模型
就像是我们在饭店门口排队等待叫号。叫号器就好比select,poll,epoll
可以统一管理全部顾客的吃饭就绪
事件,客户好比是socket
连接,谁可以去吃饭了,叫号器就通知谁。

##异步IO(AIO)
以上介绍的四种IO模型
均为同步IO
,它们都会阻塞在第二阶段数据拷贝阶段
。
通过在前边小节《同步与异步》中的介绍,相信大家很容易就会理解异步IO模型
,在异步IO模型
下,IO操作在数据准备阶段
和数据拷贝阶段
均是由内核来完成,不会对应用程序造成任何阻塞。应用进程只需要在指定的数组
中引用数据即可。
异步 IO
与信号驱动 IO
的主要区别在于:信号驱动 IO
由内核通知何时可以开始一个 IO 操作
,而异步 IO
由内核通知 IO 操作何时已经完成
。
举个生活中的例子:异步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多路复用模型
。
IO线程模型
在前边内容的介绍中,我们详述了网络数据包的接收和发送过程,并通过介绍5种IO模型
了解了内核是如何读取网络数据并通知给用户线程的。
前边的内容都是以内核空间
的视角来剖析网络数据的收发模型,本小节我们站在用户空间
的视角来看下如果对网络数据进行收发。
相对内核
来讲,用户空间的IO线程模型
相对就简单一些。这些用户空间
的IO线程模型
都是在讨论当多线程一起配合工作时谁负责接收连接,谁负责响应IO 读写、谁负责计算、谁负责发送和接收,仅仅是用户IO线程的不同分工模式罢了。
Reactor
Reactor
是利用NIO
对IO线程
进行不同的分工:
使用前边我们提到的
IO多路复用模型
比如select,poll,epoll,kqueue
,进行IO事件的注册和监听。将监听到
就绪的IO事件
分发dispatch
到各个具体的处理Handler
中进行相应的IO事件处理
。
通过IO多路复用技术
就可以不断的监听IO事件
,不断的分发dispatch
,就像一个反应堆
一样,看起来像不断的产生IO事件
,因此我们称这种模式为Reactor
模型。
下面我们来看下Reactor模型
的三种分类:
单Reactor单线程

Reactor模型
是依赖IO多路复用技术
实现监听IO事件
,从而源源不断的产生IO就绪事件
,在Linux系统下我们使用epoll
来进行IO多路复用
,我们以Linux系统为例:
单
Reactor
意味着只有一个epoll
对象,用来监听所有的事件,比如连接事件
,读写事件
。单线程
意味着只有一个线程来执行epoll_wait
获取IO就绪
的Socket
,然后对这些就绪的Socket
执行读写,以及后边的业务处理也依然是这个线程。
单Reactor单线程
模型就好比我们开了一个很小很小的小饭馆,作为老板的我们需要一个人干所有的事情,包括:迎接顾客(accept事件
),为顾客介绍菜单等待顾客点菜(IO请求
),做菜(业务处理
),上菜(IO响应
),送客(断开连接
)。
单Reactor多线程
随着客人的增多(并发请求
),显然饭馆里的事情只有我们一个人干(单线程
)肯定是忙不过来的,这时候我们就需要多招聘一些员工(多线程
)来帮着一起干上述的事情。
于是就有了单Reactor多线程
模型:

这种模式下,也是只有一个
epoll
对象来监听所有的IO事件
,一个线程来调用epoll_wait
获取IO就绪
的Socket
。但是当
IO就绪事件
产生时,这些IO事件
对应处理的业务Handler
,我们是通过线程池来执行。这样相比单Reactor单线程
模型提高了执行效率,充分发挥了多核CPU的优势。
主从Reactor多线程
做任何事情都要区分事情的优先级
,我们应该优先高效
的去做优先级更高
的事情,而不是一股脑不分优先级的全部去做。
当我们的小饭馆客人越来越多(并发量越来越大
),我们就需要扩大饭店的规模,在这个过程中我们发现,迎接客人
是饭店最重要的工作,我们要先把客人迎接进来,不能让客人一看人多就走掉,只要客人进来了,哪怕菜做的慢一点也没关系。
于是,主从Reactor多线程
模型就产生了:

我们由原来的
单Reactor
变为了多Reactor
。主Reactor
用来优先专门
做优先级最高的事情,也就是迎接客人(处理连接事件
),对应的处理Handler
就是图中的acceptor
。当创建好连接,建立好对应的
socket
后,在acceptor
中将要监听的read事件
注册到从Reactor
中,由从Reactor
来监听socket
上的读写
事件。最终将读写的业务逻辑处理交给线程池处理。
注意:这里向
从Reactor
注册的只是read事件
,并没有注册write事件
,因为read事件
是由epoll内核
触发的,而write事件
则是由用户业务线程触发的(什么时候发送数据是由具体业务线程决定的
),所以write事件
理应是由用户业务线程
去注册。
用户线程注册
write事件
的时机是只有当用户发送的数据无法一次性
全部写入buffer
时,才会去注册write事件
,等待buffer重新可写
时,继续写入剩下的发送数据、如果用户线程可以一股脑的将发送数据全部写入buffer
,那么也就无需注册write事件
到从Reactor
中。
主从Reactor多线程
模型是现在大部分主流网络框架中采用的一种IO线程模型
。我们本系列的主题Netty
就是用的这种模型。
Proactor
Proactor
是基于AIO
对IO线程
进行分工的一种模型。前边我们介绍了异步IO模型
,它是操作系统内核支持的一种全异步编程模型,在数据准备阶段
和数据拷贝阶段
全程无阻塞。
ProactorIO线程模型
将IO事件的监听
,IO操作的执行
,IO结果的dispatch
统统交给内核
来做。

Proactor模型
组件介绍:
completion handler
为用户程序定义的异步IO操作回调函数,在异步IO操作完成时会被内核回调并通知IO结果。Completion Event Queue
异步IO操作完成后,会产生对应的IO完成事件
,将IO完成事件
放入该队列中。Asynchronous Operation Processor
负责异步IO
的执行。执行完成后产生IO完成事件
放入Completion Event Queue
队列中。Proactor
是一个事件循环派发器,负责从Completion Event Queue
中获取IO完成事件
,并回调与IO完成事件
关联的completion handler
。Initiator
初始化异步操作(asynchronous operation
)并通过Asynchronous Operation Processor
将completion handler
和proactor
注册到内核。
Proactor模型
执行过程:
用户线程发起
aio_read
,并告诉内核
用户空间中的读缓冲区地址,以便内核
完成IO操作
将结果放入用户空间
的读缓冲区,用户线程直接可以读取结果(无任何阻塞
)。Initiator
初始化aio_read
异步读取操作(asynchronous operation
),并将completion handler
注册到内核。
在
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与Proactor对比
Reactor
是基于NIO
实现的一种IO线程模型
,Proactor
是基于AIO
实现的IO线程模型
。Reactor
关心的是IO就绪事件
,Proactor
关心的是IO完成事件
。在
Proactor
中,用户程序需要向内核传递用户空间的读缓冲区地址
。Reactor
则不需要。这也就导致了在Proactor
中每个并发操作都要求有独立的缓存区,在内存上有一定的开销。Proactor
的实现逻辑复杂,编码成本较Reactor
要高很多。Proactor
在处理高耗时 IO
时的性能要高于Reactor
,但对于低耗时 IO
的执行效率提升并不明显
。
Netty的IO模型
在我们介绍完网络数据包在内核中的收发过程
以及五种IO模型
和两种IO线程模型
后,现在我们来看下netty
中的IO模型是什么样的。
在我们介绍Reactor IO线程模型
的时候提到有三种Reactor模型
:单Reactor单线程
,单Reactor多线程
,主从Reactor多线程
。
这三种Reactor模型
在netty
中都是支持的,但是我们常用的是主从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线程
执行,每个Socket连接
分配一个独立的PipeLine
实例,用来编排这个Socket连接
上的IO处理逻辑
。这种无锁串行化
的设计的目的是为了防止多线程并发执行同一个socket连接上的IO逻辑处理
,防止出现线程安全问题
。同时使系统吞吐量达到最大化
由于每个
Reactor
中只有一个IO线程
,这个IO线程
既要执行IO活跃Socket连接
对应的PipeLine
中的ChannelHandler
,又要从Reactor
中获取IO就绪事件
,执行IO调用
。所以PipeLine
中ChannelHandler
中执行的逻辑不能耗时太长,尽量将耗时的业务逻辑处理放入单独的业务线程池中处理,否则会影响其他连接的IO读写
,从而近一步影响整个服务程序的IO吞吐
。
当
IO请求
在业务线程中完成相应的业务逻辑处理后,在业务线程中利用持有的ChannelHandlerContext
引用将响应数据在PipeLine
中反向传播,最终写回给客户端。
netty
中的IO模型
我们介绍完了,下面我们来简单介绍下在netty
中是如何支持前边提到的三种Reactor模型
的。
配置单Reactor单线程
配置多Reactor线程
配置主从Reactor多线程
总结
本文是一篇信息量比较大的文章,用了25
张图,22336
个字从内核如何处理网络数据包的收发过程开始展开,随后又在内核角度
介绍了经常容易混淆的阻塞与非阻塞
,同步与异步
的概念。以这个作为铺垫,我们通过一个C10K
的问题,引出了五种IO模型
,随后在IO多路复用
中以技术演进的形式介绍了select,poll,epoll
的原理和它们综合的对比。最后我们介绍了两种IO线程模型
以及netty
中的Reactor模型
。
原文作者:bin的技术小屋
