0%

IO多路复用

任务类型

程序任务类型一般分 CPU密集型 和 IO密集型。
CPU密集型任务,指:进行大量计算,消耗CPU资源的任务,eg. 图像处理、音视频编码解码、复杂的科学计算等。
IO密集型任务,指:涉及大量网络、磁盘等比较耗时的输入输出任务,eg. Web服务、数据库操作等。
不论CPU密集型任务还是IO密集型任务,为了提高处理能力,从软件层面入手,有几种主流方式:启动多个服务实例(多进程、多线程、多协程)、IO多路复用、异步IO。
实际应用时,会结合任务类型在上述方式中选择多种进行组合,以此搭建适合的高性能服务框架,eg. Apache 属于多进程多线程并配合 select 模型进行IO处理、Nginx 主要是 epoll 模型(IO多路复用) + 多进程。

IO 和 Socket

Socket 又称 套接字,是一种进程间通信机制,在系统层面上封装了通过 IP + Port 进行 TCP / UDP 数据传输的接口,介于 OSI(Open System Interconnection)模型的传输层与应用层之间。

严格来说,Socket 不归属于 OSI 的任何一层,它是应用层与 TCP/IP 协议族通信的中间软件抽象层。
在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议族隐藏在 Socket 接口后面。
Socket 还可以用在同一主机不同进程间的通信(本地 Socket),且建立的是双向的通信。本地 Socket 与 网络 Socket 共用同一套接口,只是地址结构与某些参数不同。
进程间通信(IPC,Inter-Process Communication)方式,除了 Socket,还有:管道 Pipe命名管道 FIFO信号 Signal消息队列 Message Queue共享内存 Shared Memory

网络IO的本质是通过 Socket 对网卡进行读写,Socket 在 linux 系统被抽象为流,IO可以理解为对流的操作。

普通的网络传输步骤如下:

  1. 操作系统将数据从磁盘复制到操作系统内核的页缓存(内核缓冲区)中。
  2. 应用将数据从内核缓冲区复制到应用的进程缓冲区中。
  3. 应用将数据从进程缓冲区写回内核的 Socket 缓冲区中。
  4. 操作系统将数据从 Socket 缓冲区复制到网卡,然后将其通过网络发出。

注:上述过程可以通过 Zero-Copy(零拷贝机制) 进行优化,关于 Zero-Copy 后续再开篇来讲。

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

单从字面上看这几个概念,我们很容易把同步等同于阻塞,异步等同于非阻塞,但实际上,不同的层面有不同的理解。

  1. 进程通信层面
    • “阻塞”是指进程在发起一个系统调用(System Call)后,由于该系统调用的操作(一般是涉及I/O操作,比较耗时)不能立即完成,需要等待一段时间,于是内核将进程挂起为等待(waiting)状态,以确保它不会被调度执行,占用CPU资源。
    • 非阻塞I/O系统调用(Non-Blocking I/O System Call)指一个非阻塞调用不会因I/O操作而挂起执行程序,而是会立即返回。
    • 异步I/O系统调用(Asychronous I/O System Call)也属于非阻塞系统调用(Non-Blocing System Call),不同的是,I/O操作完成后,操作系统会通知调用进程(设置一个用户空间特殊的变量值 或者 触发一个 Signal 或者 产生一个软中断 或者 调用应用程序的回调函数)。
      不同于非阻塞I/O系统调用,异步I/O系统调用的结果必须是完整的,但这个操作完成的通知可以延迟到将来的某个时间点,即,返回结果的方式和内容有所差别。
  2. IO系统调用层面
    (单线程背景下)多数I/O系统调用是同步的,真正的异步I/O是需要内核级支持的,eg. libaio。

非阻塞系统调用(Non-Blocking I/O System Call 与 Asynchronous I/O System Call)可以用于实现线程级别的 I/O 并发,与通过多进程实现的 I/O 并发相比可以减少内存消耗以及进程切换的开销。

举个栗子:我们知道,JS是单线程的,那为什么 Ajax 可以实现异步 HTTP 请求?
本质还是事件驱动,即,把事件加入队列,通过 Event Loop 去轮询队列,待JS线程空闲时再执行回调函数。
所以,对于JS线程来讲,Ajax 是异步的,但在I/O调用上确实还是同步的。
注:JS引擎确实是单线程运行的,而浏览器会另开线程专门处理 Event Loop、HTTP 请求、界面渲染等。

IO过程

传统I/O的大致过程如下:
alt

内核空间 / 用户空间

虚拟内存被操作系统划分为两块:内核空间 和 用户空间,为了安全,它们是隔离的。
内核空间是内核代码运行的地方,可以调用系统的一切资源;用户空间是用户程序代码运行的地方,不能直接调用系统资源,必须通过系统接口(System Call)才能向内核发出指令。
进程运行在内核空间时就处于内核态,运行在用户空间时就处于用户态,通过系统接口,进程可以从用户空间切换到内核空间。

1
2
3
4
str = "my string" // 用户空间
x = x + 2
file.write(str) // 切换到内核空间
y = x + 4 // 切换回用户空间

上面代码中,第一行和第二行都是简单的赋值运算,在 User Space 执行。第三行需要写入文件,就要切换到 Kernel Space,因为用户空间不能直接写文件,必须通过内核。第四行又是赋值运算,就切换回 User Space。

过程简述

整个I/O过程可以划分为两个阶段:I/O调用阶段 和 I/O执行阶段。

  • I/O调用阶段 - 进程向内核发起系统调用。
  • I/O执行阶段 - 内核执行I/O 并 拷贝数据至进程缓冲区 或 _向进程反馈写成功_。
    • 数据准备 - 内核在收到指令后需要做一系列 把数据从I/O设备写到内核缓冲区(读)把进程缓冲区的待写数据替换到内核缓冲区中(写) 的准备工作。
    • 数据拷贝 - 读过程中,数据准备好后,从内核缓冲区拷贝数据到进程缓冲区;写过程不需要拷贝数据,内核只需在合适时机再将脏页刷入磁盘。

I/O模型

Richard Stevens 在《Unix Network Programming Volume 1, Third Edition: The Sockets Networking API》6.2节“I/O Models”中提出了Unix下可用的5种I/O模型。

Blocking I/O

阻塞I/O,简称 BIO,Blocking I/O,是最常见的I/O模型,默认情况下,所有套接字都是阻塞的。以数据报(UDP)套接字为例:
alt

其中,recvfrom函数视为系统调用,会从在应用进程空间中运行切换到在内核空间中运行,直到数据报到达且被复制到应用进程的缓冲区中或者发生错误(最常见的错误是系统调用被信号中断)才返回。
进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的,recvfrom成功返回后,应用进程开始处理数据报。
BIO比较影响应用性能,虽然可以通过多线程来提升,但线程的创建销毁与切换同样消耗系统资源、影响性能。

Non-Blocking I/O

非阻塞I/O,简称 NIO,Non-Blocking I/O,即,内核收到进程发起的系统调用请求后会立即返回,之后进程通过轮询(polling)的方式来不断请求获取处理结果。
alt
上图中,前几次调用recvfrom时,数据还未准备好,内核立即返回一个EWOULDBLOCK错误;之后再次调用recvfrom时数据报已准备好,并从内核缓冲区复制到进程缓冲区,完成后返回成功。

EWOULDBLOCK - 表示该操作可能被阻塞,其中,E是Error,WOULD BLOCK表示可能被阻塞。

NIO中,进程需要不断地询问内核数据是否就绪,这会耗费大量CPU时间。
值得注意的是:BIO中进程(或线程)被阻塞,但不占用CPU,只因一个进程(或线程)只能处理一个任务,比较影响应用性能。

I/O Multiplexing

I/O多路复用,I/O Multiplexing,Select 是内核提供的系统调用,它支持一次查询多个系统调用的可用状态,当任意一个结果状态可用时就会返回,用户进程再发起一次系统调用进行数据读取。
换句话说,NIO中轮询的系统调用,借助 Select,只需要发起一次系统调用就够了。
alt
进程阻塞于select调用,等待数据报套接字变为可读;当select返回套接字可读这一条件时,再调用recvfrom把所读数据报复制到进程缓冲区。
相比BIO,select 的优势在于可以等待多个描述符就绪。

与I/O多路复用相对应的是在多线程中使用BIO,每个文件描述符一个线程,这样每个线程都可以自由地阻塞调用recvfrom。

Signal-Driven I/O

信号驱动I/O,Signal-Driven I/O,即,让内核在描述符就绪时发送 SIGIO信号 通知应用。
alt
通过 sigaction 系统调用安装一个信号处理程序,当数据报准备好读取时,内核就为该进程产生一个SIGIO信号;
信号处理程序捕获SIGIO信号并调用recvfrom读取数据报(也可以直接通知应用去调recvfrom),然后通知应用进程开始处理。
信号驱动I/O与BIO和NIO最大的区别在于:在I/O执行的数据准备阶段,不会阻塞用户进程。
信号驱动I/O有种异步操作的感觉,但在I/O执行的第二阶段(也就是将数据从内核复制到用户空间),用户进程还是被阻塞的。

Asynchronous I/O

异步I/O,简称 AIO,Asynchronous I/O,由 POSIX标准 定义(POSIX标准 定义了aio_*系列异步I/O函数)。

POSIX,Portable Operating System Interface of Unix,可移植操作系统接口,X 表明其对 Unix API 的传承。
POSIX标准由IEEE(电气和电子工程师协会)发布,是IEEE为要在各种UNIX操作系统上运行软件而定义的一系列API标准的总称。

alt
异步I/O真正实现了I/O全流程的非阻塞:进程发出系统调用后立即返回,内核在数据准备完成后将数据拷贝到进程缓冲区,然后发送信号告诉进程I/O操作执行完毕。
与信号驱动I/O相比,异步I/O是由内核在I/O操作执行完成后通知应用。

I/O多路复用实现机制

select、poll、epoll都是I/O多路复用的实现机制。

select

过程简述

  • 应用中维护待检查的FD_SET(Socket集合),然后调用 select 函数。
  • 内核逐个检测FD_SET中的Socket,若某个Socket状态有变化,则填入内部分配的一个数组,待所有Socket检测完成,再将此数组拷贝到对应的FD_SET,然后返回。
  • 应用从返回的 select 函数获取有对应状态变化的FD_SET,然后对这些Socket进行IO操作。

select 会阻塞并监视三类(读/写/异常)文件描述符,等有数据(读就绪列表/写就绪列表/异常列表)或超时,就会返回。

1
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

其中,nfds 是 readfds、writefds、exceptfds 中编号最大的那个文件描述符加1(加1是因为文件描述符从0开始计数),readfds 存放读就绪的文件描述符列表,writefds 存放写就绪的文件描述符列表,exceptfds 存放出现异常的文件描述符列表。timeout 为最大阻塞时间长度(精确到毫秒)…>>

优缺点

优点:有较多系统实现了 select 接口,跨平台更容易。
缺点:对监听的文件描述符有上限(一般是1024);应用需维护FD_SET,并与内核来回传递,复制开销大;内核轮询扫描待检测FD_SET,占用较多CPU时间,效率低。

select 中,为什么监听的文件描述符有上限?
FD_SET 是个 Bitmap,每个 bit 都表示对应的描述符是否监听(即使相应位置的描述符不需要监听,在 FD_SET 里也有它的 bit 存在),最多 nfds 个 bit(nfds 即代表了 fd_set 的长度:fd_set[0] ~ fd_set[nfds - 1])。
而 Linux 系统中,文件描述符是有上限的,Soft limit 默认为1024。
虽然可以改 Soft limit(ulimit -n <文件数目> 或 修改 /etc/security/limits.conf 文件),但 select 中的 FD_SETSIZE 也默认设置了1024,还得重新编译内核。

poll

本质上与 select 一致,区别是使用链表存储FD,解决了最大文件描述符的限制,但内核依然要遍历所有FD且同样需要复制数据。

1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

nfds 是 fds 数组的长度,struct pollfd 定义如下:

1
2
3
4
5
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};

可以看出,fds 的核心还是文件描述符,通过 events 和 revents 来标识已变化的监听事件。
https://man7.org/linux/man-pages/man2/poll.2.html

epoll

epoll 的核心是 Event-Driven(事件驱动)模型,即,注册监听事件,通过回调维护就绪事件队列,以便在系统调用时返回给应用,进而完成I/O操作。
三个核心函数:

  • epoll_create() - 初始化,构建 epoll 对象,用于存储 fd 和 就绪事件。
  • epoll_ctl() - 增删改 epoll 对象中的 fd 并注册监听事件。
  • epoll_wait() - 阻塞等待以获取就绪事件,以便应用继续完成I/O操作。

函数定义

epoll_create
1
int epoll_create(int size);

https://man7.org/linux/man-pages/man2/epoll_create.2.html
返回一个 epoll 文件描述符(epfd),size 用于指定最大监听的 fd + 1 的值。
epfd 会占用一个 fd 值,若不再使用,必须调用 close() 关闭,否则可能导致 fd 被耗尽。
当所有引用 epfd 的文件描述符均已关闭,内核也会销毁此对象并释放相关联资源。

epoll_create1
1
int epoll_create1(int flags);

https://man7.org/linux/man-pages/man2/epoll_create1.2.html
epoll_create1 扩展了 epoll_create 的功能,当 flags = 0 时,两者等价。
当 flags = EPOLL_CLOEXEC 时,在进程替换映像(exec* 函数组,用于把当前进程替换为一个新进程,新进程与原进程有相同的PID;实际上是调用 fork 复制一个新的子进程,利用 exec* 系统调用将新产生的子进程完全替换父进程)的时候会关闭这个文件描述符,这样新的映像中就无法对这个文件描述符进行操作,用于多进程及映像替换的情况。
返回值:成功时返回一个文件描述符;出错时返回 -1。

epoll_ctl
1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

https://man7.org/linux/man-pages/man2/epoll_ctl.2.html
op,要执行的命令:EPOLL_CTL_ADD - 向 epfd 添加一个连接 socket 的文件描述符;EPOLL_CTL_MOD - 改变 epfd 中一个文件描述符的监听事件;EPOLL_CTL_DEL - 移除 epfd 中一个文件描述符。
fd,要操作的文件描述符。
epoll_event 结构:

1
2
3
4
5
6
7
8
9
10
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};

events 为以下零个或多个可用事件进行的或运算:

  • EPOLLIN - 对应的文件描述有可以读取的内容。
  • EPOLLOUT - 对应的文件描述符可以写入。
  • EPOLLRDHUP - 写到一半的时候连接断开。
  • EPOLLPRI - 发生异常情况,eg. TCP连接中收到了带外数据。
  • EPOLLET - 设置 epfd 中 fd 的事件触发机制为边缘触发(Edge Trigger),默认为水平触发(Level Trigger)。
  • EPOLLERR - 对应的文件描述符发生错误。
  • EPOLLHUP - 对应的文件描述符被挂断。
  • EPOLLONESHOT - 只监听一次事件,当监听完这次事件之后,如果还想继续监听,需要再次把 socket 对应的 fd 加入到 epfd 里。
  • EPOLLWAKEUP(Linux 3.5之后新增) - 如果 EPOLLONESHOT 和 EPOLLET 清除了,并且进程拥有 CAP_BLOCK_SUSPEND 权限,那么此标志能够保证事件在挂起或者处理的时候,系统不会挂起或休眠。
  • EPOLLEXCLUSIVE(Linux 4.5之后新增) - 保证一个事件发生时只有一个线程会被唤醒,以避免多侦听下的“惊群”问题;不过任一时候只能有一个工作线程调用 accept,限制了真正并行的吞吐量。
epoll_wait
1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

https://man7.org/linux/man-pages/man2/epoll_wait.2.html
events,从内核得到的事件集合。
maxevents,告诉内核 events 的大小,maxevents 的值不能大于 epoll_create() 的 size。
timeout,超时时间(毫秒,0 立即返回,-1 永久阻塞)。
返回值:需要处理的事件数量,返回 0 表示已超时。

基本用法

https://man7.org/linux/man-pages/man7/epoll.7.html 提供了使用示例。

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
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Code to set up listening socket, 'listen_sock',
(socket(), bind(), listen()) omitted */

epollfd = epoll_create1(0);
if (epollfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}

ev.events = EPOLLIN;// 监听 listen_sock 的连接请求
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {// 把 listen_sock 添加到 epollfd 里
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}

for (;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}

for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listen_sock) {// 如果事件对应的 fd 为 listen_sock, 说明已经有客户端请求连接 listen_sock
// 建立连接, 创建 conn_sock
conn_sock = accept(listen_sock, (struct sockaddr *) &addr, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
// 把 conn_sock 设置为非阻塞
setnonblocking(conn_sock);
// 监听 conn_sock 是否有内容可读取(即, 客户端发送数据过来), 同时设置为边缘触发模式
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
// 把 conn_sock 添加到 epollfd 里
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
// 进行其他操作
do_use_fd(events[n].data.fd);
}
}
}

核心设计

在 epoll 文件系统里新建一个文件,此文件对应 eventpoll 结构体。

1
2
3
4
5
6
7
8
struct eventpoll {
  ...
  /* 红黑树的根节点 */
  struct rb_root rbr;
  /* 双向链表 rdllist 保存就绪事件 */
  struct list_head rdllist;
  ...
};

在内核缓冲区里构建一棵红黑树,用于存储所有添加到 epoll 的事件。
事件都会与设备(eg. 网卡)驱动程序建立回调关系,相应事件发生时会执行回调函数 ep_poll_callback
回调函数中把就绪事件添加到 rdllist 双向链表。

每一个事件都对应一个 epitem 结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct epitem {
...
  /* 红黑树节点 */
  struct rb_node rbn;
  /* 双向链表节点 */
  struct list_head rdllink;
  /* 事件句柄等信息 */
  struct epoll_filefd ffd;
  /* 指向其所属的 eventpoll 对象 */
  struct eventpoll *ep;
  /* 监听的事件 */
  struct epoll_event event;
  ...
};

当调用 epoll_wait 检查是否有就绪事件时,只需检查 eventpoll 对象中的 rdllist 是否有 epitem 元素;
若 rdllist 不为空,则复制事件到进程缓冲区(使用 mmap 提升效率),并返回事件数量。
红黑树对查询、新增、删除的效率极高,所以通过 epoll_ctl 向 epoll 对象增删改事件时非常快。

mmap 是目前 Linux 进程间通信中传递最快、消耗最小、传递数据过程不涉及系统调用的方法,这极大降低了大量FD时数据拷贝的消耗。

alt

触发模式

epoll 有两种触发模式:边缘触发(Edge Trigger),简称 ET;水平触发(Level Trigger),简称 LT;默认为 LT。

  • LT 模式
    只要某个文件描述符还有数据可读,则每次 epoll_wait 都会返回此文件描述符的事件,提醒应用程序去操作。
  • ET 模式
    在检测到有 I/O 事件时,通过 epoll_wait 调用会得到就绪事件的文件描述符,对于每一个就绪事件的文件描述符,如可读,应用程序需要以 Non-Blocking 方式将该文件描述符一直读完(非阻塞情况下,没有数据可读时,read() 会返回 EAGAIN),否则下次 epoll_wait 不会返回该事件,直到该文件描述符再次可读时才通知。

为什么需要 ET 模式?
如果你只关心部分就绪文件描述符,但每次调用 epoll_wait 都会返回大量无关的事件,那么,程序会额外增加很多无效的遍历判断。
而 ET 模式下,当被监控的文件描述符上有可读写事件发生时,epoll_wait 才会返回给处理程序去读写。
但,如果当次通知没有把数据全部读写完(eg. 读写缓冲区太小),那么就有可能导致数据丢失。

惊群问题

当多个进程(或多个线程)同时阻塞等待(休眠状态)一个事件的发生,如果某个时间点等待的这个事件发生了,那么它就会唤醒所有等待的进程(线程),但是最终只能有一个进程(线程)获得资源,对事件进行处理,而其他进程(线程)只能重新进入休眠状态,这种现象就叫做惊群(Thundering herd)效应。

惊群效应带来哪些问题?

  • 最终只能有一个线程获得资源,所以理想情况下只需唤醒一个线程,而唤醒多个线程导致了不必要的线程调度,造成系统开销。
  • 其次,为了确保只有一个线程得到资源,需要对资源进行加锁保护。
    eg. Nginx 通过锁机制解决惊群问题。

单线程处理 epoll_wait 是没有惊群问题的,但引入多线程就会面临惊群问题,针对不同的多线程处理I/O场景有不同的解决方式:

  • fd 注册在一个 epfd 上,多进程(可能是通过 fork 产生的,eg. Nginx 的 Worker 进程就是通过 Master 进程 fork 出来的,Master 和 Worker 是父子进程)共享 fd,一起处理 fd 的 I/O 事件。
    就绪事件可能会将父子进程都唤醒,即,出现惊群。
    解决方式:使用 ET 模式,即,fd 的就绪事件只通知一次,这样保证了每次只会唤醒一个进程去处理事件。
  • fd 注册到多个不同的 epfd 上,且每个 epfd 由不同线程负责调用 epoll_wait,这些线程共同处理 fd 的 I/O 事件。
    就绪事件可能会在多个 epfd 上将阻塞在 epoll_wait 的线程都唤醒,于是出现惊群。
    解决方式:epoll_wait 时带上 EPOLLEXCLUSIVE 参数,保证同一个 fd 产生的 I/O 事件只唤醒一个线程来处理;此方式同时解决了第一种场景的惊群问题。

实际中,解决方案远没有上面描述的两种方式来的简单,不过Linux内核(2.6+)基本为我们解决了常见的惊群问题(通过互斥信号量?)。

kqueue

kqueue 是 MacOS 和 FreeBSD 下的实现。

实际应用

epoll 在实际中有着广泛的应用。
我们能从众多开源项目中发现它的身影,eg. Redis、Nginx、Node.js、Swoole 以及 Java 中的 NIO(New I/O)等等。

几个问题

Socket 为什么称 套接字?

知乎上有个「Socket 为什么要翻译成套接字?」的话题,通过下面这句话(原样摘录),大体理解下。

Socket 提供一种供应用程序访问通信协议的操作系统调用,并且通过将 Socket 与 Unix 系统文件描述符相整合,使得网络读写数据(或者服务调用)和读写本地文件一样容易。很显然,Socket 已经离“插座”越来越远了,已经完全不再是硬件上的物件,而是一序列的“指令”,按汉语的理解,已经具备了“套接”(建立网络通讯或进程间通讯)和“字”(可交互的有序指令串)的概念。

异步一定是多线程吗?

应用层面的多线程是模拟实现异步的一种方法,实际上,在软件系统中,异步的真正实现需要底层操作系统的支持。

Linux AIO(libaio)利用了 CPU 和 IO 设备异步工作的特性,与同步 IO 相比,很大程度上减少了 CPU 资源的浪费。
而 POSIX AIO(glibc) 利用了线程与线程之间的异步工作特性,在用户线程中实现 IO 的异步操作。
所以,libaio 是 I/O系统调用层面(内核态)的异步,而 glibc 是进程通信层面(用户态)的异步。

简单讲,多线程(glibc/…)、多路复用(select/poll/epoll)、异步I/O(libaio)是实现异步的常见手段。

为什么说 epoll 是 同步非阻塞的?

这其实是在不同层面上的理解:
非阻塞 - 体现在I/O请求后把 Socket 写到缓冲区,底层硬件实现了DMA机制,才能够注册回调函数,这之后进程不需要阻塞等待。
同步 - epoll 本身要阻塞在那里等待 Socket,也就是同步I/O。

为什么分 监听socket 和 连接socket?

先来看看 Socket 建立连接的过程:

  • 客户端
    socket() - 创建出 active_socket_fd(client_socket_fd)。
    bind() - 把 active_socket_fd 与 ip,port 绑定起来。
    connect() - client_socket_fd 主动请求服务端的 listen_socket_fd。
    read() / write() - 读/写 socket io。
    close() - 关闭 socket_fd。
  • 服务端
    socket() - 创建出 active_socket_fd。
    bind() - 把 active_socket_fd 与 ip,port 绑定起来。
    listen() - active_socket_fd -> listen_socket_fd,等待客户端的 client_socket_fd 来请求连接。
    accept() - listen_socket_fd -> connect_socket_fd,创建 连接socket,用于建立连接后的读写数据。
    read() / write() - 读/写 socket io。
    close() - 关闭 socket_fd。

监听socket(listen_socket_fd)存在于服务端的整个生命周期,不需要为每个连接都创建一个新的监听socket。
连接socket(connect_socket_fd)是客户端与服务端之间已经建立起连接的一个端点,服务端每次接受连接请求都会创建一个新的连接socket,它的生命周期是客户端请求服务端的时间范围。
有了 listen_socket_fd 和 connect_socket_fd,就可以用 listen_socket_fd 负责响应客户端的请求,而每次创建新的 connect_socket_fd 专门负责当次连接的数据读写。
总的来说,是为了分层协作,提高服务端性能。

参考资料

怎样理解阻塞非阻塞与同步异步的区别
User space 与 Kernel space
计算机文件读写原理
《UNIX网络编程卷1:套接字联网API(第3版)》6.2节
深入学习理解 IO 多路复用
如果这篇文章说不清epoll的本质,那就过来掐死我吧
为什么有监听socket和连接socket
epoll原理详解及epoll反应堆模型