本文主要是想解答一下这样几个问题:
- 什么是非阻塞 I/O
- 非阻塞 I/O 和异步 I/O 的区别
- epoll 的工作原理
文件描述符
文件描述符在本文有多次出现, 难免有的朋友不太熟悉, 有必要简单说明一下.
文件描述符是一个非负整数, 用于标识一个打开的文件.
这里 "文件" 一词是更宽泛的概念, 可以是进程中使用的任何类型的 I\O 资源, 例如常规文件, 管道, Socket 等.
通常, 打开 I\O 流的系统调用都会返回一个 int 类型的文件描述符.
例如下面分别是打开一个文件和创建一个 Socket 的系统调用.
- int open(const char *pathname, int flags);
- int socket(int domain, int type, int protocol);
文件描述符 File descriptor, 在程序中一般简写为 fd.
阻塞模式(Blocking I/O)
解释非阻塞 I/O 之前先, 先聊下阻塞 I/O .
默认的, Unix 系统的所有文件描述符都是阻塞模式. 这意味着有关 I/O 的操作 (如 read,write,open) 都是阻塞的.
以从 Linux 终端窗口读取数据为例, 如果你在程序中调用了 read(), 程序会一直阻塞, 直到你确实敲键盘输入了字符.
这里的程序阻塞, 更准确地讲, 是系统内核把该进程设置成了睡眠状态, 直到有数据输入才被唤醒.
TCP Socket 通信也是一样的道理, 如果你尝试着从 socket 中读取数据, read()会被阻塞, 直到 socket 的另一端发送了数据.
可见, 在阻塞模式下程序是不能并发操作的, 因为进程 \ 线程都被睡眠了.
对于并发 I/O, 我们将讨论三个解决办法:
非阻塞模式
I/O 多路复用系统调用, select 和 epoll
多线程 / 多进程
下面详细聊聊这三个方法.
非阻塞模式(Nonblocking I/O)
在打开一个文件时, 设置 flag 参数为 O_NONBLOCK, 便告诉了操作系统对这个文件的操作应该是非阻塞的, 或者说该文件描述符处于非阻塞模式.
这意味着两点:
如果这个文件不能立即打开, open()系统调用会返回一个错误码, 而不是阻塞 open().
文件打开成功后, 后续的 I/O 操作应该也是非阻塞的, 例如如果不能立即完成 read 或 write, 返回错误码.
不能立即完成读写的原因可能是没有数据可读, 或者缓存已满没有更多空间可以写.
(注意, 非阻塞模式对常规文件无效, 因为系统内核总能保证有足够的缓存让常规文件 I/O 不阻塞)
非阻塞模式仅仅是 Unix 系统中一个原始特性, 只有它还不能让程序并发执行. 还需要我们在应用程序中有一个死循环, 不停的检测文件描述符的状态.
下面以并发访问两个文件描述符为例写一段伪代码.
先解释一下 read()系统调用的几个参数:
int read(int fd, void *buffer, size_t count);
fd 即读取的文件描述符
buffer 用于接收本次读取的数据
count 本次读取的最大字节数
返回值 实际读取的字节数, 发生错误返回 - 1
- ssize_t nbytes;
- for (;;) {
- // 文件描述符 1
- if ((nbytes = read(fd1, buf, sizeof(buf))) < 0) {
- if (errno != EWOULDBLOCK) {
- // 发生错误, 且错误码为 EWOULDBLOCK
- // 说明文件描述符 fd1 没有准备好 read
- }
- } else {
- handle_data(buf); // 处理数据
- }
- // 文件描述符 2
- if ((nbytes = read(fd2, buf, sizeof(buf))) < 0) {
- if (errno != EWOULDBLOCK) {
- //fd2 不能 read
- }
- } else {
- handle_data(buf);
- }
- nanosleep(sleep_interval, NULL); // 再次检测前睡眠片刻
- }
以上实现了对两个文件并发 I\O, 但有很明显的缺点:
循环的频次太低, 会导致 I/O 的响应延迟.
循环的频次太高, 当需要并发处理的文件很多时, 每次循环都要检测所有文件, 性能会变得很差 (read() 是系统调用).
多进程 / 线程方式
并发处理多个 I/O 流还有一种更原始的方法, 使用多进程或多线程, 每个 I/O 流独占一个进程或线程, 很容易理解.
也有很多缺点:
在任何时刻, 都可能有大量的线程处于空闲状态, 造成资源浪费.
线程是占内存的, 不可能创建太多的工作线程.
线程 / 进程的上下文切换也会带来很大的性能开销.
在互联网早期流量比较小, 很多服务采用的这种方式, 但是它只适用于低并发的服务器.
上面讨论了非阻塞模式和多进程两个方式, 在高并发场景都不太好用.
这时, 我们就需要 Unix 的 I/O 多路复用了.
I/O 多路复用(I/O Multiplexing)
类 Unix 系统有多个实现了 I/O 多路复用的系统调用, Unix 系统的 select 和 poll,Linux 的 epoll, 以及 BSD 的 kqueue.
他们的底层工作原理相似: 首先告诉系统内核你想监控哪些文件描述符的哪些事件(典型的 read 和 write 事件), 然后用户程序被阻塞, 直到你感兴趣的事件发生.
例如你可能告诉系统内核,"当文件描述符 X 可以 read 时, 通知我."
Unix 的 I/O 多路复用机制不关心文件描述符是否处于非阻塞模式, 你可以把所有文件描述符设置为阻塞模式, epoll 和 select 不会受影响.
这一点很重要! 非阻塞模式和 I/O 多路复用, 这两个方法都可以实现并发 I/O, 但是本质上他们是相互独立的两个解决问题思路, 互不依赖.
多路复用实现的并发 I/O, 有时被称为异步 I/O(asynchronous I/O). 但是有人也把这种方式称为非阻塞 I/O, 这是错误的, 应该是对非阻塞模式有什么误解.
epoll 的工作原理
epoll 是 event poll 的简写, 是 Linux 内核提供的一种由事件驱动的 I/O 通知机制.(注意 epoll 是 Linux 特有的, 其他类 Unix 系统可能没有实现.)
另外, epoll 本身并不是一个系统调用, 而是一组系统调用的统称.
这组系统调用包括:
epoll_create()系统调用
epoll_ctl()系统调用
epoll_wait()系统调用
epoll_create()
方法签名: int epoll_create(int size)
该系统调用用于创建一个 epoll 实例, 该实例存在于系统内核空间.
epoll 实例会初始化一个列表, 用于存储用户程序感兴趣的文件描述符, 下文简称 interest list, 参数 size 即为这个列表的初始大小(可以动态扩展). 返回值是 epoll 实例的文件描述符.
epoll_ctl()
使用 epoll_ctl()可以对 interest list 增删改(ctl 应该是 control 的缩写)
方法签名: int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev)
参数说明:
epfd: 即 epoll_create()创建的 epoll 实例的文件描述符
op: 枚举值, 指定操作类型, 例如 EPOLL_CTL_ADD 添加一个文件描述符
ev: 结构体类型, 是对该文件描述符的设置.
ev 参数最重要的是 ev.events 字段可以添加感兴趣的事件, 例如添加了 read 事件, 表示该文件可以 read 的时候, 通知应用程序.
epoll_wait()
用户程序调用该方法获取 ready 的文件描述符.
继续之前先解释一下文件描述符什么时候 ready?
即使在文件描述符处于阻塞模式 (没有设置 O_NONBLOCK) 的情况下, 对该文件描述符的 read\write 等操作扔不会阻塞, 就说该文件描述符 ready.
方法签名:
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
参数说明:
epfd: epoll 实例的文件描述符
evlist: 用于接收 ready 的文件描述符, 该数组由用户程序分配.
maxevents: 一次调用返回的最大事件数量
timeout: epoll_wait 系统调用的超时时间, 0 表示不阻塞, 无事件发生立即返回.-1 一直阻塞, 直到有事件发生. 大于 0 表示超时返回.
为了对 epoll 的并发 I/O 编程有个感性的认识, 我们来写一段伪代码
- epfd = epoll_create(EPOLL_QUEUE_LEN); // 创建 epoll 实例
- static struct epoll_event ev;
- int client_sock;
- ev.events = EPOLLIN | EPOLLPRI | EPOLLERR | EPOLLHUP; // 声明感兴趣的事件
- ev.data.fd = client_sock; // 文件描述符指向一个 Socket 连接
- // 添加要监控的文件描述符和事件类型到 interest list
- // 真实环境中, 可能需要添加成百上千个这种事件.
- int res = epoll_ctl(epfd, EPOLL_CTL_ADD, client_sock, &ev);
- while (1) {
- int nfds = epoll_wait(epfd, events, MAX_EVENTS_PER_RUN, TIMEOUT);// 获取 ready 的事件, 可能有多个
- if (nfds < 0) die("Error in epoll_wait!"); // 发生错误, 退出程序
- for(int i = 0; i < nfds; i++) { // 遍历处理每一个已 ready 的 socket
- int fd = events[i].data.fd;
- handle_io_on_socket(fd);
- }
- }
从伪代码中可以看到, 基于 epoll 的 I/O 多路复用的时间复杂度只有 O(n), 其中 n 是 ready 的事件数量.
时间复杂度不会随着 interest list 的增加而线性增长, 这使得 epoll 有很好的扩展性.
试想一个高并发服务器同一时刻可能需要监控成千上万甚至更多的 socket 连接,
如果使用文章开始介绍的非阻塞模式, 每一次循环都要对全部的 socket 进行测试, 这是很恐怖的.
但是 epoll 的压力只与 socket 通信的繁忙程度有关.
另外提一下, Unix 中的 poll()和 select()有着更差的时间复杂度和空间复杂度, 篇幅受限这里不展开说了.
最后, 结合图片了解一下 epoll 的内部结构
(图片出处见文章最后)
1. 进程 483 通过 epoll_create()创建一个 epoll 实例, 该实例存在于内核空间.
2. 进程 483 通过 epoll_ctl()系统调用, 添加五个感兴趣的文件描述符到 interest list.
3. 当有文件描述符 ready 时, 系统内核会把该文件描述符添加到 ready list,ready list 是 interest list 的子集.
事件发生后添加到 ready list, 这个过程是内核完成的.
4. 用户程序调用 epoll_wait()时, 内核会把 ready list 返回给用户程序.
写在最后
博主的主力语言是 Java, 对 C 一知半解, 如文章有理解错误的地方, 感谢指正.
我在学习 Java NIO 的原理时, 遇到了很多 Jdk 源码解决不了的困惑. 于是我决定从 Linux 编程接口着手, 想了解一下 Linux 系统怎么做到的并发 I/O.
途中翻阅了很多资料, 其中对我帮助最大的是《The Linux Programming Interface》, 作者是 Linux man-pages 的维护者, 具有一定的权威性.
这本书并没有像书名一样停留在 Linux API 层面, 而是穿插着讲解了很多原理性的知识, 有几个知识点讲的可谓醍醐灌顶.
其他资料就比较杂乱了, 像维基百科, man 手册, 个人博客都有.
这篇文章, 更多得是对这几天学习的知识梳理和总结, 没什么原创内容.
希望对你有所帮助吧.
参考内容
《The Linux Programming Interface》英文版 4.3 章节, 5.9 章节, 56.2 章节, 63.1 章节, 63.2.3 章节 (核心参考)
man7 关于 O_NONBLOCK 的权威说明: http://man7.org/linux/man-pages/man2/open.2.html (
里面有提到, 设置 O_NONBLOCK 与否对 select 和 epoll 没有影响.
)
很清晰的解释了非阻塞模式和多路复用的区别和联系: https://eklitzke.org/blocking-io-nonblocking-io-and-epoll
介绍了 epoll 的工作原理和内部数据结构(本文中的图片来源):https://medium.com/@copyconstruct/the-method-to-epolls-madness-d9d2d6378642
对《The Linux Programming Interface》,epoll 章节的读书笔记: https://jvns.ca/blog/2017/06/03/async-io-on-linux--select--poll--and-epoll/
一个 epoll 编程的小 demo : https://kovyrin.net/2006/04/13/epoll-asynchronous-network-programming/
关于 epoll 和 poll 性能的讨论: https://www.win.tue.nl/~aeb/linux/lk/lk-12.html
来源: https://www.cnblogs.com/upnote/p/12017212.html