一文讲清楚所有IO,同步IO,异步IO,阻塞IO,非阻塞IO,IO多路复用,网络编程

一文讲清楚所有IO,同步IO,异步IO,阻塞IO,非阻塞IO,IO多路复用,网络编程

前两天写了一篇 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

2#include

3#include

4#include

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

2#include

3#include

4#include

5#include

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。

selectselect 是 最常见的一种 多路复用技术,几乎所有的操作系统都支持。

1#include

2#include

3#include

4#include

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 在每次调用时都需要把被监控的fds集合从用户态空间拷贝到内核态空间,这在文件描述符数量较多时会导致性能下降。文件描述符数量限制:select 对文件描述符的数量有限制(通常是 1024),这在大量连接的场景下可能成为瓶颈。返回的文件描述符集合需要遍历:select 返回后,应用程序需要遍历整个文件描述符集合来检查哪些文件描述符准备好了,这在文件描述符数量较多时效率较低。poll后来为了解决 select 的一些不足之处,出现了 poll。poll 的使用方式与 select 类似,但它不再使用固定大小的文件描述符集合,而是使用一个数组来表示所有待监视的文件描述符。这使得 poll 可以支持更多的文件描述符。但是,poll 仍然需要在每次调用时遍历整个数组,性能上仍然不够理想。

epollepoll 是 Linux 2.6 开始支持的一种多路复用技术,它克服了 select 和 poll 的一些缺点。epoll 使用事件通知机制,可以在文件描述符状态发生变化时立即通知应用程序,而不需要轮询。这使得 epoll 在处理大量并发连接时具有更好的性能。

缺点就是带来了更高的复杂性,使用起来相对较为复杂。

epoll server 和 epoll 惊群问题 这两篇文章详细介绍了 epoll 的使用和注意事项。

kqueuekqueue 是 BSD 系统特有的一种多路复用技术,它与 epoll 类似,使用事件通知机制来提高性能。kqueue 可以监视文件描述符、信号、定时器等多种事件,并在事件发生时通知应用程序。

kqueue 是 BSD 系统特有的技术,无法在 Linux 上使用。我平时主要在 Linux 上进行开发,所以就不在这里贴代码了。想要了解可以去看 redis 的源码,里面有使用 kqueue 的例子。

redis kqueue 源码

我之前为 kiwi 数据库写过一套跨平台的网络库,里面也有 kqueue 的实现。

kqueue

不同多路复用区别特性selectpollepollkqueuefd 上限1024 (FD_SETSIZE)无固定上限无固定上限无固定上限fd 集合管理位图,每次重置数组,每次重置内核维护红黑树内核维护返回结果遍历所有 fd遍历所有 fd直接返回活跃 fd直接返回活跃 fd时间复杂度O(n)O(n)O(活跃 fd)O(活跃 fd)触发方式水平触发水平触发水平 + 边缘触发水平 + 边缘触发epoll,kqueue 比 select,poll 更加高效的原因。

事件驱动机制:epoll 和 kqueue 都是基于事件驱动的模型,内核会在事件发生时通知应用程序,而且只关注那些已经就绪的fd即可,而不是像 select 和 poll 每次都需要遍历所有 fd避免频繁的数据拷贝:每次调用 select 或 poll 时,都需要将整个 fd 集合从用户态复制到内核态,调用结束后再将结果从内核态复制回用户态。这种频繁的数据拷贝在高并发场景下会带来较大的性能开销。epoll 和 kqueue 使用了内存映射,内核态和用户态可以访问同一块物理内存,避免了这种频繁的数据拷贝,提升了性能。支持大规模并发:epoll 和 kqueue 都可以支持大量的并发连接,而 select 和 poll 在文件描述符数量较多时会出现性能瓶颈。更灵活的触发方式:epoll 和 kqueue 支持水平触发和边缘触发,应用程序可以根据需要选择合适的触发方式,从而提高性能。再来聊一下 水平触发(LT) 和 边缘触发(ET) 的区别。

水平触发(LT):当文件描述符的状态发生变化时,内核会通知应用程序。应用程序需要在每次调用时检查文件描述符的状态,如果状态仍然就绪,则会重复接收通知。这种方式简单易用,但在高并发场景下可能导致大量重复通知,浪费 CPU 资源。

边缘触发(ET):只有当文件描述符的状态发生变化时,内核才会通知应用程序。应用程序在接收到通知后,需要立即读取所有可用数据,直到返回 EAGAIN 错误。这种方式可以减少重复通知,提高性能,但实现起来相对复杂。

总结以上就是对阻塞 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操作都被封装好了,直接调用就行了。根本不用关心底层的实现细节。

但是这些底层的知识多了解一点还是有用处的。

相关推荐

张承业 (Owon)高清作品大全、代表作品下载
bat365入口

张承业 (Owon)高清作品大全、代表作品下载

07-10 👁️‍🗨️ 5182
bypy更换绑定的百度云盘账户
bat365入口

bypy更换绑定的百度云盘账户

10-25 👁️‍🗨️ 9419
【世界杯】首轮小组赛结束,排名、积分榜
365scores下载

【世界杯】首轮小组赛结束,排名、积分榜

09-12 👁️‍🗨️ 2208
【S26】异界前进黄金港的流程
bat365入口

【S26】异界前进黄金港的流程

09-20 👁️‍🗨️ 6274
备注blue什么意思?blue还有什么特殊含义?
bat365入口

备注blue什么意思?blue还有什么特殊含义?

07-06 👁️‍🗨️ 8182
功成天下窖藏红酒,一桶天下四十二度精品窖藏红瓶装白酒的零售价是多少?
Vol.146 对话「头号玩家」罗叔:做个人IP不是当KOL,做播客就像火车下铺两人唠嗑
小牛电动车质保多久
365scores下载

小牛电动车质保多久

07-22 👁️‍🗨️ 2728
手机运行内存不足怎么办
bat365入口

手机运行内存不足怎么办

09-18 👁️‍🗨️ 1319