有关阻塞 IO 和非阻塞 IO 的内容,可以在之前的博客中找到。
若用阻塞 IO 方式,等待 IO 就绪时可能会阻塞很多线程,这些线程创建的、上下文切换的开销依然存在。如果我们用非阻塞 IO,单线程不断轮询去检查文件描述符的状态,会浪费 CPU 资源。
- 多路:多个文件操作符。
- 复用:复用一个线程,让其检查多个文件描述符的就绪状态。
select
select 实现多路复用的方式简单粗暴:
- 用户态线程需要将需要监听的文件操作符放到一个集合,然后调用 select 函数将文件描述符集合拷贝到内核里。
- 调用 select 的进程会加入每个文件操作符的等待队列中,睡眠等待。
- 当任何一个文件描述符就绪后,会向 CPU 发起中断,中断程序唤醒进程。
- 进程唤醒后,select 通过线性遍历文件操作符集合的方式,设置好每一个文件操作符的状态,将其标记为可读或可写。
- 内核接着将整个文件描述符集合拷贝回用户态里。
- 用户态线程仍需要再次线性遍历找到可读可写的文件操作符,再对其处理。
对于 select,需要线性遍历 2
次文件描述符集合,拷贝 2
次文件描述符集合。而且 select 使用固定长度的 bitmap
来表示文件描述符集合。在 Linux 中因为内核 FD_SETSIZE
限制(默认 1024),默认只能监听 0 ~ 1023 的文件描述符。
poll
poll 不再使用 bitmap
存储关注的文件描述符,而是以链表来阻止。虽然突破了 select
文件描述符个数的限制,但还会受到系统文件描述符的限制(参考
ulimit -n
)。
但是 select 和 poll 并没有本质区别,都是使用线性数据结构存储关注的文件描述符集合,时间复杂度都是 \(O(n)\)。随着并发度增长,性能的损耗也会随线性增大。
epoll
epoll 通过两个个改进,很好解决了 select/poll 的部分问题:
- epoll 在内核里使用红黑树管理所有待检测的文件描述符,插入、查找、修改的时间复杂度都是 \(O(log n)\),它就不用像 select/poll 那样维护一个完整的线性集合。
- epoll
使用事件驱动的机制,内核里维护了一个就绪事件链表。当某个文件描述符有事件发生时,内核通过回调函数将该文件描述符加入到就绪事件表中。当用户调用
epoll_wait()
函数时,只会返回有事件发生的文件描述符个数,不用再像 select/poll 那样线性遍历整个文件描述符集合。
注意,内存拷贝消耗依然无法避免,但是拷贝内存的大小减少了,从拷贝整个集合降至只发生变更的文件描述符。
水平触发(level-triggered,LT)
使用水平触发模式时,当被监控的文件操作符上有事件发生时,服务器端不断地从
epoll_wait()
中苏醒,直到内核缓冲区数据被
read()
函数读完才结束。
select/poll 只有水平触发模式,epoll 默认是水平触发。
边缘触发(edge-triggered,ET)
使用边缘触发模式时,当被监控的文件描述符事件发生时,进程也只会从
epoll_wait()
中苏醒一次。即使进程没有调用
read()
函数从内核读取数据,也依然只苏醒一次。因此程序要保证一次性将内核缓冲区的数据完全读取。
使用边缘触发模式只能搭配非阻塞 IO 使用。epoll 可以根据应用场景设置为边缘触发模式。
形象化例子
下面举一个例子,模拟一个 TCP 服务器处理 30 个客户端 socket:假设你是一个老师,让 30 个学生解答一道题目,然后检查学生做的是否正确,你有下面几个选择:
- 第一种选择:你按顺序逐个检查,先检查 A,然后是 B,之后是 C、D …… 这中间如果有 1 个学生卡着,全班都会被耽误。
这种模式就好比,得用线性遍历每个 socket 挨个处理 socket,根本不具有并发能力。
第二种选择:你创建 30 个分身,每个分身检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
第三种选择:你站在讲台上等,谁解答完谁举手。这时 C、D 举手,表示他们解答问题完毕,你下去依次检查 C、D 的答案,然后继续回到讲台上等。此时 E、A 又举手,然后去处理 E 和 A ……
这种就是 IO 复用模型,Linux 下的 select、poll 和 epoll 就是干这个的。将用户 socket 对应的 fd 注册进 epoll,然后 epoll 帮你监听哪些 socket 上有消息到达。这样,整个过程只在调用 select、poll、epoll 这些调用的时候才会阻塞,收发客户消息不会阻塞,整个进程或者线程就被充分利用起来,这就是事件驱动,也就是所谓的 Reactor 模式。
select/poll 只会告诉你有人举手,不会告诉你是哪个同学举的手。
常见疑问
为什么 IO 多路复用要搭配非阻塞 IO?
问题:假如我调用了一个 select
函数,并且关注了几个描述字,select
函数就会一直阻塞直到我关注的事件发生。假如当有 socket 可读时, select
函数就解除阻塞返回,告诉我套接口已经可读,然后我去读这个套接口。我可以用阻塞的
read
或者非阻塞的 read
。阻塞 read
是无数据可读就阻塞进程,非阻塞 read
是无数据可读就返回一个
EWOULDBLOCK
错误。那么:
- 既然 select 都返回可读了,那就表示一定能读了,阻塞函数
read
也就能读取了也就不会阻塞了, - 非阻塞
read
的话,也有数据读了,也不会返回错误了。
这俩不都一样了吗?一样直接读取数据直到读完,为什么还得用非阻塞函数?还有 Reactor 模式也是用的 IO 多路复用与非阻塞 IO,这是什么道理呢?
答案:假如 socket
的读缓冲区中已有足够多的数据,需要调用三次 read
才能读取完。或 ACCEPT 队列已经有三个「握手已完成的连接」。
- 非阻塞 I/O 的处理方式:循环的
read
或accept
,直到读完所有的数据(抛出EWOULDBLOCK
异常)。 - 阻塞 I/O 的处理方式:每次只能调用一次
read
或accept
,因为多路复用只会告诉你 fd 对应的 socket 可读了,但不会告诉你有多少的数据可读,所以在handle_read/handle_accept
中只能read/accept
一次,你无法知道下一次read/accept
会不会发生阻塞。所以只能等 ioloop 的第二次循环,ioloop 告诉你 fd 可用后再继续调用handle_read/handle_accept
处理,然后再循环第三次。
所以你会发现,后者的处理方式要复杂很多,稍不注意就会阻塞整个进程。