IO多路复用底层原理
参考内容:I/O 多路复用:select/poll/epoll | 小林coding (xiaolincoding.com)
1.用户空间和内核空间
2.阻塞IO
顾名思义,阻塞IO就是两个阶段都必须阻塞等待
:
阶段一:
用户进程尝试读取数据(比如网卡数据)
此时数据尚未到达,内核需要等待数据
此时用户进程也处于阻塞状态
阶段二:
数据到达并拷贝到内核缓冲区,代表已就绪
将内核数据拷贝到用户缓冲区
拷贝过程中,用户进程依然阻塞等待
拷贝完成,用户进程解除阻塞,处理数据
3.非阻塞IO
顾名思义,非阻塞IO的recvfrom
操作会立即返回结果
而不是阻塞用户进程。
阶段一:
用户进程尝试读取数据(比如网卡数据)
此时数据尚未到达,内核需要等待数据
返回异常给用户进程
用户进程拿到error后,再次尝试读取
循环往复,直到数据就绪
阶段二:
将内核数据拷贝到用户缓冲区
拷贝过程中,用户进程依然阻塞等待
拷贝完成,用户进程解除阻塞,处理数据
可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞
,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高,而且忙等机制会导致CPU空转,CPU使用率暴增
。
4.多进程模型
基于最原始的阻塞网络 I/O,如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。
服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork()
函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。
正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket」和客户端通信了,可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。
下面这张图描述了从连接请求到连接建立,父进程创建子进程为客户服务。
另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。
因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait()
和 waitpid()
函数。
这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
5.多线程模型
既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型。
线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
当服务器与客户端 TCP 完成连接后,通过 pthread_create()
函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。
那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。
需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。
上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。
6.I/O多路复用
既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。
IO多路复用是利用单个进程/线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
我们熟悉的select/poll/epoll内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select/poll/epoll这是三个多路复用接口,都能实现C10K吗?接下来,我们分别说说它们。
6.1select/poll
select实现多路复用的方式是,将已连接的Socket都放到一个文件描述符集合,然后调用select函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此Socket标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的Socket,然后再对其处理。
所以,对于select这种方式,需要进行2次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里,而且还会发生2次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select使用固定长度的BitsMap
,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在Linux系统中,由内核中的FD_SETSIZE
限制,默认最大值为1024
,只能监听0~1023的文件描述符。
poll不再用BitsMap来存储所关注的文件描述符,取而代之用动态数组
,以链表形式来组织,突破了select的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是poll和select并没有太大的本质区别,都是使用「线性结构」存储进程关注的Socket集合,因此都需要遍历文件描述符集合来找到可读或可写的Socket,时间复杂度为O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
6.2epoll
先复习下epoll的用法。如下的代码中,先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。
1 | int s = socket(AF_INET, SOCK_STREAM, 0); |
epoll通过两个方面,很好解决了select/poll的问题。
第一点,epoll在内核里使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的socket通过 epoll_ctl()
函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是O(logn)
。而select/poll内核里没有类似epoll红黑树这种保存所有待检测的socket的数据结构,所以select/poll每次操作时都传入整个socket集合给内核,而epoll因为在内核维护了红黑树,可以保存所有待检测的socket,所以只需要传入一个待检测的socket,减少了内核和用户空间大量的数据拷贝和内存分配。
第二点,epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用epoll_wait()
函数时,只会返回有事件发生的文件描述符的个数,不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测的效率。
从下图你可以看到epoll相关的接口作用:
epoll的方式即使监听的Socket数量很多的时候,效率不会大幅度降低,能够同时监听的Socket的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll被称为解决C10K问题的利器。
插个题外话,网上文章不少说,epoll_wait
返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。
这是错的!看过epoll内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到,epoll_wait实现的内核代码中调用了__put_user
函数,这个函数就是将数据从内核拷贝到用户空间。
6.3边缘触发和水平触发
epoll支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)
和水平触发(level-triggered,LT)
。
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
- 使用边缘触发模式时,当被监控的Socket描述符上有可读事件发生时,服务器端只会从epoll_wait中苏醒一次,即使进程没有调用read函数从内核读取数据,也依然只苏醒一次,因此我们程序要
保证一次性将内核缓冲区的数据读取完
; - 使用水平触发模式时,当被监控的Socket上有可读事件发生时,服务器端不断地从epoll_wait中苏醒,直到内核缓冲区数据被read函数读完才结束,目的是告诉我们有数据需要读取;
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞I/O搭配使用,程序会一直执行I/O操作,直到系统调用(如 read
和 write
)返回错误,错误类型为EAGAIN
或EWOULDBLOCK
。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少epoll_wait的系统调用次数
,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
LT模式下epoll_wait返回时就绪链表中的FD并不会立即删除,需要等待FD读取完毕,下次调用epoll_wait时就绪链表还存在相应的FD;ET模式下epoll_wait返回时就绪链表中的FD会被立即删除,下次调用epoll_wait时就绪链表不存在对应的FD了。
select/poll只有水平触发模式,epoll默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
6.4基于epoll的Web服务流程
7.异步IO
同步/异步主要看第二阶段(数据从内核拷贝到用户空间),阻塞/非阻塞主要看第一阶段(等待数据准备就绪),因此上面我们说的阻塞IO、非阻塞IO、IO多路复用和信号驱动IO都是同步IO。