Skip to content

[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,有事件才唤醒,调用方主动 readselect/poll/epoll仍属同步(read 拷贝由调用方发起)
异步非阻塞(AIO)注册回调后立即返回,内核完成拷贝后通知Linux AIO、io_uring、Node.js编程模型复杂,回调地狱问题

3. 为什么 IO 多路复用是"同步"的?

同步/异步的分界在于数据从内核缓冲区拷贝到用户空间这一步由谁完成

  • 多路复用epoll_wait 告知"fd 就绪"后,应用仍需主动调用 read() 完成拷贝 → 同步
  • AIO:内核负责完成拷贝,完成后才通知应用 → 异步

多路复用在"等待就绪事件"阶段是阻塞的,但一次阻塞可监听 N 个 fd,避免了 BIO 的一连接一线程问题。

4. PHP 场景对应

模型PHP 对应特点
BIOPHP-FPM 默认模型每请求独占一个进程,IO 等待时进程空闲,并发上限 = 进程数
IO 多路复用Swoole、ReactPHP事件循环 + epoll,单进程并发大量请求,IO 等待时协程切换
AIOio_uring(PHP 扩展实验性)极低内核调用开销,PHP 生态尚不成熟

考察意图

考察候选人能否厘清"同步=阻塞"这一最常见认知误区,以及从 BIO → NIO → 多路复用的演进逻辑——理解这一演进是理解 PHP-FPM 吞吐瓶颈与 Swoole 工作原理的前提。

追问链

  1. IO 多路复用为什么比 NIO 轮询高效?
    NIO 轮询需应用层持续调用 read() 检查每个 fd,无事件时 CPU 空转;IO 多路复用将"等谁就绪"的工作交给内核(epoll_wait),内核只在有事件时唤醒应用,应用无需空转。本质是把 O(N) 的用户态轮询替换为内核的事件通知。

  2. PHP-FPM 的并发瓶颈根源是什么?
    PHP-FPM 是同步阻塞(BIO)+ 多进程模型:每个请求独占一个 worker 进程,进程阻塞等待数据库/Redis 就绪期间无法接收其他请求。并发上限约等于 worker 进程数,IO 密集型场景下大量进程在空等,CPU 利用率低但内存消耗高。

  3. Swoole 协程如何在 PHP 中实现非阻塞 IO?
    Swoole 底层用 epoll 监听所有 IO 事件,协程遇到网络/数据库操作时挂起(让出控制权),事件循环调度其他协程执行;IO 就绪后恢复协程,整个切换在用户态完成,无系统调用开销。对业务代码而言写法与同步阻塞相同,运行时自动异步化。

  4. select、poll、epoll 三者的核心差异是什么?
    select:fd 集合大小固定(FD_SETSIZE,通常 1024),每次调用需将集合从用户态拷贝到内核,返回后需遍历全部 fd 找就绪的,O(N) 扫描。poll:解除 fd 数量限制,但仍需 O(N) 遍历。epoll:内核维护就绪列表,只返回就绪的 fd,O(1) 获取事件,适合高并发长连接场景(epoll 原理在 L3 题深入)。

易错点

  1. 以为"同步 = 阻塞":同步/异步与阻塞/非阻塞是两个独立维度,"同步非阻塞"(NIO 轮询)完全成立——调用方主动取结果(同步),但取的时候立即返回不挂起(非阻塞)。

  2. 以为 IO 多路复用是异步 IO:多路复用(select/epoll)仍属同步——内核只通知"哪个 fd 就绪",数据拷贝(内核→用户空间)仍需调用方主动发起 read();真正的异步 IO(AIO/io_uring)才由内核完成拷贝后再通知,调用方无需手动 read。

  3. 混淆"阻塞"与"性能差"epoll_wait() 本身是阻塞调用,但一次阻塞可监听成千上万个 fd,整体吞吐远高于 BIO;阻塞不等于低效,关键在于阻塞期间能否复用线程服务其他请求。

代码示例

php
// ① 同步阻塞(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);

基于 Apache License 2.0 开源