从阻塞 I/O 到 I/O 多路复用
-
阻塞IO:
阻塞 I/O,是指进程发起调用后,会被挂起(阻塞),直到收到数据再返回。如果调用一直不返回,进程就会一直被挂起。因此,当使用阻塞 I/O 时,需要使用多线程来处理多个文件描述符。多线程切换有一定的开销,因此引入非阻塞 I/O。 -
非阻塞IO:
非阻塞 I/O 不会将进程挂起,调用时会立即返回成功或错误,因此可以在一个线程里轮询多个文件描述符是否就绪。
但是非阻塞 I/O 的缺点是:每次发起系统调用,只能检查一个文件描述符是否就绪。当文件描述符很多时,系统调用的成本很高。
因此我们引入了IO多路复用,可以通过一次系统调用(一个线程),检查多个文件描述符的状态。一旦某个文件描述符准备就绪,就能通知到对应应用程序进行相应读写操作。
这是 I/O 多路复用的主要优点,相比于非阻塞 I/O,在文件描述符较多的场景下,避免了频繁的用户态和内核态的切换,减少了系统调用的开销。
I/O 多路复用相当于将「遍历所有文件描述符、通过非阻塞 I/O 查看其是否就绪」的过程从用户线程移到了内核中,由内核来负责轮询。
文件描述符
上面我们提到了文件描述符,这里就解释下 什么是文件描述符?
这里引用 linux进程.md
先说 files,它是一个文件指针数组。一般来说,一个进程会从 files[0] 读取输入,将输出写入 files[1],将错误信息写入 files[2]。
举个例子,以我们的角度 C 语言的 printf 函数是向命令行打印字符,但是从进程的角度来看,就是向 files[1] 写入数据;同理,scanf 函数就是进程试图从 files[0] 这个文件中读取数据。
每个进程被创建时,files 的前三位被填入默认值,分别指向标准输入流、标准输出流、标准错误流。我们常说的「文件描述符」就是指这个文件指针数组的索引,所以程序的文件描述符默认情况下 0 是输入,1 是输出,2 是错误。
对于一般的计算机,输入流是键盘,输出流是显示器,错误流也是显示器,所以现在这个进程和内核连了三根线。因为硬件都是由内核管理的,我们的进程需要通过「系统调用」让内核进程访问硬件资源。
note:不要忘了,Linux 中一切都被抽象成文件,设备也是文件,可以进行读和写。
如果我们写的程序需要其他资源,比如打开一个文件进行读写,这也很简单,进行系统调用,让内核把文件打开,这个文件就会被放到 files 的第 4 个位置:
明白了这个原理,输入重定向就很好理解了,程序想读取数据的时候就会去 files[0] 读取,所以我们只要把 files[0] 指向一个文件,那么程序就会从这个文件中读取数据,而不是从键盘:
$ command < file.txt
同理,输出重定向就是把 files[1] 指向一个文件,那么程序的输出就不会写入到显示器,而是写入到这个文件中:
$ command > file.txt
错误重定向也是一样的,就不再赘述。
管道符其实也是异曲同工,把一个进程的输出流和另一个进程的输入流接起一条「管道」,数据就在其中传递,不得不说这种设计思想真的很优美:
$ cmd1 | cmd2 | cmd3
到这里,你可能也看出「Linux 中一切皆文件」设计思路的高明了,不管是设备、另一个进程、socket 套接字还是真正的文件,全部都可以读写,统一装进一个简单的 files 数组,进程通过简单的文件描述符访问相应资源,具体细节交于操作系统,有效解耦,优美高效。
socket 可以用于同一台主机的不同进程间的通信,也可以用于不同主机间的通信。操作系统将 socket 映射到进程的一个文件描述符上,进程就可以通过读写这个文件描述符来和远程主机通信。
socket 是进程间通信规则的高层抽象,而 fd 提供的是底层的具体实现。socket 与 fd 是一一对应的。后面可以将 socket 和 fd 视为同义词。
IO多路复用的三种模型
进程可以通过 select、poll、epoll发起IO多路复用的系统调用,这些系统调用都是同步阻塞的:如果传入的多个文件描述符中,有描述符就绪,则返回就绪的描述符;否则如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞时长超过设置的 timeout 后,再返回。I/O 多路复用内部使用非阻塞 I/O 检查每个描述符的就绪状态。
I/O 多路复用引入了一些额外的操作和开销,性能更差。但是好处是用户可以在一个线程内同时处理多个 I/O 请求。如果不采用 I/O 多路复用,则必须通过多线程的方式,每个线程处理一个 I/O 请求。后者线程切换也是有一定的开销的。
Select
int select(int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout);
readfds
、writefds
、errorfds
是三个文件描述符集合。select 会遍历每个集合的前 nfds 个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为“就绪”的描述符。然后用找到的子集替换参数中的对应集合,返回所有就绪描述符的总数。
timeout 参数表示调用 select 时的阻塞时长。如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。
找到就绪的文件描述符,触发相应的IO操作。
优点:跨平台性好,几乎在所有平台都支持
缺点:
- 性能开销大:select系统调用,会将 fd_set 从用户空间拷贝到内核空间。并且内核需要轮询遍历 fd_set的每一位。因此随着 FD 数量的增多会导致性能下降
- 同时能够监听的文件描述符少。一般是1024个,不同的操作系统不相同。
Poll
poll与select的区别只在于,poll在用户态时是通过数组方式传递文件描述符,在内核态时会转为链表存储,所以没有最大数据的限制。
EPoll
select 和 poll 都会因为吞吐量增加而导致性能下降,因此出现了 epoll 模型。
epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。
简而言之,epoll 有以下几个特点:
- 使用红黑树存储文件描述符集合
- 使用队列存储就绪的文件描述符
- 每个文件描述符只需在添加时传入一次;通过事件更改文件描述符状态
它能支持的文件描述符FD上限是操作系统的最大文件描述符数,一般而言1G内存大概支持10万个文件描述符。所以分布式系统中如Redis、Nginx都是优先使用 epoll模型。
select、poll 模型都只使用一个函数,而 epoll 模型使用三个函数:epoll_create、epoll_ctl 和 epoll_wait。
1. epoll_create
epoll_create 会创建一个 epoll 实例,同时返回一个引用该实例的文件描述符。
返回的文件描述符仅仅指向对应的 epoll 实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctl、epoll_wait 会使用这个文件描述符来操作相应的 epoll 实例。
当创建好 epoll 句柄后,它会占用一个 fd 值,在 linux 下查看 /proc/进程id/fd/,就能够看到这个 fd。所以在使用完 epoll 后,必须调用 close(epfd) 关闭对应的文件描述符,否则可能导致 fd 被耗尽。当指向同一个 epoll 实例的所有文件描述符都被关闭后,操作系统会销毁这个 epoll 实例。
epoll 实例内部存储:
- 监听列表:所有要监听的文件描述符,使用红黑树
- 就绪列表:所有就绪的文件描述符,使用链表
2. epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl 会监听文件描述符 fd 上发生的 event 事件。每新建一个连接的时候,会同步更新 epoll 对象中的 FD,并绑定一个 callback 回调函数。
参数说明:
- epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
- fd 表示要监听的目标文件描述符
- event 表示要监听的事件(可读、可写、发送错误…)
- op 表示要对 fd 执行的操作,有以下几种:
- EPOLL_CTL_ADD:为 fd 添加一个监听事件 event
- EPOLL_CTL_MOD:Change the event event associated with the target file descriptor fd(event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值)
- EPOLL_CTL_DEL:删除 fd 的所有监听事件,这种情况下 event 参数没用
返回值 0 或 -1,表示上述操作成功与否。
epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。
3. epoll_wait
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
这是 epoll 模型的主要函数,功能相当于 select。轮询所有的callback集合,触发对应的IO操作。
参数说明:
- epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
- events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
- maxevents 指定 events 的大小
- timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回
返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。
select | poll | epoll | |
---|---|---|---|
数据结构 | 数组 | 链表 | B+树、红黑树 |
最大连接数 | 1024 | 无上限 | 无上限 |
FD拷贝 | 每次调用select | 每次调用poll | FD首次调用epoll_ctl拷贝,每次调用 epoll_wait不用拷贝 |
效率 | 轮询:O(n) | 轮询:O(n) | 回调:O(1) |