前两天写了一篇 C++协程 + io_uring 的 文章 ,里面介绍了如何在 C++ 中结合使用协程和 io_uring 来实现异步 I/O 操作。写完自己在审阅时,回想起来自己刚接触 linux 网络编程时走过的一些弯路,一些错误的理解。所有就想着写一篇 I/O 的文章总结一下。 所以今天我们就来聊聊常见的IO类型,同步 I/O ,异步 I/O ,阻塞 I/O ,非阻塞 I/O 还有 IO 多路复用。
一开始我错误的认为,非阻塞 I/O 就是异步 I/O ,阻塞 I/O 就是同步 I/O 。后来才发现,原来并不是这样的。IO 的分类有两个维度,一个是按调用方式分为:同步 和 异步;另一个是按等待方式分为:阻塞 和 非阻塞。
简单说 阻塞/非阻塞 是指 函数调用时的返回行为 ,而 同步/异步 是指 I/O的完成通知 。
而 I/O多路复用 则是一种特殊的技术,是提升效率的一种机制,它允许单个线程同时管理多个 I/O 操作。通过使用 select
、poll
或 epoll
等系统调用,应用程序可以在多个文件描述符上等待事件的发生,从而实现高效的 I/O 处理。I/O多路复用通常与非阻塞 I/O 结合使用,以提高性能和响应能力。
模型 | 应用行为 | 等待位置 | 优缺点 |
---|---|---|---|
同步 I/O | 等待完成 | 应用自己阻塞 | 简单,但效率低 |
异步 I/O | 发起请求立刻返回,完成后通知 | 内核异步完成 | 最理想,但实现复杂 |
阻塞 I/O | 调用阻塞直到数据就绪 | 应用阻塞 | 编程简单,但浪费等待时间 |
非阻塞 I/O | 数据没好立即返回,需要轮询 | 应用层轮询 | 避免阻塞,但效率差 |
I/O 多路复用 | 统一等待多个 I/O 就绪 | 内核等待,应用一次醒来处理 | 高效,常用于高并发服务器 |
下面用 C 为每种 I/O 类型写一个简单的例子,来帮助理解。
在linux中,一切都是文件,包括网络连接和设备。通过文件描述符,应用程序可以以统一的方式进行I/O操作,所以有些例子中,使用 open
、read
和 close
等系统调用来进行文件的读取操作。对于网络 I/O,应用程序可以使用相同的接口来进行数据的发送和接收。
阻塞I/O
1#include <stdio.h>
2#include <stdlib.h>
3#include <unistd.h>
4#include <fcntl.h>
5
6int main() {
7 char buffer[1024];
8 int fd = open("file.txt", O_RDONLY);
9 if (fd == -1) {
10 perror("open");
11 return 1;
12 }
13 ssize_t bytesRead = read(fd, buffer, sizeof(buffer));
14 if (bytesRead == -1) {
15 perror("read");
16 close(fd);
17 return 1;
18 }
19 printf("Read %zd bytes: %.*s\n", bytesRead, (int)bytesRead, buffer);
20 close(fd);
21 return 0;
22}
read
函数 操作默认的 文件描述符 (fd) 是阻塞的,也就是说,如果没有数据可读,它会一直等待,直到有数据可读为止。这种方式在某些情况下是合适的,但在高并发的网络应用中,可能会导致性能瓶颈。
优势就是这种阻塞方式编程简单,容易理解。
非阻塞I/O
1#include <stdio.h>
2#include <unistd.h>
3#include <fcntl.h>
4#include <errno.h>
5#include <string.h>
6
7int main() {
8 char buf[100];
9 int flags = fcntl(STDIN_FILENO, F_GETFL, 0);
10 fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); // 设置非阻塞
11
12 printf("非阻塞输入(没有输入时立即返回):\n");
13 while (1) {
14 ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)-1);
15 if (n > 0) {
16 buf[n] = '\0';
17 printf("你输入了:%s\n", buf);
18 break;
19 } else if (n < 0 && errno == EAGAIN) {
20 printf("暂时没有输入,干点别的事...\n");
21 sleep(1);
22 } else {
23 break;
24 }
25 }
26 return 0;
27}
这次使用 标准输入(STDIN_FILENO
)进行非阻塞读取。
使用 fcntl
函数设置文件描述符的标志位为非阻塞。
后续使用 read
函数操作 这个文件描述符时,就会变成 非阻塞I/O 了。 在没有数据可读时会立即返回,而不是阻塞等待。使用非阻塞I/O 编程时,需要判断 errno
的值来判断当前 fd
的状态。
常见的错误码有:
EAGAIN
:表示当前没有数据可读,非阻塞I/O模式下会立即返回。EINTR
:表示系统调用被信号中断,可能需要重试。EINVAL
:表示无效的文件描述符或参数。ENETDOWN
:表示网络关闭。EIO
:表示 I/O 错误。ETIMEDOUT
:表示操作超时。
在网络编程中,可以使用 accept4
函数来创建非阻塞的 socket。
函数定义
1int accept4(int sockfd, struct sockaddr *addr,socklen_t *addrlen, int flags);
- sockfd:监听socket fd(必须是 listen 状态)。
- addr:返回对端地址(客户端 IP + 端口)。如果不关心,可以传 NULL。
- addrlen:输入输出参数,传入时为 addr 的大小,返回时表示实际长度。
- flags:额外选项,可以是以下的 按位或:
SOCK_NONBLOCK
设置新 socket 为非阻塞模式。SOCK_CLOEXEC
设置 FD_CLOEXEC(执行 exec 时自动关闭 fd)。
调用方式
1struct sockaddr_in cliaddr;
2socklen_t clilen = sizeof(cliaddr);
3int fd = accept4(listenfd,(struct sockaddr*)&cliaddr,&clilen, SOCK_NONBLOCK | SOCK_CLOEXEC);
使用非阻塞I/O 可以避免应用程序在等待 I/O 操作完成时被阻塞,从而提高整体的响应能力和并发处理能力。
一般来说,非阻塞I/O 适用于对响应时间要求较高的场景,比如网络服务、实时数据处理等。而阻塞I/O 则更适合对性能要求不高的场景,比如简单的文件读取等。
非阻塞I/O 需要搭配 多路复用技术一起使用,才能发挥出更好的性能。通过使用 select
、poll
或 epoll
等系统调用,应用程序可以在多个文件描述符上等待事件的发生,从而实现高效的 I/O 处理,关于多路复用,后面会介绍
同步 I/O
在 POSIX 语义里,阻塞 I/O 本质就是同步 I/O。还有上面提到的非阻塞 I/O,虽然它的返回行为是非阻塞的,但在数据准备好之前,应用程序仍然需要主动去查询状态,这种行为在某种程度上也可以视为一种同步。
所以就不再重复这些内容了。
异步 I/O
所谓的 异步 I/O ,是指应用程序发起 I/O 请求后,不需要等待操作完成,而是可以继续执行其他任务。当 I/O 操作完成后,内核会通过某种机制(如信号、回调函数或事件通知)来通知应用程序。
在 Linux 5.1 版本中,引入了新的异步 I/O 接口(io_uring
),它提供了一种更高效的方式来进行异步 I/O 操作。通过 io_uring
,应用程序可以将 I/O 请求提交到内核,并在请求完成时获得通知,从而实现真正的异步 I/O。
举一个简单的例子,我去麦当劳点餐。
同步 I/O: 我走到柜台前,告诉服务员我要点什么,然后站在那里等着,直到小姐姐把我的餐给我为止。在这个过程中我只能在柜台前等待,我不能做其他事情,只能等待。
异步 I/O: 我走到柜台前,告诉服务员我要点什么,然后就去找地方坐着玩手机了,甚至可以去上个厕所。当餐点准备好后,小姐姐会通过某种方式通知我取餐,比如喊一声XXX号餐好了。
回到程序中,同步 I/O 应用程序发起 I/O 请求后,必须等待内核完成操作才能继续执行后续代码。而异步 I/O 则允许应用程序在发起请求后立即返回,继续执行其他任务,内核会在操作完成后通过回调或信号的方式通知应用程序。
异步 I/O 的代码比较多,就不在这里展示了,可以去 io_uring 和 C++协程+io_uring 查看相关内容。
多路复用
乍一听这个名字还挺高大上的,其实它的核心思想就是让一个线程同时管理多个 I/O 操作,从而提高效率。最早的网络编程中,通常是为每个连接创建一个线程,这样虽然简单,但在高并发场景下会导致线程数量激增,系统资源耗尽。所有有了 C10K 连接的问题。
很多程序就是使用这种每个线程处理一个连接的方式,像是 Apache HTTP Server,MySQL 社区版(听说付费版使用了多路复用)等。
为了解决这个问题,出现了 I/O 多路复用技术。它允许一个线程同时监视多个 I/O 流,并在其中任何一个流准备好时进行处理。常见的 I/O 多路复用机制有 select
、poll
、 epoll
和 kqueue
。
select
select
是 最常见的一种 多路复用技术,几乎所有的操作系统都支持。
1#include <stdio.h>
2#include <unistd.h>
3#include <sys/select.h>
4#include <string.h>
5
6int main() {
7 char buf[100];
8 fd_set rfds;
9
10 printf("多路复用等待输入 (5秒超时):\n");
11 FD_ZERO(&rfds);
12 FD_SET(STDIN_FILENO, &rfds);
13
14 struct timeval tv = {5, 0}; // 5秒超时
15 int ret = select(STDIN_FILENO+1, &rfds, NULL, NULL, &tv);
16 if (ret > 0 && FD_ISSET(STDIN_FILENO, &rfds)) {
17 ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)-1);
18 buf[n] = '\0';
19 printf("你输入了:%s\n", buf);
20 } else if (ret == 0) {
21 printf("5秒内没有输入,超时!\n");
22 } else {
23 perror("select 出错");
24 }
25 return 0;
26}
select
也有不足之处,比如:
- 性能问题:
select
在每次调用时都需要重新设置文件描述符集合,这在文件描述符数量较多时会导致性能下降。 - 文件描述符数量限制:
select
对文件描述符的数量有限制(通常是 1024),这在高并发场景下可能成为瓶颈。 - 返回的文件描述符集合需要遍历:
select
返回后,应用程序需要遍历整个文件描述符集合来检查哪些文件描述符准备好了,这在文件描述符数量较多时效率较低。
poll
后来为了解决 select
的一些不足之处,出现了 poll
。poll
的使用方式与 select
类似,但它不再使用固定大小的文件描述符集合,而是使用一个数组来表示所有待监视的文件描述符。这使得 poll
可以支持更多的文件描述符。但是,poll
仍然需要在每次调用时遍历整个数组,性能上仍然不够理想。
epoll
epoll
是 Linux 2.6 开始支持的一种多路复用技术,它克服了 select
和 poll
的一些缺点。epoll
使用事件通知机制,可以在文件描述符状态发生变化时立即通知应用程序,而不需要轮询。这使得 epoll
在处理大量并发连接时具有更好的性能。
缺点就是带来了更高的复杂性,使用起来相对较为复杂。
epoll server 和 epoll 惊群问题 这两篇文章详细介绍了 epoll
的使用和注意事项。
kqueue
kqueue
是 BSD 系统特有的一种多路复用技术,它与 epoll
类似,使用事件通知机制来提高性能。kqueue
可以监视文件描述符、信号、定时器等多种事件,并在事件发生时通知应用程序。
kqueue
是 BSD 系统特有的技术,无法在 Linux 上使用。我平时主要在 Linux 上进行开发,所以就不在这里贴代码了。想要了解可以去看 redis 的源码,里面有使用 kqueue
的例子。
我之前为 kiwi 数据库写过一套跨平台的网络库,里面也有 kqueue
的实现。
kqueue
不同多路复用区别
特性 | select | poll | epoll | kqueue |
---|---|---|---|---|
fd 上限 | 1024 (FD_SETSIZE) | 无固定上限 | 无固定上限 | 无固定上限 |
fd 集合管理 | 位图,每次重置 | 数组,每次重置 | 内核维护红黑树 | 内核维护 |
返回结果 | 遍历所有 fd | 遍历所有 fd | 直接返回活跃 fd | 直接返回活跃 fd |
时间复杂度 | O(n) | O(n) | O(活跃 fd) | O(活跃 fd) |
触发方式 | 水平触发 | 水平触发 | 水平 + 边缘触发 | 水平 + 边缘触发 |
总结
以上就是对阻塞 I/O、非阻塞 I/O、同步 I/O、异步 I/O 和多路复用等概念的介绍。通过对比不同 I/O 模型的优缺点和适用场景,可以在实际开发中选择合适的 I/O 模型,以提高应用程序的性能和响应能力。
没有万能的解决方案,只有最合适的选择,目前我了解到的,完全用异步 I/O 的服务端还是比较少,比较常用的还是 非阻塞 I/O+多路复用技术。
以上都是用 C/C++ 编程时,自己手写的I/O操作示例。因为 C++ STL 没有提供网络库,IO库,所以需要手动实现这些功能。
如果是使用 golang 这些新的语言,很多I/O操作都被封装好了,直接调用就行了。根本不用关心底层的实现细节。
但是这些底层的知识多了解一点还是有用处的。