文章目录
- 阻塞 I/O、非阻塞 I/O、同步 I/O与异步 I/O:从系统调用到 Linux 内核的完整解析
-
- 一、为什么这四个概念容易混淆?
- 二、一次网络读取到底包含哪些阶段?
-
- 2.1 第一阶段:等待数据进入内核接收缓冲区
- 2.2 第二阶段:将数据从内核空间复制到用户空间
- 三、什么是阻塞 I/O?
-
- 3.1 阻塞 I/O 的定义
- 3.2 阻塞 I/O 的执行过程
- 四、阻塞 I/O 到底阻塞了什么?
-
- 4.1 阻塞的是当前调用线程的执行流
- 4.2 阻塞的不是整个 CPU
- 4.3 阻塞的不是整个进程
- 4.4 阻塞的不是操作系统和网络协议栈
- 4.5 阻塞的不是远端程序
- 4.6 阻塞 I/O 的准确总结
- 五、“阻塞”和“挂起”是否完全相同?
- 六、阻塞线程在等待时是否占用 CPU?
- 七、阻塞 I/O 是否一定会永久阻塞?
- 八、什么是非阻塞 I/O?
-
- 8.1 非阻塞 I/O 的定义
- 8.2 如何将 Socket 设置为非阻塞?
- 8.3 非阻塞读取示例
- 九、非阻塞 I/O 是否意味着一直占用 CPU?
- 十、阻塞 I/O 和非阻塞 I/O 的核心区别
- 十一、阻塞 I/O 的完整服务器示例
- 十二、什么是 I/O 多路复用?
-
- 12.1 `epoll_wait()` 本身可以阻塞
- 12.2 为什么配合 epoll 仍然要设置 O_NONBLOCK?
- 十三、epoll 属于异步 I/O 吗?
- 十四、什么是同步 I/O?
-
- 14.1 同步 I/O 的定义
- 14.2 同步不等于阻塞
- 十五、什么是异步 I/O?
-
- 15.1 异步 I/O 的定义
- 15.2 同步 I/O 与异步 I/O 的核心区别
- 十六、Reactor 与 Proactor
-
- 16.1 Reactor:就绪通知
- 16.2 Proactor:完成通知
- 十七、Linux 中有哪些异步 I/O 技术?
- 十八、四种常见 I/O 模型对比
-
- 18.1 阻塞同步 I/O
- 18.2 非阻塞同步 I/O
- 18.3 I/O 多路复用
- 18.4 异步 I/O
- 十九、两个维度应该怎样组合?
- 二十、为什么高并发服务器通常不用一连接一线程?
- 二十一、为什么非阻塞 I/O 适合事件循环?
- 二十二、非阻塞写操作还要处理“部分写入”
- 二十三、阻塞、非阻塞、同步、异步的最终对照表
- 二十四、常见错误说法修正
-
- 错误一:阻塞 I/O 会占满 CPU
- 错误二:非阻塞 I/O 一定会占满 CPU
- 错误三:epoll 是异步 I/O
- 错误四:epoll 通知可读后,可以放心使用阻塞 recv
- 错误五:阻塞 I/O 阻塞的是 CPU
- 错误六:同步一定阻塞,异步一定非阻塞
- 二十五、用一句话分别记住四个概念
-
- 阻塞 I/O
- 非阻塞 I/O
- 同步 I/O
- 异步 I/O
- 二十六、最终总结
阻塞 I/O、非阻塞 I/O、同步 I/O与异步 I/O:从系统调用到 Linux 内核的完整解析
一、为什么这四个概念容易混淆?
网络编程中经常出现四个术语:
- 阻塞 I/O,Blocking I/O
- 非阻塞 I/O,Non-blocking I/O
- 同步 I/O,Synchronous I/O
- 异步 I/O,Asynchronous I/O
很多人会把它们简单理解为:
阻塞 = 同步
非阻塞 = 异步
这是错误的。
它们描述的是两个不同维度:
阻塞 / 非阻塞
关注:调用线程在等待期间是否停在系统调用中。
同步 / 异步
关注:I/O 操作最终由谁完成,以及应用程序如何获得完成结果。
因此:
阻塞与非阻塞:描述线程等待方式
同步与异步:描述 I/O 完成方式
Linux 中常见的 select、poll、epoll,虽然通常配合非阻塞套接字和事件驱动程序使用,但从严格的 I/O 模型分类来看,它们仍然属于同步 I/O。
二、一次网络读取到底包含哪些阶段?
要理解四个概念,必须先看一次 recv() 或 read() 的完整过程。
假设程序执行:
char buffer[4096];
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
对于 TCP Socket,读取过程大致分为两个阶段。
2.1 第一阶段:等待数据进入内核接收缓冲区
网络数据首先经过:
远程主机
│
▼
物理网络
│
▼
本机网卡
│
▼
网卡驱动
│
▼
Linux 网络协议栈
│
▼
Socket 内核接收缓冲区
此时数据还没有进入用户程序的 buffer。
如果 Socket 接收缓冲区为空,说明当前没有数据可以读取。
2.2 第二阶段:将数据从内核空间复制到用户空间
当内核接收缓冲区中已有数据后,内核还需要执行:
Socket 内核接收缓冲区
│
│ copy_to_user
▼
用户程序 buffer
只有完成这一步,recv() 才能向用户程序返回读取到的字节数。
因此,一次传统 Socket 读取可以抽象为:
阶段一:等待数据准备完成
阶段二:将数据复制到用户空间
这两个阶段是理解同步、异步、阻塞和非阻塞的关键。
三、什么是阻塞 I/O?
3.1 阻塞 I/O 的定义
阻塞 I/O 是指:
当线程执行 I/O 系统调用,而当前无法立即完成该操作时,系统调用不会立刻返回,调用线程会进入等待状态,直到数据到达、连接关闭、发生错误、收到信号或者超时。
例如:
char buffer[1024];
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
如果 fd 是阻塞 Socket,并且接收缓冲区为空,那么 recv() 不会立即返回。
当前线程会停在这一行:
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
// 程序暂时无法继续向下执行
3.2 阻塞 I/O 的执行过程
其流程可以表示为:
用户线程调用 recv()
│
▼
进入内核态
│
▼
检查 Socket 接收缓冲区
│
├── 有数据
│ │
│ ▼
│ 复制到用户空间
│ │
│ ▼
│ recv() 返回
│
└── 没有数据
│
▼
将线程加入等待队列
│
▼
设置为可睡眠状态
│
▼
调度器运行其他线程
│
▼
数据到达后唤醒线程
│
▼
复制数据到用户空间
│
▼
recv() 返回
对应的线程状态变化大致是:
运行态
│
│ 调用 recv(),暂时无数据
▼
睡眠等待态
│
│ 数据到达、信号或超时
▼
可运行态
│
│ 获得 CPU 时间片
▼
运行态
这里的“睡眠”不是用户代码主动调用 sleep(),而是内核在等待条件不满足时暂停当前线程的执行。
四、阻塞 I/O 到底阻塞了什么?
这是最容易产生误解的地方。
4.1 阻塞的是当前调用线程的执行流
阻塞 recv() 会阻止当前线程继续向下执行:
std::cout << "before recv\\n";
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
// 数据没有到达时,下面这行不会执行
std::cout << "after recv\\n";
在 recv() 返回之前,这个线程不能继续执行后面的业务代码。
因此,阻塞 I/O 阻塞的是:
当前调用线程的控制流
4.2 阻塞的不是整个 CPU
当前线程进入睡眠等待后,操作系统调度器可以运行其他线程或其他进程。
例如:
CPU 当前运行线程 A
│
│ A 调用阻塞 recv()
▼
线程 A 进入等待状态
│
▼
调度器选择线程 B
│
▼
CPU 开始运行线程 B
因此:
错误理解:阻塞 I/O 会把 CPU 卡住
正确理解:阻塞 I/O 会让当前线程暂时不再占用 CPU
严格来说,线程进入睡眠和重新被调度会产生一定的调度与上下文切换开销,但在线程睡眠期间,它不会持续执行轮询指令。
4.3 阻塞的不是整个进程
如果一个进程中有多个线程:
进程
├── 线程 A:阻塞在 recv()
├── 线程 B:继续处理任务
├── 线程 C:继续计算
└── 线程 D:继续响应其他连接
线程 A 阻塞,不代表线程 B、C、D 也会阻塞。
除非其他线程需要等待线程 A 持有的互斥锁或其他共享资源。
4.4 阻塞的不是操作系统和网络协议栈
线程阻塞后,以下组件仍然继续工作:
网卡继续接收数据
网卡驱动继续处理数据
内核网络协议栈继续解析 TCP 报文
内核继续维护 Socket 缓冲区
其他进程和线程继续运行
当数据进入 Socket 接收缓冲区后,内核会唤醒等待该 Socket 的线程。
4.5 阻塞的不是远端程序
本地线程阻塞在 recv() 上,并不会让远端程序自动停止运行。
远端仍然可以:
继续发送数据
继续执行计算
关闭连接
发送 FIN 或 RST
4.6 阻塞 I/O 的准确总结
阻塞 I/O:
阻塞当前线程的后续执行
不持续占用 CPU 执行轮询
不阻塞其他可运行线程
不阻塞整个操作系统
不阻塞网卡和内核协议栈
可以记成一句话:
阻塞 I/O 卡住的是当前线程的代码执行,不是整台计算机。
五、“阻塞”和“挂起”是否完全相同?
在很多口语化解释中,会把阻塞线程称为“挂起线程”。
这种说法便于理解,但从操作系统术语上看并不完全严谨。
阻塞 I/O 中的线程通常进入的是:
等待状态
睡眠状态
Blocked / Sleeping / Waiting
而“挂起”有时专门表示:
Suspended
Stopped
被调试器暂停
收到 SIGSTOP
被换出内存
因此,在讨论 Linux I/O 时,更准确的表达是:
当前线程因为等待 I/O 条件而进入睡眠或等待状态。
而不是简单地说线程进入了某种统一的“挂起态”。
六、阻塞线程在等待时是否占用 CPU?
通常不会持续占用 CPU。
当线程因为阻塞 I/O 进入等待状态后,它不会留在 CPU 上执行如下循环:
while (没有数据) {
继续检查;
}
内核会让它离开当前 CPU 的运行状态,由调度器选择其他可运行任务。
概念上的内核逻辑类似:
for (;;) {
if (socket_has_data(sock)) {
break;
}
add_current_to_wait_queue(sock);
set_current_state(SLEEPING);
schedule();
}
真实 Linux 内核实现要复杂得多,并且会随内核版本、文件类型和协议而变化,但核心思想是:
加入等待队列
设置等待状态
让出 CPU
条件满足后被唤醒
七、阻塞 I/O 是否一定会永久阻塞?
不一定。
阻塞系统调用可能因为以下条件返回:
1. 数据到达
2. 对端正常关闭连接
3. 连接发生错误
4. 收到可处理中断信号
5. 设置的超时时间到达
例如,可以使用 SO_RCVTIMEO 设置 Socket 接收超时:
#include <sys/socket.h>
#include <sys/time.h>
timeval timeout{};
timeout.tv_sec = 5;
timeout.tv_usec = 0;
setsockopt(
fd,
SOL_SOCKET,
SO_RCVTIMEO,
&timeout,
sizeof(timeout)
);
如果在规定时间内没有可读取的数据,recv() 通常返回:
–1
并将 errno 设置为:
EAGAIN
或者:
EWOULDBLOCK
不能笼统地认为 Socket 接收超时一定返回 ETIMEDOUT。不同系统调用和不同错误场景可能使用不同错误码。
八、什么是非阻塞 I/O?
8.1 非阻塞 I/O 的定义
非阻塞 I/O 是指:
当系统调用暂时无法立即完成时,不让当前线程在该系统调用中睡眠等待,而是立即返回一个“当前无法完成”的结果。
对于非阻塞 Socket,如果接收缓冲区为空:
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
会立即返回:
n == –1
并设置:
errno == EAGAIN
或者:
errno == EWOULDBLOCK
这表示:
当前没有数据,不代表连接发生了错误,请稍后再试。
8.2 如何将 Socket 设置为非阻塞?
可以使用 fcntl():
#include <fcntl.h>
#include <stdexcept>
void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == –1) {
throw std::runtime_error("fcntl(F_GETFL) failed");
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == –1) {
throw std::runtime_error("fcntl(F_SETFL) failed");
}
}
注意不能直接写成:
fcntl(fd, F_SETFL, O_NONBLOCK);
因为这样可能覆盖文件描述符上原有的其他状态标志。
正确方式是:
原有标志 | O_NONBLOCK
8.3 非阻塞读取示例
#include <cerrno>
#include <cstring>
#include <iostream>
#include <sys/socket.h>
void try_read(int fd) {
char buffer[4096];
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
if (n > 0) {
std::cout << "读取到 " << n << " 字节\\n";
return;
}
if (n == 0) {
std::cout << "对端正常关闭连接\\n";
return;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
std::cout << "当前没有数据,稍后再尝试\\n";
return;
}
if (errno == EINTR) {
std::cout << "系统调用被信号中断\\n";
return;
}
std::cerr << "recv failed: "
<< std::strerror(errno)
<< '\\n';
}
非阻塞 recv() 不会因为“当前没有数据”而让线程睡眠在这个调用中。
九、非阻塞 I/O 是否意味着一直占用 CPU?
不意味着。
非阻塞只是说:
当前这一次 I/O 系统调用无法立即完成时,会立即返回。
它没有规定调用者接下来必须怎样做。
调用者可以:
1. 立即再次调用,形成忙轮询
2. 先处理其他任务,稍后重试
3. 使用 select、poll、epoll 等待就绪事件
4. 使用定时器稍后重试
5. 将控制权交还事件循环
因此,下面这种写法会忙等:
while (true) {
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
if (n > 0) {
process(buffer, n);
} else if (n < 0 &&
(errno == EAGAIN || errno == EWOULDBLOCK)) {
// 什么都不做,立刻继续循环
continue;
}
}
其行为是:
recv()
│
├── EAGAIN
▼
recv()
│
├── EAGAIN
▼
recv()
│
├── EAGAIN
▼
持续占用 CPU
但问题不在于“非阻塞 I/O 必然浪费 CPU”,而在于程序采用了错误的高频轮询策略。
正确的高并发做法通常是:
非阻塞 Socket
+
I/O 多路复用
+
事件循环
十、阻塞 I/O 和非阻塞 I/O 的核心区别
| 当前没有数据 | 系统调用等待 | 系统调用立即返回 |
| 当前线程 | 进入等待状态 | 可以继续执行其他代码 |
| 返回结果 | 数据、关闭、错误、信号或超时 | 通常返回 -1 和 EAGAIN |
| 等待期间 CPU | 当前线程通常不占用 CPU | 由程序设计决定 |
| 是否可能忙等 | 通常不会 | 错误轮询时可能发生 |
| 编程复杂度 | 较低 | 较高 |
| 典型模型 | 一连接一线程 | 事件循环 |
| 高并发扩展性 | 受线程数量限制 | 通常更好 |
十一、阻塞 I/O 的完整服务器示例
下面是一个最简单的阻塞式 TCP 服务端。
#include <arpa/inet.h>
#include <cerrno>
#include <cstring>
#include <iostream>
#include <sys/socket.h>
#include <unistd.h>
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == –1) {
std::cerr << "socket failed: "
<< std::strerror(errno)
<< '\\n';
return 1;
}
int reuse = 1;
if (setsockopt(
listen_fd,
SOL_SOCKET,
SO_REUSEADDR,
&reuse,
sizeof(reuse)) == –1) {
std::cerr << "setsockopt failed: "
<< std::strerror(errno)
<< '\\n';
close(listen_fd);
return 1;
}
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(
listen_fd,
reinterpret_cast<sockaddr*>(&server_addr),
sizeof(server_addr)) == –1) {
std::cerr << "bind failed: "
<< std::strerror(errno)
<< '\\n';
close(listen_fd);
return 1;
}
if (listen(listen_fd, SOMAXCONN) == –1) {
std::cerr << "listen failed: "
<< std::strerror(errno)
<< '\\n';
close(listen_fd);
return 1;
}
std::cout << "server listening on 0.0.0.0:8888\\n";
sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(
listen_fd,
reinterpret_cast<sockaddr*>(&client_addr),
&client_len
);
if (client_fd == –1) {
std::cerr << "accept failed: "
<< std::strerror(errno)
<< '\\n';
close(listen_fd);
return 1;
}
char buffer[4096];
while (true) {
// client_fd 默认是阻塞 Socket。
// 当前没有数据时,当前线程停在 recv() 中等待。
ssize_t n = recv(
client_fd,
buffer,
sizeof(buffer),
0
);
if (n > 0) {
std::cout << "received " << n << " bytes\\n";
ssize_t sent = send(client_fd, buffer, n, 0);
if (sent == –1) {
std::cerr << "send failed: "
<< std::strerror(errno)
<< '\\n';
break;
}
continue;
}
if (n == 0) {
std::cout << "client closed connection\\n";
break;
}
if (errno == EINTR) {
continue;
}
std::cerr << "recv failed: "
<< std::strerror(errno)
<< '\\n';
break;
}
close(client_fd);
close(listen_fd);
return 0;
}
这个服务器只能在当前线程中处理一个已连接客户端。
当线程阻塞在某个客户端的 recv() 上时,它无法在同一线程中处理其他连接。
解决方式可以是:
方式一:一个连接对应一个进程
方式二:一个连接对应一个线程
方式三:线程池
方式四:非阻塞 I/O + I/O 多路复用
方式五:真正的异步 I/O
十二、什么是 I/O 多路复用?
I/O 多路复用的核心目标是:
使用一个线程等待多个文件描述符,只在某些文件描述符已经就绪时,再对它们执行具体的 I/O 操作。
Linux 中常见的 I/O 多路复用接口包括:
select
poll
epoll
以 epoll 为例:
连接 1 ─┐
连接 2 ─┤
连接 3 ─┤
连接 4 ─┤
… ├── epoll ── 通知应用程序哪些 fd 已就绪
连接 N ─┘
应用线程不再对一万个 Socket 逐个调用 recv() 询问。
而是调用:
epoll_wait(...)
让内核返回已经就绪的文件描述符。
12.1 epoll_wait() 本身可以阻塞
一个常见误区是:
非阻塞 I/O + epoll
所以整个程序中所有调用都不会阻塞。
实际上,epoll_wait() 通常就是一个阻塞等待调用:
int ready_count = epoll_wait(
epoll_fd,
events,
max_events,
–1
);
最后一个参数为 -1 表示:
如果当前没有就绪事件,就让调用线程等待。
因此,一个典型事件循环是:
epoll_wait() 阻塞等待事件
│
│ 某些 fd 就绪
▼
epoll_wait() 返回
│
▼
对就绪 fd 执行非阻塞 recv()
│
▼
处理完毕
│
▼
再次进入 epoll_wait()
这种模型并不是“线程永远不阻塞”,而是:
线程集中阻塞在一个能够同时等待大量连接的 epoll_wait() 上,而不是阻塞在某一个具体客户端的 recv() 上。
12.2 为什么配合 epoll 仍然要设置 O_NONBLOCK?
有人可能认为:
epoll 已经通知这个 fd 可读,直接使用阻塞 recv() 不就可以了吗?
这样并不稳妥。
因为“收到可读通知”和“之后执行 recv() 时一定不会等待”并不是完全等价的。
中间可能发生:
其他线程先读取了数据
事件状态发生变化
错误处理或竞态条件
一次读取没有取完完整业务消息
ET 模式下需要持续读取
因此,生产环境通常使用:
epoll + O_NONBLOCK
读取时循环到 EAGAIN:
while (true) {
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
if (n > 0) {
process_data(buffer, n);
continue;
}
if (n == 0) {
close_connection(fd);
break;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 当前可读取数据已经取完
break;
}
close_connection(fd);
break;
}
特别是在 epoll ET,也就是边缘触发模式下,通常必须一直读取到 EAGAIN。
十三、epoll 属于异步 I/O 吗?
严格来说,不属于。
epoll 是:
I/O 就绪通知机制
I/O 多路复用机制
Reactor 模型的重要基础
它通知应用程序:
某个文件描述符现在可能可以读取或写入了。
但真正的数据读取仍然需要应用程序主动调用:
recv()
read()
也就是说:
epoll 只通知“可以做”
应用程序仍然需要亲自“做”
其流程是:
内核通知:fd 可读
│
▼
应用程序调用 recv()
│
▼
内核把数据复制到用户空间
│
▼
recv() 返回
因此,epoll 通常被归类为:
同步 I/O 多路复用
而不是严格意义上的异步 I/O。
十四、什么是同步 I/O?
14.1 同步 I/O 的定义
同步 I/O 是指:
应用程序需要主动执行 I/O 系统调用,并且 I/O 操作的关键完成过程发生在该调用路径中;操作完成后,系统调用才把结果交给应用程序。
传统的 read()、recv() 都属于同步 I/O。
无论 Socket 是阻塞还是非阻塞,真正读取数据时,都必须由应用程序执行:
recv(fd, buffer, size, 0);
内核在这个调用过程中,将数据复制到用户缓冲区。
因此:
阻塞 recv():同步 I/O
非阻塞 recv():仍然是同步 I/O
epoll + recv():仍然是同步 I/O
14.2 同步不等于阻塞
同步 I/O 可以是阻塞的:
recv(blocking_fd, buffer, size, 0);
没有数据时,线程等待。
同步 I/O也可以是非阻塞的:
recv(nonblocking_fd, buffer, size, 0);
没有数据时,立即返回 EAGAIN。
两者的共同点是:
最终的数据读取仍然由应用程序主动调用 recv() 完成。
十五、什么是异步 I/O?
15.1 异步 I/O 的定义
异步 I/O 是指:
应用程序提交一个 I/O 请求后立即继续执行其他任务,内核或 I/O 系统在后台完成整个 I/O 操作;操作完成后,再通过完成事件、回调、信号或完成队列通知应用程序。
关键区别在于:
同步 I/O:
应用程序等到“可以读取”后,自己调用 read/recv 完成读取。
异步 I/O:
应用程序先提交“请把数据读到这个缓冲区”的请求,
内核完成读取后再通知应用程序。
异步读取可以抽象为:
应用程序提交异步读取请求
│
▼
提交函数返回
│
├─────────── 应用程序继续处理其他任务
│
▼
内核等待数据到达
│
▼
内核将数据放入指定缓冲区
│
▼
内核生成完成通知
│
▼
应用程序处理完成结果
15.2 同步 I/O 与异步 I/O 的核心区别
同步非阻塞模型:
应用程序:数据准备好了吗?
内核:还没有,返回 EAGAIN。
应用程序:数据准备好了吗?
内核:准备好了。
应用程序:那我调用 recv() 读取。
异步模型:
应用程序:请把数据读到这个缓冲区,完成后通知我。
内核:请求已接收。
应用程序:继续处理其他任务。
内核:数据读取完成,这是结果。
十六、Reactor 与 Proactor
同步事件驱动与异步 I/O还可以分别对应两个经典模型。
16.1 Reactor:就绪通知
epoll 通常用于实现 Reactor。
内核通知应用程序:这个 fd 已经可读
应用程序自己执行 recv()
应用程序自己处理数据
流程:
事件就绪
│
▼
Reactor 分发事件
│
▼
用户代码调用 recv()
│
▼
处理读取结果
核心是:
内核通知“现在可以做了”,应用程序负责完成具体操作。
16.2 Proactor:完成通知
真正的异步 I/O 更接近 Proactor。
应用程序提交读取请求
内核完成实际读取
内核通知应用程序:读取已经完成
应用程序直接处理结果
流程:
提交异步操作
│
▼
内核执行 I/O
│
▼
I/O 完成
│
▼
Proactor 分发完成事件
│
▼
用户代码处理结果
核心是:
内核通知“操作已经做完了”,应用程序只处理完成结果。
十七、Linux 中有哪些异步 I/O 技术?
Linux 中常见的相关机制包括:
POSIX AIO
Linux Native AIO
io_uring
其中 io_uring 提供了基于提交队列和完成队列的接口:
用户空间提交队列 SQ
│
▼
内核处理 I/O 请求
│
▼
用户空间完成队列 CQ
其基本思想是:
1. 应用程序构造 I/O 请求
2. 将请求放入提交队列
3. 内核处理请求
4. 完成后写入完成队列
5. 应用程序读取完成结果
不过需要注意:
使用 io_uring 并不自动意味着所有操作在所有情况下都完全异步。
具体行为还与操作类型、文件类型、内核版本、配置方式及是否需要工作线程回退有关。
但从编程模型上看,它比 epoll + read() 更接近完成驱动的异步 I/O。
十八、四种常见 I/O 模型对比
18.1 阻塞同步 I/O
调用 recv()
│
▼
等待数据
│
▼
复制数据
│
▼
recv() 返回
特点:
线程等待
调用简单
一个线程同一时刻通常只能等待一个主要操作
示例:
recv(blocking_fd, buffer, size, 0);
18.2 非阻塞同步 I/O
调用 recv()
│
├── 没数据:立即返回 EAGAIN
│
└── 有数据:复制并返回
特点:
系统调用不因暂无数据而睡眠
需要程序决定何时重试
错误设计容易形成忙轮询
示例:
recv(nonblocking_fd, buffer, size, 0);
18.3 I/O 多路复用
epoll_wait()
│
▼
等待多个 fd
│
▼
返回就绪 fd
│
▼
应用程序调用 recv()
特点:
一个线程等待大量连接
通常配合非阻塞 Socket
仍然由应用程序主动完成读取
属于同步 I/O
18.4 异步 I/O
提交读取请求
│
▼
应用程序继续运行
│
▼
内核完成读取
│
▼
通知应用程序读取完成
特点:
完成驱动
内核或 I/O 系统完成整个操作
应用程序处理完成结果
实现与状态管理通常更加复杂
十九、两个维度应该怎样组合?
可以用下面的坐标理解:
I/O 完成方式
同步 异步
┌────────────────┬────────────────┐
阻塞等待 │ 阻塞 recv │ 提交异步请求后 │
│ │ 主动等待完成 │
├────────────────┼────────────────┤
非阻塞等待 │ O_NONBLOCK │ 提交请求后继续 │
│ epoll + recv │ 处理其他任务 │
└────────────────┴────────────────┘
实际开发中最常见的是:
阻塞同步 I/O
非阻塞同步 I/O
I/O 多路复用
异步完成 I/O
“异步接口提交后立刻等待完成”虽然技术上可以做到,但会失去大部分异步编程的价值。
二十、为什么高并发服务器通常不用一连接一线程?
阻塞 I/O 本身并不低效。
对于少量连接,阻塞模型往往具有明显优势:
代码简单
容易调试
业务流程直观
线程睡眠期间不忙等
问题主要出现在连接数量非常大时。
如果一万个连接都使用独立线程:
10000 个连接
│
▼
10000 个线程
│
├── 线程栈地址空间
├── 内核线程管理结构
├── 调度开销
├── 上下文切换
└── 缓存局部性下降
常见 Linux 环境中,线程栈上限可能显示为数 MB,例如 8 MB,但这通常主要是虚拟地址空间和最大栈范围,并不表示创建线程后会立刻为每个线程提交同等数量的物理内存。
因此,不能简单计算:
10000 × 8 MB = 80 GB 物理内存立即被占用
更准确的说法是:
大量线程会消耗虚拟地址空间、实际使用的栈内存、内核线程资源,并显著增加调度和上下文切换压力。
现代系统并不是创建一万个线程就一定立即崩溃,但线程数量越多,系统的可扩展性和尾延迟通常越难控制。
二十一、为什么非阻塞 I/O 适合事件循环?
非阻塞 I/O 的主要价值并不是“读取更快”,而是:
某一个连接暂时没有数据时,不会把整个事件循环困在这个连接上。
例如,一个线程处理三个连接:
连接 A:没有数据
连接 B:有数据
连接 C:有数据
如果线程先对阻塞连接 A 调用 recv():
线程阻塞在 A
B 和 C 即使已有数据,也无法及时处理
如果三个连接都是非阻塞的:
检查 A:EAGAIN,跳过
处理 B:读取数据
处理 C:读取数据
再配合 epoll,就不需要逐个无效检查:
epoll_wait() 返回 B、C
只处理 B、C
不检查暂时没有事件的 A
这就是事件驱动高并发服务器的基本思想。
二十二、非阻塞写操作还要处理“部分写入”
非阻塞 I/O 不只影响读取,也影响写入。
假设执行:
send(fd, data, 10000, 0);
即使当前 Socket 可写,也不保证一次调用一定写入全部 10000 字节。
可能返回:
10000:全部写入内核发送缓冲区
3000:只写入一部分
-1 + EAGAIN:当前发送缓冲区没有足够空间
因此必须维护发送状态:
std::size_t offset = 0;
while (offset < data_size) {
ssize_t n = send(
fd,
data + offset,
data_size – offset,
0
);
if (n > 0) {
offset += static_cast<std::size_t>(n);
continue;
}
if (n == –1 && errno == EINTR) {
continue;
}
if (n == –1 &&
(errno == EAGAIN || errno == EWOULDBLOCK)) {
// 保存 offset,等待下一次 EPOLLOUT
break;
}
// 其他错误,关闭连接
break;
}
因此,非阻塞服务器通常需要为每个连接维护:
接收缓冲区
发送缓冲区
已发送偏移量
协议解析状态
连接生命周期状态
超时状态
这也是非阻塞事件驱动程序比阻塞程序复杂的主要原因。
二十三、阻塞、非阻塞、同步、异步的最终对照表
| 阻塞 | 调用不能立即完成时,线程是否等待 | 线程停在系统调用中 |
| 非阻塞 | 调用不能立即完成时,线程是否立即得到结果 | 返回 EAGAIN |
| 同步 | 应用程序是否需要主动参与并完成 I/O 调用 | 应用调用 recv() 获取数据 |
| 异步 | 提交请求后,是否由内核完成并通知结果 | 完成队列、回调或完成事件 |
| I/O 多路复用 | 如何用少量线程等待多个 fd | select、poll、epoll |
| Reactor | 通知操作已经就绪 | 应用程序执行实际 I/O |
| Proactor | 通知操作已经完成 | 应用程序处理完成结果 |
二十四、常见错误说法修正
错误一:阻塞 I/O 会占满 CPU
错误。
阻塞线程在等待期间通常会进入睡眠状态,不会持续执行轮询指令。
准确说法:
阻塞 I/O 会暂停当前线程的执行,但通常不会让这个线程在等待期间持续占用 CPU。
错误二:非阻塞 I/O 一定会占满 CPU
错误。
只有程序不断立即重试时才会形成忙轮询。
准确说法:
非阻塞 I/O 允许调用立即返回,是否忙轮询取决于程序如何安排下一次重试。
错误三:epoll 是异步 I/O
严格来说错误。
准确说法:
epoll 是 I/O 多路复用和就绪通知机制,通常配合同步非阻塞 I/O 使用。
错误四:epoll 通知可读后,可以放心使用阻塞 recv
不推荐。
准确说法:
为避免竞态、事件状态变化以及 ET 模式读取不完整等问题,epoll 管理的 Socket 通常应设置为非阻塞。
错误五:阻塞 I/O 阻塞的是 CPU
错误。
准确说法:
阻塞的是当前线程的执行流,CPU 可以继续运行其他可运行任务。
错误六:同步一定阻塞,异步一定非阻塞
错误。
准确说法:
阻塞与非阻塞描述等待方式,同步与异步描述 I/O 完成方式,两者不是同一个维度。
二十五、用一句话分别记住四个概念
阻塞 I/O
暂时做不了,当前线程就在系统调用里等待。
非阻塞 I/O
暂时做不了,系统调用立即告诉当前线程稍后再试。
同步 I/O
数据最终需要应用程序主动调用 I/O 接口来取得或完成处理。
异步 I/O
应用程序先提交请求,内核完成整个操作后再通知应用程序。
二十六、最终总结
将整个知识体系压缩成一张图:
网络 I/O
│
┌───────────┴───────────┐
│ │
等待方式维度 完成方式维度
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ │ │ │
阻塞 非阻塞 同步 异步
│ │ │ │
线程等待 立即返回 EAGAIN 应用主动 I/O 内核完成后通知
Linux 高并发服务器中最常见的组合是:
非阻塞 Socket
+
epoll 就绪通知
+
同步 read/recv
+
事件循环
其本质并不是“所有地方都不阻塞”,而是:
集中在 epoll_wait() 上等待多个连接
而不阻塞在任何一个具体客户端的 recv() 上
最后记住三个核心结论:
第一,阻塞 I/O 阻塞的是当前线程,不是 CPU。
第二,非阻塞 I/O 不等于忙轮询,忙轮询只是错误使用方式。
第三,epoll 是同步 I/O 多路复用,不是真正的异步 I/O。
这套区分可以直接作为后续学习 select/poll/epoll、LT/ET、Reactor、Proactor 和 io_uring 的基础。
网硕互联帮助中心


评论前必须登录!
注册