如果你是第二次写并发的服务端程序,多半已经隐约感觉到一件事:程序绝大多数时间不是在"算",而是在"等"——等网卡数据到,等对端发消息,等磁盘把块读出来。在网络编程里,等待往往占据了九成以上的时间。能不能把这段"干等"的时间省下来,直接决定了一个服务能不能扛住成千上万的并发连接。

这一篇我们就把 Linux 网络编程里那个绕不开的"IO 模型"讲透。它是后面理解 select/poll/epoll 的地基,也是面试的高频考点。我们先把一个朴素的问题问到底:一次读操作,程序到底在等什么?

别小看这个问题。把一个 IO 拆成两段时间去看,五种 IO 模型的本质差异就全出来了。

一次 IO 要走的两步:等待数据,再拷贝数据

我们在课堂上反复强调过一句"金句":任何 IO 过程中,都包含两个步骤。第一步是等待,第二步是拷贝。 这句话是所有 IO 模型的脊椎骨,务必把它刻进脑子里。

拿"往 socket 上读数据"举例。你调用 read(fd, buf, n) 之后,这一趟"读"可拆成两个泾渭分明的阶段:

  1. 阶段一:等待数据就绪。 内核先把网卡上到的数据收到自己的内核缓冲区(这一小段由内核协议栈处理,等数据经过网络到达网卡、再被压进内核的接收缓冲区)。所谓数据就绪(IO ready),对读取而言就是"内核缓冲区里已经有至少一个字节可以拷给你";对写入而言就是"内核缓冲区里有足够的空间可以收下你要写的数据"。
  2. 阶段二:拷贝数据。 内核把数据从内核态缓冲区拷到用户态缓冲区,也就是拷到你程序里那个 buf。写反方向(write)就是让内核把用户缓冲区的数据拷进内核发送缓冲区。

画成图就是:

应用进程调用 read
     │
     ▼
┌───────────────────────────────────────────────┐
│  阶段一:等待数据就绪                            │
│  数据还没到,内核把网络包收进内核缓冲区           │
│  这段耗时变化极大:毫秒级 ~ 数秒,甚至更久        │
└───────────────────────────────────────────────┘
     │ 数据就绪
     ▼
┌───────────────────────────────────────────────┐
│  阶段二:拷贝数据                                │
│  把数据从内核缓冲区拷进用户缓冲区 (buf)          │
│  这段耗时通常很稳定:微秒级,内存/总线拷贝         │
└───────────────────────────────────────────────┘
     │ 拷贝完成
     ▼
 read 返回,应用处理数据

为什么非要强调这两个阶段?因为一个反直觉的结论:在实际场景里,等待消耗的时间往往远远高于拷贝的时间。 拷贝只是把数据从一处内存搬到另一处,用代名词说就是"微秒级的事";而等待是要让一个慢吞吞的外部世界把数据送到你面前,动不动就是毫秒到秒的级别,一个数量级都不止的差距。

于是思路就清晰了:让 IO 更高效,最核心的办法就是让等待的时间尽量少。 五种 IO 模型,本质上就是五种"应对阶段一那个等待"的不同策略,有的你替它等,有的让它通知你,有的干脆全局一起等。至于阶段二的拷贝,除了异步 IO,其它模型里都得你自己动手、自己等。这个分工,正是接下来分类的钥匙。

五种 IO 模型

理论界(主要是 W. Richard Stevens 那本《UNIX 网络编程》,缩写 UNP)总结了五种经典的 IO 模型。你只需要把它们当成五种"等待数据 + 拷贝数据"的排列组合,就不需要死记硬背。

先给一个钓鱼的例子做铺垫。把"往河里钓一尾鱼"当成一次读操作:鱼咬钩 = 数据就绪,把鱼拉上岸收进鱼篓 = 拷贝数据。五种拿鱼的方式,就是五种模型。

阻塞 IO

阻塞 IO(Blocking IO) 是默认、也最常见的模型。Linux 里所有套接字,默认都是阻塞方式。它的行为是:在内核把数据准备好之前,read 这个系统调用会一直等着,连人带线程一起挂起;直到数据就绪、拷贝完成,调用才返回。

用钓鱼的话说:你搬张小板凳坐在河边,鱼不咬钩就一直坐着,啥也不干,目不转睛,直到鱼上钩、把它收进鱼篓,你才起身走人。等待的那段时间,你的进程是彻底"死等"的——它挂在那个 read 上,CPU 上是找不到它的(它在睡眠队列里),但它也干不了任何别的事。

阻塞IO

 应用                                       内核
  │ read(fd, buf, n)                          │
  │ ────────────────────────────────────────> │
  │                                           │ 数据未就绪……
  │ 挂起, 死等(不占 CPU, 但啥也干不了)          │
  │                                           │ 数据到达
  │                                           │ 拷贝到用户缓冲
  │ <──────────────────────────────────────── │
  │ read 返回, 继续                           │

一个连接配一个线程(或进程),各自阻塞在自己的 read 上,是阻塞模型最直观的写法。它的优点是简单、好懂、不易出错;缺点是并发能力极差——要同时服务一万个连接,就得开一万个线程,线程本身的创建、销毁、切换和内存开销能把机器拖垮。这也就是为什么"一连接一线程"的经典模型最多只能撑到一两百个连接就吃力了。

非阻塞 IO

非阻塞 IO(Non-blocking IO) 的名字其实说反了重点:它化解的不是"拷贝"阻塞,而是"等待"阻塞。把文件描述符设置成非阻塞后,如果内核还没有把数据准备好,read 依然会立刻返回,不挂起你的线程,但会带一个 EWOULDBLOCK 错误码骗你"这次没拿到数据、你过会儿再来"。 小写为 EAGAIN 的地方在 Linux 上 EWOULDBLOCK 与 EAGAIN 是同一个值,两个名字指同一个宏。

拿钓鱼说:你不搬凳子了,改成一锅鱼汤一边炖一边每隔一会儿跑到河边掀开水面"看一眼"鱼咬钩没——没咬就转身接着炖汤,过会儿再来。这个"每隔一会儿来看一次"的动作,就叫轮询(polling)。

非阻塞IO

 应用                                      内核
  │ read(fd, buf, n)                         │
  │ ───────────────────────────────────────> │
  │ <──── 没数据, 返回 -1, errno=EWOULDBLOCK │
  │ 处理别的事/睡一觉……                       │
  │ read(fd, buf, n)                         │
  │ ───────────────────────────────────────> │
  │                                          │ 数据到达
  │ <─── 有数据, 拷贝完返回 n                 │
  │ 处理数据                                 │

注意,非阻塞只解决了阶段一的"等待"。一旦你轮询到数据就绪,真正去 read 拷贝数据的那趟调用,仍然是阻塞式地把数据拷完才返回。所以严谨地讲,非阻塞 IO 的拷贝阶段依旧是阻塞的,这一点稍后讲同步/异步时会非常关键。

非阻塞 IO 的致命缺点是轮询会空耗 CPU:如果数据迟迟不到,你的 while 循环就会像疯子一样一次次发起 read 系统调用、一次次被 EWOULDBLOCK 打回,CPU 转速拉满却一事无成。正因为如此,纯粹的、自己死命轮询的非阻塞 IO 一般只在少数特定场景才用,它的真正价值往往体现在和 IO 多路转接配合使用(后面会说明为什么)。你要记的一句话是:"非阻塞"不等于"高效",它只是"不挂起你的线程",代价是你要自己一遍遍去确认。

IO 多路转接

IO 多路转接(I/O Multiplexing,也叫 IO 多路复用) 是我们这五种里的重头戏,也是生产环境高并发服务的基石,代表是 select/poll/epoll。

从流程图上乍一看,多路转接很像阻塞 IO——你还是会阻塞在一个函数上等待。但二者有本质区别:阻塞 IO 是在"等一个文件描述符"上阻塞;而多路转接是站在 select/poll/epoll 这个"总哨所"上,同时等待一大片文件描述符的就绪状态,哪个就绪了"总哨所"就把哪个报给你,你再对它就绪的那个去 read。

用钓鱼说:你升级成了养鱼场的总管,一个人同时巡视几十上百个鱼塘,哪个塘里的鱼咬钩了(冒泡了),你就专门去"那一个"塘收鱼。你确实还是一个"等待的人",但你等的对象从"一个"变成了"一整片"。

IO多路转接

 应用                                       内核
  │ select/epoll_wait(fds...)                │
  │ ───────────────────────────────────────> │
  │ 挂起, 同时盯一批 fd                        │
  │                                          │ 其中有 fd 就绪
  │ <──── 返回"哪些fd就绪"                     │
  │ read(fd_1, buf, n) ────> 拷贝 ────> 完     │
  │ read(fd_n, buf, n) ────> 拷贝 ────> 完     │

一个进程,只阻塞在"一个总征求点"上,却能服务成千上万个连接——这就是多路转接"能扛高并发"的根本原因。至于它到底"快"在哪儿、和阻塞 IO 在单连接场景下谁更快,这恰恰是很多人讲不清的坑,我在后面专门开一节讲清楚,这里先记住它的定位。

信号驱动 IO

信号驱动 IO(Signal-driven I/O) 的思路和远程提醒类似:内核把数据准备好的那一刻,会向你的进程发送一个 SIGIO 信号来通知你"可以去读数据了"。 收到信号之前,你的进程爱干嘛干嘛,完全不用挂起等待。

用钓鱼说:你请了只精明的翠鸟帮你盯梢,让它站在岸边守着,鱼一咬钩它就"叭"地叫一声;你在这边睡大觉,听到叫声才起身去收鱼。

信号驱动IO

 应用                                      内核
  │ 提前通过 fcntl(F_SETOWN) 等设置         │
  │ 注册 SIGIO 处理                          │
  │ (等待期间完全自由, 不阻塞)                │
  │                                          │ 数据到达
  │ <── 发送 SIGIO 信号通知就绪               │
  │ 在信号处理函数里 read(fd,buf,n) 拷贝       │

信号驱动的意义在于:它把"何时可以开始拷贝数据"这件事,从"等待方"转移给了"内核主动通知"。它止步于"通知你就绪",真正把数据拷到你的缓冲区这件事,还是要你自己在收到信号后去做(这一步仍然同步阻塞)。这一点,正是它和下一个模型——异步 IO——的分水岭,课堂上那句"信号驱动是告诉应用程序何时可以开始拷贝数据",说的就是这层意思。

异步 IO

异步 IO(Asynchronous I/O) 是五兄弟里最"彻底"的一个,也是唯一一个真正不用你管阶段一和阶段二全部过程的:进程调用异步 IO(如 POSIX 的 aio_read,Linux 上还有 io_uring)后立刻返回,剩下的"等待数据就绪"和"把数据从内核拷贝到用户缓冲"两件事,全交给内核在后台办完;等内核把数据完整拷好了,再来通知你(通过信号、回调或轮询完成队列)。

用钓鱼说:你直接甩给钓场一个"全托管"订单:找人钓鱼、收拾干净、送到你桌上都算在内。人家把整盘做好的端到你面前的时候才叫你,中间你爱干嘛干嘛,连"收鱼"这一步都不用你伸手。

异步IO

 应用                                       内核
  │ aio_read(fd, buf, n, ...)                │
  │ ───────────────────────────────────────> │
  │ 立即返回, 继续干别的 (完全不等)            │
  │                                          │ 内核等待数据就绪...
  │                                          │ 内核把数据拷到 buf...
  │ <──── 全部完成, 通知你 (信号/回调/完成队列) │
  │ 此时 buf 里已经有数据了                   │

把这句话钉死:异步 IO 是"数据和拷贝都完成了才通知你";而信号驱动是"数据就绪了就通知你,拷贝还得你自己来"。 通知的时机,一个在拷贝完成之后,一个在拷贝开始之前——一字之差,模型天壤之别,这是最常见的考点,务必分清。

一张表看穿五种模型

把五种模型在"等待"和"拷贝"两个阶段谁负责、谁等待的情况归纳成一张表,一切一目了然:

                    |   阶段一:等待数据就绪   |   阶段二:拷贝数据
--------------------+-------------------------+------------------------------
 阻塞 IO            |  进程阻塞, 干等          |  进程阻塞, 等拷完
 (Blocking)         |  (挂起, 不占CPU)         |  (挂起, 不占CPU)
--------------------+-------------------------+------------------------------
 非阻塞 IO          |  立即返回 EWOULDBLOCK,   |  就绪后进程阻塞拷贝
 (Non-blocking)     |  需自己一遍遍轮询        |
--------------------+-------------------------+------------------------------
 IO 多路转接        |  select/poll/epoll_wait  |  就绪后进程阻塞拷贝
 (Multiplexing)     |  阻塞等待一批 fd         |
--------------------+-------------------------+------------------------------
 信号驱动 IO        |  内核发 SIGIO 通知就绪,  |  就绪后进程阻塞拷贝
 (Signal-driven)    |  等待期间进程不阻塞      |
--------------------+-------------------------+------------------------------
 异步 IO            |  内核在后台等待,         |  内核在后台拷贝,
 (Asynchronous)     |  进程完全不等            |  拷贝完才通知
--------------------+-------------------------+------------------------------

看这张表你会发现一个规律:除了异步 IO,前面四种模型在阶段二"拷贝"上都得让你的进程老老实实地阻塞着。 它们之间真正的差别,全部集中在阶段一"等待"上如何安排。这正是"两个阶段决定模型分类"这个判断的含义——你去判定一个 IO 属于哪种模型,永远先问:它在等待数据阶段怎么等?在拷贝数据阶段阻塞吗? 答案就出来了。

(以上同步/异步的划分,采用 POSIX.1 与《UNIX 网络编程》(UNP) 的经典界定:阻塞、非阻塞、多路转接、信号驱动这四种,因为在真正拷贝数据的系统调用上会阻塞进程,故都归于"同步 IO";只有异步 IO 因"等待+拷贝"全程由内核接管、进程不阻塞,才叫"异步 IO"。这一划分我在写作时也通过网络检索复核过,与通用资料一致。)

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

把五种模型装进脑子里之后,还有一组绕来绕去、特别容易把人绕晕的概念,也是课程的"高级 IO 重要概念":同步与异步、阻塞与非阻塞。很多同学把它们当成同一件事的近义词,其实它们是两组互相独立、描述不同侧面的标尺。这节课我们用"妖怪蒸唐僧"的场景把它彻底掰开。

先敲黑板给一个粗分法:同步/异步,回答的是"结果怎么送到你手上"——是你主动去要,还是有人做好了主动通知你;阻塞/非阻塞,回答的是"你在等结果的时候,你自己停不停下来"——是原地杵着,还是该干嘛干嘛。 一个讲"结果如何送达",一个讲"等待时人停不停"。

同步通信与异步通信:结果是怎么送达的

同步(Synchronous)和异步(Asynchronous)关注的,是消息通信机制——也就是"一次调用的结果,最终靠什么方式交到调用者手里"。

  • 同步:发出一个调用时,在没得到结果之前,这个调用就不返回。 但一旦返回,就拿着结果回来了。换句话说,是调用者主动等待这个调用的结果。你发调用 → 结果出来 ➜ 返回。中间就算你想做点别的,也轮不到,被卡在"等结果"上。
  • 异步:调用发出之后,这个调用就直接返回了,所以当时没有返回结果。 结果出来之后,是被调用者通过状态、通知,或回调函数,主动把结果递给你。调用者这边发完就撒手,等着被"喊"。

对应到钓鱼场景:同步是你守着鱼竿直到咬钩收了鱼才起身;异步是你下完订单就走, 等钓场做好端到你面前才叫你。

需要特别点明的是:"同步/异步"这个词在这里是指通信层面的"消息怎么送达",它和操作系统并发里那个"进程同步/互斥"完全是两码事。 我们前面讲多进程多线程时提过"同步与互斥",那是描述多个进程/线程之间围绕临界资源、执行次序的制约关系——几个线程为完成同一个任务,在某些位置上要协调工作次序、互相等待和传递信息,尤其在访问共享资源(临界区)的时候。这里的"同步",是线程和线程之间的关系;而"同步通信"的同步,是调用方和被调用方之间"结果怎么送"的关系。

所以课堂上反复叮嘱你一句话:以后看到"同步"两个字,先判断大背景——它说的是"同步通信/异步通信"的同步,还是"同步与互斥"的同步。 牛头不对马嘴,是最常见的理解悲剧。

阻塞与非阻塞:等待时你停不停

阻塞(Blocking)和非阻塞(Non-blocking)关注的,是程序在等待调用结果(消息、返回值)时的状态。

  • 阻塞调用:在调用结果返回之前,当前线程会被挂起。 线程只有得到结果之后才会继续往下走。等待期间它从运行队列里下来,但也就意味着"暂时干不了别的"。
  • 非阻塞调用:在不能立刻得到结果之前,这个调用不会阻塞当前线程。 它把"没结果"的状态原样告诉你(比如返回 EWOULDBLOCK),你拿着状态去干别的,过会儿再来问。

这两者回答的是"等待时你这个人停不停",跟"结果是怎么送达的"完全不挂钩——一个阻塞的调用也可以要靠你最后主动拿结果(同步),一个非阻塞的调用也可能后面走着走着突然有结果通知你(异步)。千万不要把"非阻塞"直接等同于"异步",这是新手最大的误区之一。

两个维度一交叉:妖怪蒸唐僧

课程上用"妖怪蒸唐僧"来串这四个词,我们把它展开成一个四宫格。设小妖要"把唐僧蒸熟"等于一次完整的 IO:烧水烧开 = 等待数据就绪,上锅蒸到熟 = 拷贝数据。 于是四个象限:

                       同步                         异步
            (结果要你主动去拿 / 要         (结果由对方做好后主动通知
             你主动确认才作数)               / 回调, 你不要主动要)
------------+-----------------------------+---------------------------------
非阻塞       | 同步 + 非阻塞                | 异步 + 非阻塞
不挂起线程   | 水没开就先不杵着, 去扫地,      | 直接下"全托管"单, 蒸好端到
(人不杵着)  | 隔会儿回来掀一次锅盖看开没      | 面前才喊你, 中间爱干嘛干嘛
            | (这就是轮询)                 | (就是异步 IO 的感觉)
------------+-----------------------------+---------------------------------
阻塞         | 同步 + 阻塞                  | 异步 + 阻塞
挂起线程     | 死死守在灶前, 水不开不走,      | (常见直觉里几乎没有这种组合:
(人杵着)   | 直到熟了才走                 |  都异步通知了, 一般不会再真阻塞)
            | (就是阻塞 IO 的感觉,           |
            |  及其一般, 最朴素)              |
------------+-----------------------------+---------------------------------

注意这个四宫格的地雷位:"异步 + 阻塞"在直觉里几乎不存在——因为"异步"的精髓就在于"发完就走、等别人通知",既然都走了,又怎么会把自己杵在那儿阻塞起来?你看任何资料里举例子,异步那一侧基本都配着非阻塞。而"同步"这一侧,既可以阻塞(死等),也可以非阻塞(轮询着自己来确认),就看具体模型怎么安排了。

警惕:此"同步"非彼"同步"

既然刚才两次提到"同步"有两个意思,我们专门把这一课拿出来强调,不然它将是你未来调试和面试里的一颗暗雷。

  • 同步通信的那个"同步":属于 IO 模型的维度,回答的是"一次调用的结果靠什么方式送回来"。它和"异步"对立,讨论的对象通常是一次调用/通信。
  • 进程/线程同步的那个"同步":属于并发编程的维度,回答的是"多个执行流之间怎么协调次序、怎么保证安全地共享数据"。它和"互斥"(mutex)、条件变量、信号量这些概念一起出现,讨论的对象通常是多个进程/线程之间为了完成同一任务在某位置的制约关系。

课上那句话很关键:"进程/线程同步是进程/线程之间直接的制约关系——两个或多个线程为了完成某种任务,需要在某些位置协调工作次序,往往通过等待、传递信息产生这种制约,尤其是在访问临界资源、访问临界区的时候。" 一句话,那是"线程间的关系",不是"一次调用和它的结果"。

以后无论读源码、看文档还是听别人讲课,凡是撞见"同步"两个字,先在脑内问一句:这是通信层面的"同步/异步",还是并发层面的"同步/互斥"? 分清背景再继续,很多概念就不再打架了。

更广阔的"高级 IO"家族

上面说的非阻塞 IO、IO 多路转接,其实只是"高级 I/O"这个大家庭里两位出名的成员。教科书上,Linux 的"高级 I/O"通常还包括下面这一屋子住客:

  • 非阻塞 IO:我们正要讲的这位,用 fcntl 等把描述符设为非阻塞。
  • 记录锁(record locking):用 fcntl 的 F_GETLK/F_SETLK/F_SETLKW,给文件的一部分字节加锁,解决多个进程协同读写同一文件的冲突问题("记录"对应文件的若干字节区间,不只是"对整把锁")。
  • System V 流机制(System V stream):一套把"下层驱动 + 中间处理模块(module)+ 上层应用"用流队列串起来的机制,让程序像操作管道一样操作设备,历史色彩浓,如今在 Linux 上已经用得不多。
  • IO 多路转接(也就是 IO 多路复用):select/poll/epoll,我们上面的主角,也是本课程后续要重点深挖的对象。
  • readv / writev 函数:一次读写可以"化整为零(或聚零为整)",列表式地一次操作多个不连续的内存缓冲区(iovec 数组),减少一次 syscall 的多次数据搬运。
  • 存储映射 IO(memory-mapped I/O,mmap):把文件或设备影射到进程的地址空间,之后你就像一个普通数组一样通过指针去访问文件内容,省掉 read/write 的数据拷贝往返。

这些统称高级 IO。我们在这一篇以及整套网络编程课程里,重点攻克的是 IO 多路转接,以及为它做铺垫的非阻塞 IO。这两样组合在一起,就是现代高性能服务器(Nginx、Redis、Netty 们的思想源头)的引擎。

非阻塞 IO 落地:fcntl 一线通关

前面反复提到"把文件描述符设为非阻塞",具体怎么做到?答案是 fcntl。

一个文件描述符默认都是阻塞方式的。要把它改成非阻塞,标准姿势是通过 fcntl 系统调用去改它的"文件状态标志"(file status flags),在标志位里加上 O_NONBLOCK。函数原型长这样:

#include <unistd.h>
#include <fcntl.h>
 
int fcntl(int fd, int cmd, ... /* arg */ );

注意末尾的省略号——fcntl 是"可变参数"的函数,传入的 cmd 取值不同,后面跟着的那第三个参数(甚至第四个参数)的含义和类型也完全不同。这也是它一个头文件里能装下好几种能力的秘诀。

fcntl 的五个本领

按 cmd 的不同,fcntl 大致可以分为五类功能:

功能cmd 取值在干嘛
复制一个现有的文件描述符F_DUPFD让你新拿到的文件描述符和旧描述符指向同一个文件表项,相当于 dup
获取/设置文件描述符标志F_GETFD / F_SETFD操作像 FD_CLOEXEC 这样的"描述符自带标志"
获取/设置文件状态标志F_GETFL / F_SETFL操作 O_RDONLY、O_APPEND、O_NONBLOCK 这类标志——我们要的就是它
获取/设置异步 I/O 所有权F_GETOWN / F_SETOWN指定让"哪个进程或进程组"接收本描述符上的 SIGIO/SIGURG,是信号驱动 IO 的前置
获取/设置记录锁F_GETLK / F_SETLK / F_SETLKW就是上面"高级 IO 记录锁"那一项

我们在这一篇只用第三种:获取/设置文件状态标志,用它往标志位图上按位或一个 O_NONBLOCK,就把一个描述符改成非阻塞了。

实现 SetNoBlock:把描述符改成非阻塞

课堂上给出的写法是这样一套 SetNoBlock,我们先照它理解思路,再补一个更稳的版本:

// 把文件描述符 fd 设置为非阻塞
void SetNoBlock(int fd)
{
    // 第一步:用 F_GETFL 取出"当前"的文件状态标志(这是一张位图)
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0)                 // 取标志失败
    {
        perror("fcntl F_GETFL");
        return;
    }
    // 第二步:用 F_SETFL 把旧标志"写回去",
    // 同时按位或上 O_NONBLOCK——这样只新增非阻塞位,不影响原有标志
    if (fcntl(fd, F_SETFL, fl | O_NONBLOCK) < 0)
    {
        perror("fcntl F_SETFL");
        return;
    }
}

两步很关键,为什么要分开做、而不是直接 fcntl(fd, F_SETFL, O_NONBLOCK) 一把梭?

  • F_GETFL 是"只读当前状态",F_SETFL 是"写入状态"。 文件状态标志是一张位图,里面同一并放着访问模式、O_APPEND、O_NONBLOCK 等多个标志位。如果你不管原来的标志,直接设一个 O_NONBLOCK,等于把别的标志位全清空了——很可能连带把只读/读写模式这些比特也冲掉,埋下 bug。
  • 正确姿势是先 F_GETFL 把当前位图原样取出来,再用 fl | O_NONBLOCK 在"保留原有所有位"的基础上,只新增 O_NONBLOCK 这一位,然后 F_SETFL 写回。 这就是课堂上那句"把描述符的属性取出来(这是一个位图),再把它设置回去,同时加上一个 O_NONBLOCK 参数"的完整含义。

(提示:上面这版我对返回值做了判错,比裸写更稳健;O_NONBLOCK 可以通过 F_SETFL 修改,这一点在 fcntl(2) 手册的 F_SETFL 说明里白纸黑字写得很清楚,可放心使用。)

非阻塞轮询读取标准输入

有了 SetNoBlock,我们就能亲眼看看"非阻塞 + 轮询"到底长什么样了。下面这段程序把标准输入(描述符 0)设成非阻塞,然后在一个无限循环里反复读:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
 
void SetNoBlock(int fd)
{
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0)
    {
        perror("fcntl F_GETFL");
        return;
    }
    if (fcntl(fd, F_SETFL, fl | O_NONBLOCK) < 0)
    {
        perror("fcntl F_SETFL");
        return;
    }
}
 
int main()
{
    SetNoBlock(0);          // 把标准输入(文件描述符 0)设为非阻塞
 
    while (1)
    {
        char buf[1024] = { 0 };
 
        // 非阻塞下:有数据立刻返回读到的字节数
        //           无数据立刻返回 -1,并把 errno 置为 EWOULDBLOCK/EAGAIN
        ssize_t read_size = read(0, buf, sizeof(buf) - 1);
 
        if (read_size < 0)
        {
            if (errno == EWOULDBLOCK || errno == EAGAIN)
            {
                // 这轮没有输入,属于"预期内":可以睡一觉,或去干别的事
                printf("[no input, do something else]\n");
                sleep(1);           // 避免紧轮询空烧 CPU
                continue;
            }
            else
            {
                perror("read");     // 其它真正错误,比如 EBADF
                break;
            }
        }
 
        printf("input:%s\n", buf);
    }
 
    return 0;
}

跑起来的效果是:没有任何输入时,程序不会像之前那样卡死在 read 上等键盘,而是每秒钟打印一次 [no input, do something else] —— 中间那段时间它"没被阻塞",它只是没活儿干在睡觉。此时你敲一行字回车,它就能把这波输入打印出来,然后再回到"没数据就歇一歇"的节奏。

这个例子把"非阻塞的两件事"演示得很透:

  1. read 不在等待阶段挂住你了——没数据它也立刻回来,只是用一个负值和 errno 告诉你"这次没货"。
  2. 但你必须自己处理"没货"的情形——这就是轮询。而且一旦"没货",你不能真的 bare 着空转,否则就是 CPU 疯狂空转;sleep 让出 CPU 是很朴素的兜底做法,真正生产里则是"去处理其它就绪的事件/连接",等有货了再来。

关于 errno 再叮嘱一句:非阻塞读脏数据(一上来就返回 -1)时,真正错误和"没数据"都表现为 -1,二者必须靠 errno 区分。EWOULDBLOCK/EAGAIN 代表"这只是一个暂时的没有数据",不是系统错;而 EBADF 这类才是真错。代码里用 errno == EWOULDBLOCK || errno == EAGAIN 来判断,正是为了把这两类情况掰开——因为 Linux 下它们值是同一个,写一个就行,但不少跨平台代码保守地两个都判,也是可以的。

顺便点破一个"多路转接终极拷问"里常见的坑——它到底快在哪? 多路转接其实并没有让"某一次 read"变快:从这张轮询图也能看出来,一旦数据就绪去真正拷贝,照样是同步阻塞地拷完。它真正快的其实是等待阶段的规模效应,就这么两句话:

  • 它把"等待"从"每连接一份"收敛成"全局一份"。 阻塞模型里一千个连接 = 一千个线程各自挂在各自的 read 上等;多路转接里一千个连接也只挂在一个 select/epoll_wait 上等。省掉的是海量线程的创建、销毁、内存占用和上下文切换——这摊开销往往才是高并发下真正的瓶颈,而不是数据拷贝本身。
  • epoll 在"取就绪 fd"和"管理 fd 集合"上又比 select/poll 更省。 select/poll 每次调用都要把整套 fd 集合拷进内核、返回后再 O(n) 全量扫描一遍判就绪;epoll 用红黑树管理已注册的 fd、用就绪链表在事件发生时把就绪者直接收集起来,epoll_wait 返回的 "就绪列表" 无需全量扫描。这就是 Nginx/Redis 宁可单选 epoll 的原因。

一句话收尾这个坑:多路转接不是"把一次等 1 秒变成 0.1 秒",而是"同一秒里同时等十万路,平均每路等待成本趋近于零";且它在就绪 fd 的收集上比 select/poll 更省 O(n),这一层优势是 epoll 独有的。

思考题与自测(含详解答案)

课后不留作业是耍流氓。下面是几道针对本课知识点的思考题,每道都附上详解答案,建议你先自己动手想一遍、再对答案。

Q1(判断):非阻塞 IO 就是异步 IO。这种说法对吗?为什么?

答案与解析: 不对,这是把两个不同维度的词混为一谈。按 POSIX 的严格定义,阻塞、非阻塞、IO 多路转接、信号驱动这四种都归为"同步 IO",因为它们在真正拷贝数据的系统调用上进程是阻塞的;只有异步 IO(内核把"等待数据 + 拷贝数据"全程做完、然后再通知你)才叫异步。非阻塞只说明"等待数据就绪时不挂起你的线程",它之后真正 read 拷贝数据的阶段,进程依然是阻塞的——所以非阻塞仍属同步。判断"是不是异步"的硬标准是:数据拷贝阶段,进程会不会阻塞? 会,就是同步;全程由内核完成、不阻塞,才是异步。

Q2(辨析):既然多路转接是"同步"的,为什么生产环境还用 epoll 支撑十万级并发?它到底"快"在哪?

答案与解析: 多路转接是"同步"指的是它的拷贝阶段阻塞,这一点没错;但它快根本不体现在"单次拷贝"上,而是体现在"等待阶段"被规模化分摊。第一,阻塞模型下每个连接都要一个线程各自阻塞在自己的 read 上,把并发从几百涨到十万,线程开销先崩了;多路转接把成千上万个连接挂到同一个 select/poll/epoll_wait 上等待,等待成本从"每连接一份"变成"全局一份"。第二,相对 select/poll,epoll 用红黑树管理 fd、用就绪链表直接收集就绪事件,返回时不再需要 O(n) 全量扫描,注册的 fd 也不用每次整体拷进内核。所以结论是:多路转接快在"把等待从线性成本摊成近似常数、且就绪 fd 的获取更省",而不是"让某一次读数据变快"。

Q3(代码):把文件描述符设为非阻塞后,当数据还没就绪时 read 会返回什么?为什么轮询代码里往往要 sleep 或"去干别的事",而不是直接空转继续下一轮?

答案与解析: 数据未就绪时非阻塞 read 返回 -1,并把 errno 置为 EWOULDBLOCK(在 Linux 上与 EAGAIN 是同一个值);如果是读到流的末尾则会返回 0。如果不 sleep 也不去干别的事,while 循环会以 CPU 能给到的最高速度一遍遍发 read 系统调用、一遍遍被打回,瞬间把一个核的 CPU 烧到 100%,却什么都没干——这叫"紧轮询/busy loop",是性能杀手。所以没数据时要主动让出 CPU:要么 sleep 一小段,更专业的是去处理其它已就绪的事件或连接,等有货了再回来读。这也是为什么"纯非阻塞 + 自己死命轮询"在工程上很少被单独使用,而是让位给"多路转接触发就绪 + 非阻塞快速失败"的组合。

Q4(概念):同步通信里的"同步",和进程/线程的"同步与互斥"里的"同步",是同一个意思吗?请举例说明分不清楚会闹什么乌龙。

答案与解析: 不是一回事。同步通信的"同步"是 IO 模型维度,回答"一次调用的结果靠什么方式送回来",是调用方主动等结果、拿到结果才返回,它与"异步"对立;进程/线程的"同步"是并发维度,回答"多个执行流怎么协调次序、怎么安全共享资源",是线程与线程之间的制约关系,常与"互斥"(mutex)、信号量、条件变量一起出现。乌龙举例:老师在讲多线程时说"这个互斥锁保证了线程同步",有同学一听"同步"就以为是"子线程同步地等网络返回",于是拿着这个"同步"去套 IO 模型的概念,整个理解就歪了。所以看到"同步"二字,先判断背景是"通信层面(同步/异步)"还是"并发层面(同步/互斥)",再继续往下读。

Q5(代码修错):数据没就绪时,下面这段非阻塞读标准输入的代码有什么问题?该怎么修?

while (1)
{
    char buf[1024];
    ssize_t n = read(0, buf, sizeof(buf) - 1);
    if (n < 0)
    {
        continue;          // 数据没就绪,直接进下一轮
    }
    buf[n] = 0;
    printf("input:%s\n", buf);
}

答案与解析: 问题有两个。(1)致命的紧轮询:continue 那一路既没判 errno 也没 sleep,数据一迟到就会以最高速空转,把 CPU 烧满;(2)buf 未初始化,如果 read 恰好返回 0(读到 EOF),buf[0] = 0 也会先把整个 buf 的后续判为垃圾,且用例里没对返回 0 单独处理。修复思路:进 continue 前用 errno 区分"EWOULDBLOCK/EAGAIN(暂时的没数据,该歇一歇)"与"真正错误";把 buf 初始化为 0;对返回 0 表示 EOF 的情形单独处理。参考修正版:

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
 
/* 假设 fd 0 已被 SetNoBlock 设为非阻塞 */
int main()
{
    while (1)
    {
        char buf[1024] = { 0 };              // 初始化,避免读到未初始化的垃圾
        ssize_t n = read(0, buf, sizeof(buf) - 1);
 
        if (n > 0)
        {
            printf("input:%s\n", buf);
        }
        else if (n == 0)
        {
            printf("EOF / 无更多数据\n");      // 读到流末尾
            break;
        }
        else // n < 0
        {
            if (errno == EWOULDBLOCK || errno == EAGAIN)
            {
                sleep(1);                      // 没数据,先让出 CPU,别空转
                continue;
            }
            else
            {
                perror("read");                // 真正的错误
                break;
            }
        }
    }
    return 0;
}

Q6(综合):请用"两个阶段"一句话,独立地推出"信号驱动 IO"与"异步 IO"的区别。

答案与解析: 判定点全在通知的时机。信号驱动 IO 是在阶段一"等待数据就绪"完成时通知你(发 SIGIO),通知后你还得自己动手去 read,即阶段二"拷贝数据"仍由你同步阻塞地完成;异步 IO 则是阶段二"拷贝数据"也完成之后才通知你,你收到通知时数据已经躺在你的缓冲区里了。课堂上那句总结最精辟:"今天异步 IO 由内核在数据拷贝完成时通知应用程序,而信号驱动是告诉应用程序何时可以开始拷贝数据。" 一个通知在"拷贝前",一个通知在"拷贝后",画一画两阶段图,立刻就能背下来,而且不会忘。


到这里,五种 IO 模型和支撑它的非阻塞 IO 就算通了。回顾这一篇我们做过的几件大事:把一次 IO 拆成"等待数据 + 拷贝数据"两阶段,看穿五种模型(阻塞、非阻塞、多路转接、信号驱动、异步)本质就是这两阶段的排列组合;用同步/异步和阻塞/非阻塞两组相互独立的"尺子"去量这些模型,搞清了"非阻塞 ≠ 异步"、异步 = 拷贝也由内核接管;又专门排掉了一颗"同步通信 vs 同步互斥"的暗雷;最后亲手用 fcntl + O_NONBLOCK 写了个非阻塞轮询标准输入的程序,理解了轮询、紧轮询的代价,以及为什么生产环境最终会站到 select/poll/epoll 那一侧。

这五兄弟里最值得你深挖的,毫无疑问是 IO 多路转接——它是现代高并发服务的发动机,而 epoll 又把它的使用体验推到极致。多路转接里 select/poll/epoll 三兄弟各自怎么用、写成代码怎么处理"就绪的 fd"、事件驱动又是怎么回事,那就是我们下一篇文章要攻的堡垒了。把这一篇的两阶段拆解记牢,下一站你会学得飞快。