I/O Multiplexing(输入/输出多路复用)

概述

参考:

这是高性能、高并发系列的第三篇,承接上文《读取文件时程序经历了什么》

背景

文件描述符太多了怎么办

经过了这么多的铺垫,终于到高性能、高并发这一主题了。

从前几节我们知道,所有 I/O 操作都可以通过文件样的概念来进行,这当然包括网络通信。

如果你是一个 web 服务器,当三次握手成功以后,我们通过调用 accept 同样会得到一个文件描述符,只不过这个文件描述符是用来进行网络通信的,通过读写该文件描述符你就可以同客户端通信。在这里为了概念上好理解,我们称之为链接描述符,通过这个描述符我们就可以读写客户端的数据了。

int conn_fd = accept(...);

server 的处理逻辑通常是读取客户端请求数据,然后执行某些特定逻辑:

if(read(conn_fd, request_buff) > 0) {
    do_something(request_buff);
}

是不是非常简单,然而世界终归是复杂的,也不是这么简单的。

接下来就是比较复杂的了。

既然我们的主题是高并发,那么 server 端就不可能只和一个客户端通信,而是成千上万个客户端。这时你需要处理不再是一个描述符这么简单,而是有可能要处理成千上万个描述符。

为了不让问题一上来就过于复杂,我们先简单化,假设只同时处理两个客户端的请求。

有的同学可能会说,这还不简单,这样写不就行了:

if(read(socket_fd1, buff) > 0) { // 处理第一个
    do_something();
}
if(read(socket_fd2, buff) > 0) {
    do_something();

在本篇第二节中我们讨论过这是非常典型的阻塞式 I/O,如果读取第一个请求进程被阻塞而暂停运行,那么这时我们就无法处理第二个请求了,即使第二个请求的数据已经就位,这也就意味着所有其它客户端必须等待,而且通常情况下也不会只有两个客户端而是成千上万个,上万个连接也要这样串行处理吗。

聪明的你一定会想到使用多线程,为每个请求开启一个线程,这样一个线程被阻塞不会影响到其它线程了,注意,既然是高并发,那么我们要为成千上万个请求开启成千上万个线程吗,大量创建销毁线程会严重影响系统性能。

那么这个问题该怎么解决呢?

这里的关键点在于在进行 I/O 时,我们并不知道该文件描述对应的 I/O 设备是否是可读的、是否是可写的,在外设的不可读或不可写的状态下进行 I/O 只会导致进程阻塞被暂停运行。

因此要优雅的解决这个问题,就要从其它角度来思考这个问题了。

不要打电话给我,有需要我会打给你

大家生活中肯定会接到过推销电话,而且不止一个,一天下来接上十个八个推销电话你的身体会被掏空的。

这个场景的关键点在于打电话的人并不知道你是不是要买东西,只能来一遍遍问你,因此一种更好的策略是不要让他们打电话给你,记下他们的电话,有需要的话打给他们。

也就是不要打电话给我,有需要我会打给你

在这个例子中,你,就好比内核,推销者就好比应用程序,电话号码就好比文件描述符,和你用电话沟通就好比 I/O。

现在你应该明白了吧,处理多个文件描述符的更好方法其实就存在于推销电话中。

因此相比上一节中我们主动通过 I/O 接口主动问内核这些文件描述符对应的外设是不是已经就绪了,一种更好的方法是,我们把这些内核一股脑扔给内核,并霸气的告诉内核:“我这里有 1 万个文件描述符,你替我监视着它们,有可以读写的文件描述符时你就告诉我,我好处理”。而不是弱弱的问内核:“第一个文件描述可以读写了吗?第二个文件描述符可以读写吗?第三个文件描述符可以读写了吗?”

这样应用程序就从“繁忙”的主动变为清闲的被动了,反正哪些设备 ok 了内核会通知我, 能偷懒我才不要那么勤奋。

这是一种不同的处理 I/O 的机制,同样需要起一个名字,再次祭出“弄不懂原则”,就叫 I/O 多路复用吧,这就是 I/O multiplexing。

I/O 多路复用,I/O multiplexing

multiplexing 一词其实多用于通信领域,为了充分利用通信线路,希望在一个信道中传输多路信号,要想在一个信道中传输多路信号就需要把这多路信号结合为一路,将多路信号组合成一个信号的设备被称为 multiplexer,显然接收方接收到这一路组合后的信号后要恢复原先的多路信号,这个设备被称为 demultiplexer,如图所示:

回到我们的主题。

所谓 I/O 多路复用指的是这样一个过程:

  1. 我们拿到了一堆文件描述符(不管是网络相关的、还是磁盘文件相关等等,任何文件描述符都可以)
  2. 通过调用某个函数告诉内核:“这个函数你先不要返回,你替我监视着这些描述符,当这堆文件描述符中有可以进行 I/O 读写操作的时候你再返回
  3. 当调用的这个函数返回后我们就能知道哪些文件描述符可以进行 I/O 操作了。

那么有哪些函数可以用来进行 I/O 多路复用呢?

在 Linux 世界中有这样三种机制可以用来进行 I/O 多路复用:

  • select
  • poll
  • epoll

接下来我们就简单介绍一下牛掰的 I/O 多路复用 三剑客。本质上 select、poll、epoll 都是阻塞式 I/O,也就是我们常说的同步 I/O。

select:初出茅庐

在 select 这种 I/O 多路复用机制下,我们需要把想监控的文件描述集合通过函数参数的形式告诉 select,然后 select 会将这些文件描述符集合拷贝到内核中,我们知道数据拷贝是有性能损耗的,因此为了减少这种数据拷贝带来的性能损耗,Linux 内核对集合的大小做了限制,并规定用户监控的文件描述集合不能超过 1024 个,同时当 select 返回后我们仅仅能知道有些文件描述符可以读写了,但是我们不知道是哪一个,因此程序员必须再遍历一边找到具体是哪个文件描述符可以读写了。

因此,总结下来 select 有这样几个特点:

  • 我能照看的文件描述符数量有限,不能超过 1024 个
  • 用户给我的文件描述符需要拷贝的内核中
  • 我只能告诉你有文件描述符满足要求了,但是我不知道是哪个,你自己一个一个去找吧(遍历)

因此我们可以看到,select 机制的特性在高性能网络服务器动辄几万几十万并发链接的场景下无疑是低效的。

poll:小有所成

poll 和 select 是非常相似的,poll 相对于 select 的优化仅仅在于解决了文件描述符不能超过 1024 个的限制,select 和 poll 都会随着监控的文件描述增加而出现性能下降,因此不适合高并发场景。

epoll:独步天下

在 select 面临的三个问题中,文件描述数量限制已经在 poll 中解决了,剩下的两个问题呢?

针对第一个 epoll 使用的策略是各个击破共享内存

实际上文件描述符集合变化的频率比较低,select 和 poll 频繁的拷贝整个集合,内核都快要烦死了,epoll 通过引入 epoll_ctl 很体贴的做到了只操作那些有变化的文件描述符,同时 epoll 和内核还成为了好朋友,共享了同一块内存,这块内存中保存的就是那些已经可读或者可写的的文件描述符集合,这样就减少了内核和程序的内存拷贝开销。

针对第二点,epoll 使用的策略是“当小弟”。

在 select 和 poll 机制下,进程要亲自下场去各个文件描述符上等待,任何一个文件描述可读或者可写就唤醒进程,但是进程被唤醒后也是一脸懵逼并不知道到底是哪个文件描述符可读或可写,还要再从头到尾检查一遍。

但 epoll 就懂事多了,主动找到进程要当小弟替大哥出头。

在这种机制下,进程不需要亲自下场了,进程只要等待在 epoll 上,epoll 代替进程去各个文件描述符上等待,当哪个文件描述符可读或者可写的时候就告诉 epoll,epoll 用小本本认真记录下来然后唤醒大哥:“进程大哥,快醒醒,你要处理的文件描述符我都记下来了”,这样进程被唤醒后就无需自己从头到尾检查一遍,因为 epoll 都已经记下来了。

因此我们可以看到,在这种机制下,实际上利用的就是“不要打电话给我,有需要我会打给你”,这就不需要一遍一遍像孙子一样问各个文件描述符了,而是翻身做主人当大爷了,“你们那个文件描述符可读或者可写了主动报上来”,这中机制实际上就是大名鼎鼎的事件驱动,event-driven,这也是我们下一篇的主题。

实际上在 Linux 平台,epoll 基本上就是高并发的代名词

限于篇幅,关于 epoll 的详细使用方法就不在这里讲解了。

总结

基于一切皆文件的设计哲学,I/O 也可以通过文件的形式实现,显然高并发要与多个文件交互,这就离不开高效的 I/O 多路复用技术,本文我们详细讲解了什么是 I/O 多路复用以及使用方法,这其中以 epoll 为代表的 I/O 多路复用(基于事件驱动)技术使用非常广泛,实际上你会发现但凡涉及到高并发、高性能都能见到事件驱动的编程方法,这也是下一篇的主题。


最后修改 September 26, 2024: database, i/o (432e6e5d)