虎嗅APP 16小时前
两种系统哲学的分野:为什么 Windows 选择了 IOCP?
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_keji1.html

 

本文来自微信公众号: 宇众不同的露萱 ,作者:宇众不同的露萱,原文标题:《两种系统哲学的分野:为什么 Windows 选择了 IOCP?》

一个值得琢磨的对比:Linux 世界里,从 select、poll 一路演化到 epoll,再到近几年的 io_uring,这条路走了将近三十年。而 Windows,早在 1994 年发布 Windows NT 3.5 的时候,就已经提供了一套至今几乎没有被替代过的高并发 I/O 模型—— IOCP(I/O Completion Port,I/O 完成端口)。更耐人寻味的是,Linux 最新的 io_uring,设计哲学上居然和这套快三十年前的 Windows 方案越来越像。这中间到底发生了什么?

故事背景:1990 年代初,一台服务器该怎么同时伺候成千上万个连接?

把时间拨回 1980 年代末到 1990 年代初。彼时,微软正在打造一个全新的操作系统内核,用来彻底取代老旧的 DOS 架构——这就是后来的 Windows NT。负责这个项目的核心人物,是从 DEC(数字设备公司)挖来的传奇工程师 Dave Cutler,他此前主导设计了 DEC 的 VMS 操作系统,是操作系统内核设计领域举足轻重的人物。

Windows NT 从一开始就瞄准的是服务器级别的场景——这意味着它必须直面一个当时几乎所有操作系统都在挣扎的问题:当一台服务器需要同时处理成百上千个网络连接时,操作系统的 I/O 模型该怎么设计,才能既保证并发能力,又不把 CPU 和内存资源全部耗死在 " 等待 " 上?

这个问题后来在互联网爆发的年代,被 Unix 社区称为著名的 C10K 问题——如何让一台服务器同时支撑一万个并发连接。而在 Windows NT 诞生的那个更早的年代,微软的内核团队已经在为类似的挑战寻找答案,只不过他们走的是一条和后来 Unix 世界主流路线截然不同的道路。

旧方案为什么失败?

每连接一个线程(Thread-Per-Connection):这是最直觉的方案——每来一个客户端连接,就创建一个线程专门处理它的读写。逻辑清晰、编程模型简单,几乎是当时大多数网络服务的标准写法。但问题在于,线程从来不是免费的资源:每个线程都需要独立的内核栈空间(通常是 1MB 量级),当连接数达到几千甚至上万时,仅仅是线程本身占用的内存就已经相当可观,更致命的是,操作系统在成百上千个线程之间来回 ** 上下文切换(Context Switch)** 的开销,会随着线程数量增长而急剧膨胀——大部分 CPU 时间被浪费在 " 决定接下来运行哪个线程 " 这件事本身,而不是真正处理业务逻辑。

阻塞式同步 I/O:线程调用一次读操作,如果数据还没到达,线程就会被操作系统挂起,直到数据就绪。这种模型下,一个线程在等待 I/O 的这段时间里完全是 " 死 " 的,无法做任何其他有意义的工作——如果你想同时处理多个连接,唯一的办法就是开更多线程,回到上面那个死胡同。

基于消息循环的异步通知(比如早期 Windows 提供的 WSAAsyncSelect):把 I/O 事件转换成 Windows 消息,投递到应用程序的消息队列里,程序在消息循环里处理这些通知。这种方式确实避免了阻塞,但它天然和 Windows 的 GUI 消息机制绑定在一起,在真正高并发、高吞吐的服务器场景下,消息队列的处理效率和扩展性都远远不够。

这些方案共同的困境是:它们都没有回答一个核心矛盾——并发连接数可能是成千上万,但一台机器真正能同时执行代码的 CPU 核心数,却只有个位数或者几十个。如果线程数量和连接数量强绑定,系统迟早会被压垮。

真正的突破:不问 " 谁准备好了 ",而是 " 谁刚刚做完 "

Windows NT 内核团队给出的答案是 IOCP —— I/O 完成端口,随 Windows NT 3.5 于 1994 年正式发布。它的设计哲学,和后来 Unix 世界主流的 select、poll、epoll 有一个根本性的分歧,理解这个分歧,是理解 IOCP 为什么特别的关键。

Unix 世界的主流模型,本质上是 **" 就绪通知 "(Readiness Notification)**:应用程序问操作系统," 这些 socket 里,哪些现在已经可以读或者可以写了?",得到答案后,应用程序自己去执行真正的读写操作。

而 IOCP 走的是完全不同的路—— " 完成通知 "(Completion Notification):应用程序直接发起一次异步的读或写请求(这被称为 Overlapped I/O,重叠 I/O),然后立刻返回去做别的事情,由操作系统内核在后台真正完成这次数据搬运,等数据已经读好、或者已经写完之后,内核才把 " 这件事已经做完了 " 的通知,投递到一个叫 " 完成端口 " 的内核队列里。

这个设计和 Dave Cutler 此前在 DEC VMS 系统上的经验一脉相承—— VMS 本身就有相当成熟的异步 I/O 完成机制,Cutler 把这套思路带进了 Windows NT 的内核设计中。IOCP 真正巧妙的地方在于它同时解决了两个问题:第一,应用程序不再需要为每个连接分配一个专属线程——发起异步 I/O 之后线程立刻释放,可以去处理别的连接;第二,完成端口允许你自己控制 " 到底应该有多少个线程在同时处理这些完成通知 ",微软给出的经典实践建议是——工作线程数量应该和 CPU 核心数大致匹配,多个线程共享同一个完成端口,谁空闲了,就去队列里取下一个已经完成的 I/O 任务来处理。这就从根本上把 " 并发连接数 " 和 " 实际工作线程数 " 这两个变量彻底解耦了。

源码里的体现

IOCP 的编程模型核心,浓缩在两个函数调用里:CreateIoCompletionPort 用于创建完成端口,并把一个个 socket 或者文件句柄 " 关联 " 到这个端口上;而工作线程只需要反复调用 GetQueuedCompletionStatus,这个调用会阻塞在这里,直到内核队列里出现一个已经完成的 I/O 操作,线程被唤醒,拿到这个完成结果,处理完业务逻辑后,再回来继续调用这个函数等待下一个任务。

这段代码背后最值得玩味的设计,是完成端口内部维护的先进先出队列,加上一个 " 并发值 " 参数——你可以在创建端口时指定,同一时刻最多允许多少个线程真正处于 " 运行 " 状态(通常设为 CPU 核心数),当某个正在运行的线程因为其他原因阻塞时,内核会智能地唤醒队列里等待的下一个线程来补位,尽可能让 CPU 核心始终保持 " 满载但不过载 " 的状态。这本质上是把 " 如何合理调度线程去匹配 CPU 资源 " 这件极其复杂的事情,直接下沉进了内核里完成,而不是交给应用程序自己摸索。

设计思想

IOCP 的设计浓缩了几种在高性能系统里反复出现的核心思想:

完成驱动而非就绪驱动:不问 " 能不能做 ",而是让内核直接把 " 已经做完 " 的结果送到你面前,减少了应用层需要主动轮询和调度的复杂度。

线程池思想,与连接数彻底解耦:并发连接数可以是几万,工作线程数量却始终稳定地贴合 CPU 核心数量。

内核级别的负载均衡:把线程调度的精细决策,交给最了解硬件状态的内核,而不是应用层自己去猜。

零拷贝的延伸空间:重叠 I/O 的异步模型,天然为后续结合 DMA、分散 / 聚集(Scatter-Gather)等零拷贝技术留出了空间。

为什么它最终赢了?

IOCP 之所以在 Windows 生态里长期保持 " 没有真正对手 " 的地位,是因为它一次性解决了高并发服务器最核心的两个痛点——既不需要为每个连接付出一个线程的代价,又能让有限的 CPU 核心得到充分且不过载的利用。

这套模型此后成为 Windows 高性能网络服务的事实标准:IIS(Internet Information Services)、SQL Server 的网络层,长期基于 IOCP 构建;.NET 的异步 I/O 体系、包括后来的 async/await 语法糖,底层在 Windows 平台上很大程度依赖 IOCP 提供的异步能力;跨平台的 libuv(Node.js 的底层事件循环库)在 Windows 平台上,专门用 IOCP 作为其异步 I/O 的实现后端,与 Linux 平台上使用 epoll 的实现分庭抗礼;Rust 生态里的异步运行时 Tokio,同样在 Windows 上基于 IOCP 构建其底层驱动。

它真正改变的,是 " 高并发网络编程 " 这件事在 Windows 世界里的默认答案——不再需要工程师自己去纠结线程池大小该怎么调、连接数暴涨了该怎么办,这些复杂度被系统性地下沉进了操作系统内核这一层。

有没有更好的方案?

耐人寻味的是,Linux 世界这些年也在朝着 IOCP 当年的方向靠拢。2019 年,Linux 内核开发者 Jens Axboe 提出了 io_uring,这是 Linux 异步 I/O 历史上一次真正意义上的范式转变——它同样采用了 " 完成队列 " 的设计,用户态和内核态共享一片环形缓冲区,应用程序提交 I/O 请求,内核完成后把结果写入完成队列,用户态几乎不需要额外的系统调用开销就能拿到结果。这本质上就是 **" 完成驱动 " 哲学在 Linux 世界的一次迟到二十多年的回归 ** ——某种意义上,这是对 IOCP 当年设计理念的一次隔空致敬(尽管两者在具体实现细节上有相当大的差异,io_uring 更进一步利用了共享内存环减少系统调用次数)。

更现代的多核架构、更强调低延迟的 NVMe 存储设备,让 " 减少系统调用次数、减少内核态用户态切换 " 变得越来越重要,这也是 io_uring 相比传统 epoll 更进一步的核心动机之一。而在编程语言层面,Rust 的 async/await、Go 的 goroutine 调度器、Java 21 引入的 Virtual Thread(虚拟线程),都在用不同的方式,试图把 " 高并发编程 " 这件事的心智负担,从工程师手中进一步转移给运行时和调度器——这条路径上,IOCP 当年 " 让内核帮你管理线程和 I/O 完成 " 的思路,可以说是一次相当早期的先声。

现实中的应用

IIS(Internet Information Services):微软自家 Web 服务器,网络层长期基于 IOCP 构建。

SQL Server:数据库引擎的网络通信层同样依赖 IOCP 实现高并发连接处理。

.NET/.NET Core:异步 I/O 与 async/await 编程模型在 Windows 平台上的底层实现基础之一。

libuv(Node.js):在 Windows 平台专门使用 IOCP 作为异步事件循环的后端实现。

Tokio(Rust 异步运行时):跨平台异步运行时在 Windows 上同样基于 IOCP 构建其 I/O 驱动。

这些系统的共同选择,反映出一个朴素的事实:只要你需要在 Windows 平台上构建高并发、高吞吐的网络服务,IOCP 几乎始终是那个绕不开的底层答案。

宙世代

宙世代

ZAKER旗下Web3.0元宇宙平台

一起剪

一起剪

ZAKER旗下免费视频剪辑工具

相关标签

windows linux 微软 dos
相关文章
评论
没有更多评论了
取消

登录后才可以发布评论哦

打开小程序可以发布评论哦

12 我来说两句…
打开 ZAKER 参与讨论