30 | 真正的大杀器:异步I/O探索

你好,我是盛延敏,这里是网络编程实战的第30讲,欢迎回来。

在性能篇的前几讲中,我们谈到了阻塞I/O、非阻塞I/O以及像select、poll、epoll等I/O多路复用技术,并在此基础上结合线程技术,实现了以事件分发为核心的reactor反应堆模式。你或许还听说过一个叫做Proactor的网络事件驱动模式,这个Proactor模式和reactor模式到底有什么区别和联系呢?在今天的内容中,我们先讲述异步I/O,再一起揭开以异步I/O为基础的proactor模式的面纱。

阻塞/非阻塞 VS 同步/异步

尽管在前面的课程中,多少都涉及到了阻塞、非阻塞、同步、异步的概念,但为了避免看见这些概念一头雾水,今天,我们就先来梳理一下这几个概念。

第一种是阻塞I/O。阻塞I/O发起的read请求,线程会被挂起,一直等到内核数据准备好,并把数据从内核区域拷贝到应用程序的缓冲区中,当拷贝过程完成,read请求调用才返回。接下来,应用程序就可以对缓冲区的数据进行数据解析。


第二种是非阻塞I/O。非阻塞的read请求在数据未准备好的情况下立即返回,应用程序可以不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲,并完成这次read调用。注意,这里最后一次read调用,获取数据的过程,是一个同步的过程。这里的同步指的是内核区域的数据拷贝到缓冲区的这个过程。


每次让应用程序去轮询内核的I/O是否准备好,是一个不经济的做法,因为在轮询的过程中应用进程啥也不能干。于是,像select、poll这样的I/O多路复用技术就隆重登场了。通过I/O事件分发,当内核数据准备好时,再通知应用程序进行操作。这个做法大大改善了应用进程对CPU的利用率,在没有被通知的情况下,应用进程可以使用CPU做其他的事情。

注意,这里read调用,获取数据的过程,也是一个同步的过程。


第一种阻塞I/O我想你已经比较了解了,在阻塞I/O的情况下,应用程序会被挂起,直到获取数据。第二种非阻塞I/O和第三种基于非阻塞I/O的多路复用技术,获取数据的操作不会被阻塞。

无论是第一种阻塞I/O,还是第二种非阻塞I/O,第三种基于非阻塞I/O的多路复用都是同步调用技术。为什么这么说呢?因为同步调用、异步调用的说法,是对于获取数据的过程而言的,前面几种最后获取数据的read操作调用,都是同步的,在read调用时,内核将数据从内核空间拷贝到应用程序空间,这个过程是在read函数中同步进行的,如果内核实现的拷贝效率很差,read调用就会在这个同步过程中消耗比较长的时间。

而真正的异步调用则不用担心这个问题,我们接下来就来介绍第四种I/O技术,当我们发起aio_read之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。


还记得第22中讲到的去书店买书的例子吗? 基于这个例子,针对以上的场景,我们可以这么理解。

第一种阻塞I/O就是你去了书店,告诉老板你想要某本书,然后你就一直在那里等着,直到书店老板翻箱倒柜找到你想要的书。

第二种非阻塞I/O类似于你去了书店,问老板有没有一本书,老板告诉你没有,你就离开了。一周以后,你又来这个书店,再问这个老板,老板一查,有了,于是你买了这本书。

第三种基于非阻塞的I/O多路复用,你来到书店告诉老板:“老板,到货给我打电话吧,我再来付钱取书。”

第四种异步I/O就是你连去书店取书的过程也想省了,你留下地址,付了书费,让老板到货时寄给你,你直接在家里拿到就可以看了。

这里放置了一张表格,总结了以上几种I/O模型。

aio_read和aio_write的用法

听起来,异步I/O有一种高大上的感觉。其实,异步I/O用起来倒是挺简单的。下面我们看一下一个具体的例子:

#include "lib/common.h"
#include <aio.h>

const int BUF_SIZE = 512;

int main() {
    int err;
    int result_size;

    // 创建一个临时文件
    char tmpname[256];
    snprintf(tmpname, sizeof(tmpname), "/tmp/aio_test_%d", getpid());
    unlink(tmpname);
    int fd = open(tmpname, O_CREAT | O_RDWR | O_EXCL, S_IRUSR | S_IWUSR);
    if (fd == -1) {
        error(1, errno, "open file failed ");
    }

    char buf[BUF_SIZE];
    struct aiocb aiocb;

    //初始化buf缓冲,写入的数据应该为0xfafa这样的,
    memset(buf, 0xfa, BUF_SIZE);
    memset(&aiocb, 0, sizeof(struct aiocb));
    aiocb.aio_fildes = fd;
    aiocb.aio_buf = buf;
    aiocb.aio_nbytes = BUF_SIZE;

    //开始写
    if (aio_write(&aiocb) == -1) {
        printf(" Error at aio_write(): %s\n", strerror(errno));
        close(fd);
        exit(1);
    }

    //因为是异步的,需要判断什么时候写完
    while (aio_error(&aiocb) == EINPROGRESS) {
        printf("writing... \n");
    }

    //判断写入的是否正确
    err = aio_error(&aiocb);
    result_size = aio_return(&aiocb);
    if (err != 0 || result_size != BUF_SIZE) {
        printf(" aio_write failed() : %s\n", strerror(err));
        close(fd);
        exit(1);
    }

    //下面准备开始读数据
    char buffer[BUF_SIZE];
    struct aiocb cb;
    cb.aio_nbytes = BUF_SIZE;
    cb.aio_fildes = fd;
    cb.aio_offset = 0;
    cb.aio_buf = buffer;

    // 开始读数据
    if (aio_read(&cb) == -1) {
        printf(" air_read failed() : %s\n", strerror(err));
        close(fd);
    }

    //因为是异步的,需要判断什么时候读完
    while (aio_error(&cb) == EINPROGRESS) {
        printf("Reading... \n");
    }

    // 判断读是否成功
    int numBytes = aio_return(&cb);
    if (numBytes != -1) {
        printf("Success.\n");
    } else {
        printf("Error.\n");
    }

    // 清理文件句柄
    close(fd);
    return 0;
}

这个程序展示了如何使用aio系列函数来完成异步读写。主要用到的函数有:

  • aio_write:用来向内核提交异步写操作;
  • aio_read:用来向内核提交异步读操作;
  • aio_error:获取当前异步操作的状态;
  • aio_return:获取异步操作读、写的字节数。

这个程序一开始使用aio_write方法向内核提交了一个异步写文件的操作。第23-27行是这个异步写操作的结构体。结构体aiocb是应用程序和操作系统内核传递的异步申请数据结构,这里我们使用了文件描述符、缓冲区指针aio_buf以及需要写入的字节数aio_nbytes。

struct aiocb {
   int       aio_fildes;       /* File descriptor */
   off_t     aio_offset;       /* File offset */
   volatile void  *aio_buf;     /* Location of buffer */
   size_t    aio_nbytes;       /* Length of transfer */
   int       aio_reqprio;      /* Request priority offset */
   struct sigevent    aio_sigevent;     /* Signal number and value */
   int       aio_lio_opcode;       /* Operation to be performed */
};

这里我们用了一个0xfa的缓冲区,这在后面的演示中可以看到结果。

30-34行向系统内核申请了这个异步写操作,并且在37-39行查询异步动作的结果,当其结束时在42-48行判断写入的结果是否正确。

紧接着,我们使用了aio_read从文件中读取这些数据。为此,我们准备了一个新的aiocb结构体,告诉内核需要把数据拷贝到buffer这个缓冲区中,和异步写一样,发起异步读之后在第65-67行一直查询异步读动作的结果。

接下来运行这个程序,我们看到屏幕上打印出一系列的字符,显示了这个操作是有内核在后台帮我们完成的。

./aio01
writing... 
writing... 
writing... 
writing... 
writing... 
writing... 
writing... 
writing... 
writing... 
writing... 
writing... 
writing... 
writing... 
writing... 
Reading... 
Reading... 
Reading... 
Reading... 
Reading... 
Reading... 
Reading... 
Reading... 
Reading... 
Success.

打开/tmp目录下的aio_test_xxxx文件,可以看到,这个文件成功写入了我们期望的数据。

请注意,以上的读写,都不需要我们在应用程序里再发起调用,系统内核直接帮我们做好了。

Linux下socket套接字的异步支持

aio系列函数是由POSIX定义的异步操作接口,可惜的是,Linux下的aio操作,不是真正的操作系统级别支持的,它只是由GNU libc库函数在用户空间借由pthread方式实现的,而且仅仅针对磁盘类I/O,套接字I/O不支持。

也有很多Linux的开发者尝试在操作系统内核中直接支持aio,例如一个叫做Ben LaHaise的人,就将aio实现成功merge到2.5.32中,这部分能力是作为patch存在的,但是,它依旧不支持套接字。

Solaris倒是有真正的系统系别的aio,不过还不是很确定它在套接字上的性能表现,特别是和磁盘I/O相比效果如何。

综合以上结论就是,Linux下对异步操作的支持非常有限,这也是为什么使用epoll等多路分发技术加上非阻塞I/O来解决Linux下高并发高性能网络I/O问题的根本原因。

Windows下的IOCP和Proactor模式

和Linux不同,Windows下实现了一套完整的支持套接字的异步编程接口,这套接口一般被叫做IOCompletetionPort(IOCP)。

这样,就产生了基于IOCP的所谓Proactor模式。

和Reactor模式一样,Proactor模式也存在一个无限循环运行的event loop线程,但是不同于Reactor模式,这个线程并不负责处理I/O调用,它只是负责在对应的read、write操作完成的情况下,分发完成事件到不同的处理函数。

这里举一个HTTP服务请求的例子来说明:

  1. 客户端发起一个GET请求;
  2. 这个GET请求对应的字节流被内核读取完成,内核将这个完成事件放置到一个队列中;
  3. event loop线程,也就是Poractor从这个队列里获取事件,根据事件类型,分发到不同的处理函数上,比如一个http handle的onMessage解析函数;
  4. HTTP request解析函数完成报文解析;
  5. 业务逻辑处理,比如读取数据库的记录;
  6. 业务逻辑处理完成,开始encode,完成之后,发起一个异步写操作;
  7. 这个异步写操作被内核执行,完成之后这个异步写操作被放置到内核的队列中;
  8. Proactor线程获取这个完成事件,分发到HTTP handler的onWriteCompled方法执行。

从这个例子可以看出,由于系统内核提供了真正的“异步”操作,Proactor不会再像Reactor一样,每次感知事件后再调用read、write方法完成数据的读写,它只负责感知事件完成,并由对应的handler发起异步读写请求,I/O读写操作本身是由系统内核完成的。和前面看到的aio的例子一样,这里需要传入数据缓冲区的地址等信息,这样,系统内核才可以自动帮我们把数据的读写工作完成。

无论是Reactor模式,还是Proactor模式,都是一种基于事件分发的网络编程模式。Reactor模式是基于待完成的I/O事件,而Proactor模式则是基于已完成的I/O事件,两者的本质,都是借由事件分发的思想,设计出可兼容、可扩展、接口友好的一套程序框架。

总结

和同步I/O相比,异步I/O的读写动作由内核自动完成,不过,在Linux下目前仅仅支持简单的基于本地文件的aio异步操作,这也使得我们在编写高性能网络程序时,首选Reactor模式,借助epoll这样的I/O分发技术完成开发;而Windows下的IOCP则是一种异步I/O的技术,并由此产生了和Reactor齐名的Proactor模式,借助这种模式,可以完成Windows下高性能网络程序设计。

思考题

和往常一样,给你布置两道思考题:

  1. 你可以查一查Linux的资料,看看为了在内核层面支持完全的异步I/O,Linux的世界里都发生了什么?
  2. 在例子程序里,aio_error一直处于占用CPU轮询异步操作的状态,有没有别的方法可以改进一下,比如挂起调用者、设置超时时间等?

欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流进步一下。

精选留言

  • fackgc17

    2019-10-16 22:24:42

    Linux 的 AIO 机制可能后面逐渐不用了,可以关注 5.1 的 io_uring 机制,大杀器
    作者回复

    赞,学习了。

    2019-10-19 11:28:19

  • 2019-12-01 17:30:57

    记一下自己对阻塞/非阻塞/同步/异步的理解
    1:阻塞/非阻塞——是站在调用者(客户端或请求方)的角度来说的,如果调用者要数据,此时服务者没准备好,调用者不用傻等,那就是非阻塞的,否则就是阻塞的

    2:同步/异步——是站在服务者(服务端或响应端)的角度来说的,如果服务者不会主动给请求者它想要的数据,那就是同步的,否则就是异步的

    3:两个位置每个位置有两种状态,总共四种状态——同步阻塞/同步非阻塞/异步阻塞(不搭)/异步非阻塞
    ,其中同步阻塞和异步非阻塞是很搭的相互成就,异步阻塞是非常不搭的相互矛盾,同步非阻塞有一点怪,它通常就是靠不断的轮询来实现的,服务者不会主动把准备好的数据发给调用者,调用者也不会等着服务者把数据准备好再返回。

    4:导致出现这种现象的根本原因,我觉得是数据的读取是一个慢动作,数据不管从磁盘中拿出来还是放入到磁盘中,相对内存操作相对CPU执行命令的操作速度是非常慢的,这种速度差必然导致要么等一等,要么不断的来瞧瞧,要么准备好了送过去,这三种解决此问题的思路。
  • 进击的巨人

    2020-11-15 16:47:09

    非常好的总结:Reactor 模式是基于待完成的 I/O 事件,而 Proactor 模式则是基于已完成的 I/O 事件。
    作者回复

    都是前人栽树,后人乘凉 )

    2020-11-22 18:45:32

  • null

    2021-04-14 20:16:37

    还是没太理解阻塞和同步之间的区别

    1. 阻塞:只是针对线程挂起这一场景,线程无法获取 cpu 时间片,无法处理其他逻辑。
    2. 非阻塞:线程正常获取 cpu 时间片,正常运行,可以不断轮询 read 函数,快速返回,同时也可以处理其他逻辑。
    结论:阻塞和非阻塞的区别,只是针对线程是否挂起(即能否获取 cpu 时间片)。


    同步:线程自己读取数据,不断地从内核缓冲区读取到应用程序缓冲区,直到读取所有数据。读数据期间线程忙个不停,疲于奔命。(读数据这期间线程脱身去无法处理其他逻辑)
    异步:内核把所有数据写到应用程序缓冲区,再通知应用程序处理。在通知之前,应用程序都可以去处理其他逻辑。
    结论:同步和异步的区别,只是针对谁将数据读到应用程序缓冲区。

    自己一直混淆阻塞和同步的概念,应该都是被“线程无法处理其他逻辑”所迷惑,感觉两者的表现都是一样的,都是无法处理其他逻辑,因此将这两个概念混为一谈。

    老师,请问一下,我对阻塞/非阻塞和同步/异步的理解,并且相关的结论,是否正确?谢谢老师!!
    作者回复

    我觉得你get到了。

    2021-04-18 21:15:04

  • 马不停蹄

    2019-11-13 16:26:01

    异步I/O就相当于当数据真正到达或者说应用程序读取完数据了通过注册的回调函数通知你去处理,用 netty 是这样的,但是老师有个问题一直不明白:netty 并没有用异步I/O,而是基于(多路复用+非阻塞I/O)+ Future 实现了异步非阻塞吗?
    作者回复

    Netty用的就是epoll,基于多路复用+非阻塞I/O,至于Future,只是Java里包装异步调用的一种方式,并不是真正的异步 I/O。

    2019-11-17 19:51:49

  • TinyCalf

    2020-11-10 18:47:27

    老师我有些想法不知道正不正确:
    其实我用非阻塞IO,自己写代码把数据拷贝过程和业务处理过程分离到多个线程,也能实现代码层面的异步,而操作系统提供的异步IO只不过是把这个过程转到内核态去完成;可能内核处理这些逻辑比我们自己写的代码效率要高些,但是绝对不会像多路复用接口一样带来巨大的效率提升;nodejs好像就是这样,用非阻塞IO+libuv实现的eventloop来实现代码层面的异步,但并没有使用异步IO接口
    作者回复

    Linux系统提供的异步I/O还停留在很浅的阶段,所以,现实的做法都是多路复用+非阻塞I/O来完成你说的"代码层面的异步",这已经足够高效和有用了。

    2020-11-22 18:50:07

  • 传说中的成大大

    2019-10-16 11:42:12

    看第二遍理解了reactor和proactor的区别前者是同步 有消息到达时调用应用程序的回调,应用程序自己调用read 同步取得数据,而后者是内核异步数据读取完成之后才调用应用程序的回调
    作者回复

    我理解Linux下标榜的proactor其实都是伪的。

    2019-10-19 11:04:20

  • 传说中的成大大

    2019-10-16 11:25:06

    而突然又理解到了同步i/o和异步i/o的问题 比如我调用read函数 在read函数返回之前数据被拷贝到缓冲区这个过程就是同步i/o的操作 像后面的aio系列函数 是在函数调用后 内核把数据拷贝到应用层缓冲区 这个就叫异步
    作者回复

    你真的悟道了,哈哈:)

    2019-10-19 11:03:17

  • 土豆牛肉

    2019-11-20 00:24:42

    既然Windows有iocp,是不是可以说Windows更适合运行网络服务器呢
    作者回复

    不是哦,只是设计上各有千秋,现在互联网上跑的最多的还是Linux,就连微软,也在积极拥抱Linux。

    2019-11-23 16:42:08

  • 扩散性百万咸面包

    2020-06-01 18:24:15

    老师,不太明白为什么,要把Non-Blocking和多路复用I/O技术(select, poll, epoll)一起说?还说多路复用是基于Non-Blocking的呢?select明明可以通过参数设置为阻塞等待,或者不等待,或者等待相应时间后超时呀。我理解的是为了降低轮询的复杂度,多路I/O在Non-blocking的基础上加入了阻塞等候,减少轮询次数?
    作者回复

    select是可以和阻塞I/O一起使用的,不过在实际中,非阻塞I/O和多路复用一起使用的效果更好,我在讲座中讲到了使用阻塞I/O和select等搭配使用的一些问题,你可以体会下。

    2020-06-21 19:27:08

  • 传说中的成大大

    2019-10-19 10:54:33

    issue和mr是啥意思啊,没接触到过呢!
    作者回复

    issue是提一个问题到github上,mr是看哪里有问题直接改代码,提一个merge request过来,我直接merge到master把问题解决了

    2019-10-23 23:50:47

  • 程序水果宝

    2019-10-16 13:05:51

    看了最近几篇文章以后个人感觉应该把反应堆、epoll、异步和同步的函数列出来配合着它们的功能讲,很有可能不懂的地方都在那些封装的函数里面,像main函数里面的内容反而给出链接加注释就可以了,这样可能会让人的理解更加深刻一些。还有实验结果也不用列这么多,这些完全可以由自己去实验。
    作者回复

    感谢你的建议。时间有限,做出来的内容可能没有办法满足所有人的需求。在第四篇里可能会解答你的大部分疑惑,如果有进一步的问题,我可以在答疑中统一回复,解答大家的疑惑。

    2019-10-19 11:06:38

  • 菠萝power

    2021-03-21 18:12:44

    老师好。同步read的时候,内核把数据拷贝到应用程序这个时间段消耗的是应用程序的时间片吗?
    作者回复

    这个看你的理解,如果你认为是同步read调用的发起,导致了应用程序进入休眠,等待系统把数据拷贝完,从应用程序角度来看,好像是应用程序自己把时间片拱手让人,从而消耗了自己的时间,这个理解是对的。

    2021-03-28 20:25:50

  • ray

    2020-04-12 20:36:41

    老师好,
    这边重新梳理了一下,阻塞多路复用I/O,和非阻塞多路复用I/O,还请老师点评。

    阻塞多路复用I/O:
    当应用程序有读写请求时,不管内核缓冲区状况如何,都立即发起通知事件,告知应用程序现在可以读写。
    读写操作可能需等待内核空出缓冲区。

    非阻塞多路复用I/O:
    当应用程序有读写请求时,内核缓冲区也有空间可以被读写时,才会对应用程序发起事件通知,告知应用程序现在可以读写。
    此时读写操作所需的内核缓冲空间已被准备好,应用程序可以立即做读写操作。
    非阻塞多路复用I/O,不会在应用程序端不断polling,这是和非阻塞I/O最大的区别,而是kernel准备好了才做通知。

    不知道我的理解是否到位?

    谢谢老师!
    作者回复

    基本正确,阻塞多路复用I/O一般用的比较少。

    2020-04-19 17:35:08

  • 蓬蒿

    2020-03-16 08:51:09

    针对老师提出的第二个问题也是我一直思考的:异步io的应用场景。像老师给的例子代码发出读写后依然循环等待结果,这断然不是异步io的使用场景,还不如用同步io来节省CPU呢,所以异步io的使用场景有哪些?
    作者回复

    Linux下确实没有非常好的异步I/O场景,文章中给出了Windows下的IOCP,倒是一个真实的异步I/O的场景。

    2020-03-22 15:57:42

  • 夏目

    2019-11-18 21:18:54

    老师,可以把阻塞非阻塞对应数据准备过程,同步异步对应数据从内核到应用程序缓冲区过程吗?
    作者回复

    你这样理解也可以。

    2019-11-23 16:34:37

  • 传说中的成大大

    2019-10-17 17:21:02

    在poll-server-onethread程序中 onMessage回调里面调用 char *run_cmd(char *cmd) {
    char *data = malloc(16384);
    bzero(data, sizeof(data));
    FILE *fdp;
    const int max_buffer = 256;
    char buffer[max_buffer];
    fdp = popen(cmd, "r");
    char *data_index = data;
    if (fdp) {
    while (!feof(fdp)) {
    if (fgets(buffer, max_buffer, fdp) != NULL) {
    int len = strlen(buffer);
    memcpy(data_index, buffer, len);
    data_index += len;
    }
    }
    pclose(fdp);
    }
    return data;
    }
    总是提示 get message from tcp connection connection-7
    ls
    : not found
    这就让我很蛋疼了,百度了半天没解决到
    作者回复

    从onMessage如何调到run_cmd的?

    2019-10-19 11:52:32

  • ano

    2022-08-24 12:46:29

    之前的那个留言描述不对!正确的应该是下面这样:
    "非阻塞IO,最后一次 read 调用,获取数据的过程,是一个同步的过程。这里的同步指的是内核区域的数据拷贝到缓冲区的这个过程。” 这里说获取数据是一个同步的过程,那在已经 read ready的非阻塞套接字上调用 recv 的时候,就不会立即返回了?而是要等到数据完全拷贝完成后,才会返回?是这个意思么?
  • gecko

    2021-12-03 11:08:08

    请教老师,编译这个报错怎么解决。
    git clone https://github.com/froghui/yolanda.git
    cd yolanda
    mkdir build
    cd build/
    cmake ../
    make



    如下输出

    [ 97%] Linking C executable ../bin/aio01
    CMakeFiles/aio01.dir/aio01.c.o: In function `main':
    aio01.c:(.text+0x19b): undefined reference to `aio_write'
    aio01.c:(.text+0x1f4): undefined reference to `aio_error'
    aio01.c:(.text+0x208): undefined reference to `aio_error'
    aio01.c:(.text+0x21d): undefined reference to `aio_return'
    aio01.c:(.text+0x329): undefined reference to `aio_read'
    aio01.c:(.text+0x379): undefined reference to `aio_error'
    aio01.c:(.text+0x38d): undefined reference to `aio_return'
    collect2: error: ld returned 1 exit status
    chap-30/CMakeFiles/aio01.dir/build.make:95: recipe for target 'bin/aio01' failed
    make[2]: *** [bin/aio01] Error 1
    CMakeFiles/Makefile2:2286: recipe for target 'chap-30/CMakeFiles/aio01.dir/all' failed
    make[1]: *** [chap-30/CMakeFiles/aio01.dir/all] Error 2
    Makefile:129: recipe for target 'all' failed
    make: *** [all] Error 2
    root@ubuntu-192-168-1-182:/tmp/yolanda/build#
    作者回复

    加一下 rt这个动态库
    在CMakeLists.txt里面添加:
    target_link_libraries(aio01 yolanda rt)

    2021-12-05 16:06:14

  • schbxg

    2021-08-21 13:23:00

    如果使用g++编译,52行的cb结构需要初始化一下,不然下边aio_read的时候会报无效参数的错误。
    作者回复

    OK。

    2021-08-22 20:34:21