IO多路复用
小结
原始做法:我们需要为每个连接起一个进程来处理,进程内不断调用内核的
read
方法轮询,判断fd
的就绪状态。对于那些未就绪的fd
,每次read
都是无效且耗费资源的操作通过
select
方法,我们可以将一批fd
交给内核,内核来进行遍历。内核会给就绪的fd
打上标记,我们再遍历一次fd
列表,处理其中就绪的fd
就可以了。相比1来说,复用一个进程处理连接而不是多个进程;且省去了每次循环里用户态切到内核态再调用read
的开销poll
方法去掉了select
只能监听 1024 个fd
的限制epoll
方法对select
的一些缺点进行了优化,如:用户调用时不再需要复制一份
fd
列表给内核,而是通过epoll_ctl
方法直接向内核中写入fd
内核仅会将就绪的
fd
返回给用户,不再需要用户自行遍历fd
列表内核也不是通过轮询的方式找到就绪的
fd
了 ,而是通过异步唤醒机制,性能更好
epoll
有边缘触发和水平触发两种方式,Nginx
、Go
使用的都是边缘触发,触发次数少效率高;redis
使用水平触发,保障数据一致性
《UNIX 网络编程》中总结了五种IO模型,分别是:
阻塞I/O
非阻塞I/O
I/O复用
信号驱动式I/O(SIGIO)
异步I/O(POSIX的aio_系列函数)
阻塞 IO
想一下如果让你来写一个处理网络请求的服务端,你会怎么写?
最基础的做法是,先起一个线程监听所有的网络连接。成功建立连接后,处理该请求。请求处理完成后,建立下一个连接,处理下一个请求。如此串行的、阻塞的处理。
把服务端处理请求的细节展开,得到如下图所示的同步阻塞网络 IO 的数据接收流程:
这就是传统的阻塞 IO。如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 recvfrom
函数上不返回,也无法接受其他客户端连接。
非阻塞IO
为了解决上面的问题,其关键在于改造这个 read
函数。当没有数据到达时,立刻返回一个错误值(-1),而不是阻塞地等待。
操作系统提供了这样的功能,只需要在调用 read
前,将文件描述符设置为非阻塞即可。
这样,就需要用户线程循环调用 read
,直到返回值不为 -1,再开始处理业务。
这种循环调用 read
的方式通常称为轮询 (polling),持续轮询内核,以这种方式查看某个操作是否就绪。select
,poll
、epoll
都需要通过轮询的方式调用。
这里注意一个细节:
非阻塞的 read
,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。
当数据已到达内核缓冲区,此时调用 read
函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。
也就是说,非阻塞IO模型将原来的两处阻塞变成了一处阻塞。
IO 多路复用
先解释一下多路复用这个词的概念:上面讨论的都是一个连接的情况。想要并发处理多个连接,最简单的方式就是为每一个连接启动一个线程处理,( apache
就是这样做的),但这样很浪费性能,因为每个连接只有一段时间在工作,其他时间是空闲的。因此有一个更聪明的方案:只起 1 个线程处理连接。处理时使用上面提到的非阻塞IO,这样连接有数据时线程去处理,没数据时线程便切换至下一个连接。这种使用一个线程处理多个连接,也即多个连接共用一个线程的做法,就叫多路复用。
这样的设计首先需要一个队列保存连接:
然后起一个的线程去不断遍历这个数组,使用非阻塞 read
方法判断该连接上是否有数据到达。
基于这种设计,Linux 下提供了 3 种多路复用函数:select, poll, epoll
select
select
:Linux 下多路复用最简单的实现。通过它,我们可以不用为每个 fd
调用一次 read
了,而是一次传一批 fd
, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理。
select
系统调用的函数定义如下。
fd
(file descriptor) 文件描述符是一个非负整数,可视为文件的ID/身份证号。
fd_set
是一个默认总大小为 __FD_SETSIZE = 1024
的 bitmap ( [32]int64
),每位可标记一个 fd
select
内部会遍历前 nfds
个 fd
,对每个 fd
判断是否就绪,如果就绪,则修改相应 fd_set
上对应的位为 1。调用者再检查 fd_set
select 的实现
设程序 A 同时监视 sock1, sock2, sock3
三个 socket
。
在调用 select
之后,操作系统把进程A分别加入这三个 socket
的等待队列中。
当任何一个 socket
收到数据后,中断程序将唤起进程A。所谓唤起进程,就是遍历所有的 socket
,将进程A从所有 socket
的等待队列中移除,然后加入到工作队列里面。
select的使用
服务端代码,这样来写。首先一个线程不断接收客户端连接,并把 socket fd
放到一个 list
里。
然后,另一个线程调用 select
,将这个 list
交给操作系统去遍历。
不过,当 select
函数返回后,用户依然需要遍历刚刚提交给操作系统的 list
。操作系统只会将准备就绪的文件描述符做上标识,并返回 ready
的数量。
可以看出几个问题(这几点将在 epoll
中得到优化):
select
调用时会陷入内核,需要将传参中的fd_set
拷贝到内核空间;select
执行完毕后,还需要将fd_set
从内核拷贝回用户空间;高并发场景下这样的拷贝消耗的资源是惊人的。(epoll优化为不拷贝)select
在内核层仍然是是在遍历每个socket
,是个同步过程,只不过无系统调用切换上下文的开销。(epoll优化为异步事件通知)select
仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(epoll为只返回给用户就绪的文件描述符,无需用户做无效的遍历)正是因为遍历操作开销大,出于效率的考量,才会规定
select
的最大监视数量,默认只能监视 1024 个socket
。
整个 select
的流程图如下。
可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select
的系统调用 + n 次就绪状态的文件描述符的 read
系统调用)。
poll
poll
的实现和 select
非常相似,只是描述 fd
集合的方式不同,select
使用了 bitmap
作为 fd_set
,设置了最大大小为 1024个。poll
改用了一个链表 pollfd
,因此没有这个限制。和 select
相比只是实现细节上的区分,并没有本质上的区别。
epoll
epoll
是最终的大 boss
,它解决了 select
和 poll
的一些问题。
上面说了 select
的三个问题, epoll
主要就是针对这三点进行了改进。
内核中使用红黑树保存一份文件描述符集合,每个文件描述符只在添加时传入一次,无需每次调用
select
时都整体重新传入解决了
select
中fd_set
重复拷贝到内核的问题
内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒
内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合
具体,操作系统提供了这三个函数。
使用方法如下:
epoll对象
主要结构体如下:
epoll
采用红黑树来存储所有监听的 fd
,红黑树本身插入和删除性能比较稳定,时间复杂度 O(logN)
。
epoll
利用 epoll_ctl
来插入或者删除一个 fd
,实现用户态到内核态的数据拷贝,这确保了每一个 fd
在其生命周期只需要被拷贝一次,而不是每次调用 epoll_wait
的时候都拷贝一次。相比 select & poll
需要把全部监听的 fd
集合从用户态拷贝至内核态的做法,效率高出了一大截。
当把 fd
添加进来的时候时候会完成关键的一步:该 fd
会与相应的设备(网卡)驱动程序建立回调关系,也就是在内核中断处理程序为它注册一个回调函数,当 fd
的 socket
有数据到达后,会触发中断,内核处理中断时就会调用这个回调函数。这个回调函数的内容就是把这个 fd
添加到一个就绪链表 rdllist
中。
epoll_wait
实际上就是去检查 rdllist
链表是否为空,不空说明有就绪的 fd
,从 wq
里找到并唤醒之前阻塞的用户进程,把 rdllist
中就绪的 fd
返回给该进程,让进程调用 recv
把该 fd
的 socket
里的数据拷贝到用户空间使用。
边缘触发 / 水平触发
epoll
支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。
二者的主要区别在于什么时候通知应用程序进行读写操作。边缘触发模式只在状态变化时通知一次,而水平触发模式会在处于就绪状态时不断通知。
边缘触发 Edge-triggered(ET)
使用边缘触发模式时,如果被监控的 fd socket
上有数据到达,根据上面介绍的流程,该 fd
会被放入 rdllist
。如果此时我们调用 epoll_wait()
获取该就绪队列,那么该队列会被清空,下次再调用 epoll_wait()
就不会再返回该 fd
了。
也就是说,进程只会从 epoll_wait()
中苏醒一次,即使进程没有调用 read()
函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据
因此,我们需要循环从文件描述符读写数据。那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read
和 write
)返回错误,错误类型为 EAGAIN
或 EWOULDBLOCK
。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait
的系统调用次数,系统调用也是有一定的开销的的。
使用时是类似这样的:
Go
里的 netpoll
用的就是边缘触发模式:
水平触发 Level-triggered (LT)
水平触发是默认的模式。
使用水平触发模式时,只要被监控的 fd socket
缓冲区中有数据,epoll_wait()
就会返回这个 fd
,直到 socket
缓冲区中没有数据了,它才会在下一次调用 epoll_wait()
时被从 rdlist
中移除
仍然用上面快递的例子,如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停
如何选择?
Nginx
、Go
的 netpoll
里是用的都是边缘模式,这种模式触发次数少,效率更高
Redis
采用的是水平模式,因为边缘模式下,如果读取或写入操作没有读写完全,内核将不会再次通知该文件描述符上的可读/可写事件,这样可能导致事件丢失。对于 Redis 这种数据库,数据一致性要求较高的场景来说,水平模式更合适一些。
同步非阻塞
所有I/O多路复用操作都是同步的,涵盖 select / poll / epoll
。
阻塞/非阻塞是相对于同步I/O来说的,与异步I/O无关。
使用 select / poll / epoll
时,仍然需要起一个死循环,循环内显式的调用 select / poll / epoll_wait
查询是否有就绪的 fd
,如果有,再自行执行后续的回调函数。因此只是非阻塞了,但回调仍然是同步执行的,即同步非阻塞调用。就像餐厅排队,取了号去逛街,逛一会儿就得回来问下到号了没有,需要不断的询问。
真正的异步 I/O,应当是把回调函数交给操作系统,系统在 fd
就绪时,自动执行回调,而不是用户进程自己检查、自己执行。
另外,select / poll / epoll
实际上也提供了阻塞的调用方式。将参数里的 timeout
设成 NULL / -1
,就变成阻塞的了。不过应该没人这么用吧。
问题
IO多路复用,复用的是什么?目的是什么?
复用的是进程资源。这样可以避免每个IO操作都创建一个新进程,减小进程的创建和销毁开销,提高系统的效率和性能。
Nginx 的 worker 复用一个进程来处理多个IO事件,Go 的 netpoller 复用一个协程处理所有的网络连接。
UNIX、MacOS 下没有 epoll
,取而代之的是 kqueue
,实现和 epoll
类似
参考
Last updated