你好,我是盛延敏,这里是网络编程实战的第 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 用起来倒是挺简单的。下面我们看一下一个具体的例子:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134

#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。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

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 行一直查询异步读动作的结果。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

./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 轮询异步操作的状态,有没有别的方法可以改进一下,比如挂起调用者、设置超时时间等?

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