[L2] IO 模型:同步/异步与阻塞/非阻塞的区别
一句话结论
同步/异步描述谁来取结果,阻塞/非阻塞描述等待期线程状态,两者正交。
体系讲解
1. 两个正交维度
这两组概念常被混为一谈,其实描述的是 IO 的两个独立维度:
| 维度 | 关注点 | 同 | 异 |
|---|---|---|---|
| 同步 vs 异步 | 结果由谁获取 | 调用方主动等待/轮询取结果 | 内核完成后回调通知,调用方被动接收 |
| 阻塞 vs 非阻塞 | 等待期间线程状态 | 线程挂起,让出 CPU,等 IO 就绪 | 立即返回(未就绪返回错误/空),线程继续执行 |
两者独立,可自由组合,互不依赖。
2. 四种组合与 IO 模型对应
阻塞 非阻塞
┌─────────────────┬─────────────────┐
同步 │ 同步阻塞(BIO) │ 同步非阻塞(NIO)│
│ PHP-FPM │ O_NONBLOCK 轮询 │
├─────────────────┼─────────────────┤
异步 │ 异步阻塞(罕见)│ 异步非阻塞(AIO)│
│ - │ io_uring / AIO │
└─────────────────┴─────────────────┘
注:IO 多路复用(epoll)位于同步行,介于阻塞与非阻塞之间| 组合 | 含义 | 典型实现 | 缺陷 |
|---|---|---|---|
| 同步阻塞(BIO) | 调用后线程挂起,由调用方取结果 | fread()、传统 socket | 一连接一线程,并发高时线程耗尽 |
| 同步非阻塞(NIO) | 调用立即返回,调用方持续轮询 | O_NONBLOCK + 轮询 | CPU 空转,无事件时仍持续消耗 |
| IO 多路复用 | 内核监听多 fd,有事件才唤醒,调用方主动 read | select/poll/epoll | 仍属同步(read 拷贝由调用方发起) |
| 异步非阻塞(AIO) | 注册回调后立即返回,内核完成拷贝后通知 | Linux AIO、io_uring、Node.js | 编程模型复杂,回调地狱问题 |
3. 为什么 IO 多路复用是"同步"的?
同步/异步的分界在于数据从内核缓冲区拷贝到用户空间这一步由谁完成:
- 多路复用:
epoll_wait告知"fd 就绪"后,应用仍需主动调用read()完成拷贝 → 同步 - AIO:内核负责完成拷贝,完成后才通知应用 → 异步
多路复用在"等待就绪事件"阶段是阻塞的,但一次阻塞可监听 N 个 fd,避免了 BIO 的一连接一线程问题。
4. PHP 场景对应
| 模型 | PHP 对应 | 特点 |
|---|---|---|
| BIO | PHP-FPM 默认模型 | 每请求独占一个进程,IO 等待时进程空闲,并发上限 = 进程数 |
| IO 多路复用 | Swoole、ReactPHP | 事件循环 + epoll,单进程并发大量请求,IO 等待时协程切换 |
| AIO | io_uring(PHP 扩展实验性) | 极低内核调用开销,PHP 生态尚不成熟 |
考察意图
考察候选人能否厘清"同步=阻塞"这一最常见认知误区,以及从 BIO → NIO → 多路复用的演进逻辑——理解这一演进是理解 PHP-FPM 吞吐瓶颈与 Swoole 工作原理的前提。
追问链
IO 多路复用为什么比 NIO 轮询高效?
NIO 轮询需应用层持续调用read()检查每个 fd,无事件时 CPU 空转;IO 多路复用将"等谁就绪"的工作交给内核(epoll_wait),内核只在有事件时唤醒应用,应用无需空转。本质是把 O(N) 的用户态轮询替换为内核的事件通知。PHP-FPM 的并发瓶颈根源是什么?
PHP-FPM 是同步阻塞(BIO)+ 多进程模型:每个请求独占一个 worker 进程,进程阻塞等待数据库/Redis 就绪期间无法接收其他请求。并发上限约等于 worker 进程数,IO 密集型场景下大量进程在空等,CPU 利用率低但内存消耗高。Swoole 协程如何在 PHP 中实现非阻塞 IO?
Swoole 底层用 epoll 监听所有 IO 事件,协程遇到网络/数据库操作时挂起(让出控制权),事件循环调度其他协程执行;IO 就绪后恢复协程,整个切换在用户态完成,无系统调用开销。对业务代码而言写法与同步阻塞相同,运行时自动异步化。select、poll、epoll 三者的核心差异是什么?
select:fd 集合大小固定(FD_SETSIZE,通常 1024),每次调用需将集合从用户态拷贝到内核,返回后需遍历全部 fd 找就绪的,O(N) 扫描。poll:解除 fd 数量限制,但仍需 O(N) 遍历。epoll:内核维护就绪列表,只返回就绪的 fd,O(1) 获取事件,适合高并发长连接场景(epoll 原理在 L3 题深入)。
易错点
以为"同步 = 阻塞":同步/异步与阻塞/非阻塞是两个独立维度,"同步非阻塞"(NIO 轮询)完全成立——调用方主动取结果(同步),但取的时候立即返回不挂起(非阻塞)。
以为 IO 多路复用是异步 IO:多路复用(select/epoll)仍属同步——内核只通知"哪个 fd 就绪",数据拷贝(内核→用户空间)仍需调用方主动发起
read();真正的异步 IO(AIO/io_uring)才由内核完成拷贝后再通知,调用方无需手动 read。混淆"阻塞"与"性能差":
epoll_wait()本身是阻塞调用,但一次阻塞可监听成千上万个 fd,整体吞吐远高于 BIO;阻塞不等于低效,关键在于阻塞期间能否复用线程服务其他请求。
代码示例
// ① 同步阻塞(BIO):线程挂起直到数据就绪
$fp = fsockopen('tcp://127.0.0.1', 9000, $errno, $errstr, 5);
stream_set_blocking($fp, true); // 默认即阻塞
$response = fread($fp, 4096); // 线程在此挂起等待
// ② 同步非阻塞(NIO 轮询):立即返回,未就绪时手动重试
stream_set_blocking($fp, false);
$data = '';
while (!feof($fp)) {
$chunk = fread($fp, 4096);
if ($chunk === '' || $chunk === false) {
usleep(1000); // 未就绪,短暂等待后继续轮询(CPU 空转)
continue;
}
$data .= $chunk;
}
// ③ IO 多路复用:stream_select 一次监听多个流,只处理就绪的
$streams = [$fp1, $fp2, $fp3];
$write = $except = null;
$ready = stream_select($streams, $write, $except, seconds: 5);
if ($ready > 0) {
foreach ($streams as $stream) { // $streams 已被 stream_select 过滤为就绪集合
$data = fread($stream, 4096);
echo "收到: {$data}\n";
}
}
fclose($fp);