云计算百科
云计算领域专业知识百科平台

阻塞 I/O、非阻塞 I/O、同步 I/O与异步 I/O

文章目录

  • 阻塞 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 的核心区别

比较维度阻塞 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 的基础。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 阻塞 I/O、非阻塞 I/O、同步 I/O与异步 I/O
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!