如果你是第二次写并发的服务端程序,多半已经隐约感觉到一件事:程序绝大多数时间不是在"算",而是在"等"——等网卡数据到,等对端发消息,等磁盘把块读出来。在网络编程里,等待往往占据了九成以上的时间。能不能把这段"干等"的时间省下来,直接决定了一个服务能不能扛住成千上万的并发连接。
这一篇我们就把 Linux 网络编程里那个绕不开的"IO 模型"讲透。它是后面理解 select/poll/epoll 的地基,也是面试的高频考点。我们先把一个朴素的问题问到底:一次读操作,程序到底在等什么?
别小看这个问题。把一个 IO 拆成两段时间去看,五种 IO 模型的本质差异就全出来了。
一次 IO 要走的两步:等待数据,再拷贝数据
我们在课堂上反复强调过一句"金句":任何 IO 过程中,都包含两个步骤。第一步是等待,第二步是拷贝。 这句话是所有 IO 模型的脊椎骨,务必把它刻进脑子里。
拿"往 socket 上读数据"举例。你调用 read(fd, buf, n) 之后,这一趟"读"可拆成两个泾渭分明的阶段:
- 阶段一:等待数据就绪。 内核先把网卡上到的数据收到自己的内核缓冲区(这一小段由内核协议栈处理,等数据经过网络到达网卡、再被压进内核的接收缓冲区)。所谓数据就绪(IO ready),对读取而言就是"内核缓冲区里已经有至少一个字节可以拷给你";对写入而言就是"内核缓冲区里有足够的空间可以收下你要写的数据"。
- 阶段二:拷贝数据。 内核把数据从内核态缓冲区拷到用户态缓冲区,也就是拷到你程序里那个
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] —— 中间那段时间它"没被阻塞",它只是没活儿干在睡觉。此时你敲一行字回车,它就能把这波输入打印出来,然后再回到"没数据就歇一歇"的节奏。
这个例子把"非阻塞的两件事"演示得很透:
read不在等待阶段挂住你了——没数据它也立刻回来,只是用一个负值和errno告诉你"这次没货"。- 但你必须自己处理"没货"的情形——这就是轮询。而且一旦"没货",你不能真的 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"、事件驱动又是怎么回事,那就是我们下一篇文章要攻的堡垒了。把这一篇的两阶段拆解记牢,下一站你会学得飞快。
还没有评论 — 第一条由你来留。