从 Reactor 到 io_uring:一次讲透高并发网络底层

0 阅读8分钟

高并发网络架构从传统的 Reactor 一路进化到新一代的 io_uring,很多开发者天天用,却没真正理解底层为什么这么设计。

今天一篇把核心逻辑讲透,让你真正看懂高性能网络的本质,不管是后端、游戏服务器,还是高性能、网络安全方向的同学,看完面试不慌、开发不踩坑。

一、Reactor 模型到底是什么?

别被网上复杂的概念绕晕,Reactor 说白了就是反应堆模型,典型的事件驱动架构,没有任何玄学。

核心逻辑很简单:一个线程就能同时监听成千上万个连接,哪个连接有事件(比如有数据可读、新连接接入)来了就处理谁,没事件就闲置等待,不做无用功。

这里必须记死一个关键点:Reactor 是同步非阻塞模式。也就是说,内核通知事件就绪后,不会帮你处理数据,必须由应用程序自己去读数据、处理数据,全程自己动手。

不用死记理论,看实际应用就懂:Redis、Nginx、大部分游戏服务器、高并发网关,用的都是 Reactor 模型——它能扛住高并发,就是靠这种“事件驱动、按需处理”的思路。

二、Proactor 异步模型简单对比

讲 io_uring 之前,必须先提 Proactor,不是多余,是帮你更好理解 io_uring 的设计思想,两者思路完全相反。

Proactor 是纯异步模型,核心逻辑就一句话:应用程序只负责提交 I/O 请求,剩下的所有工作(数据读写、拷贝)都由内核搞定,内核处理完之后,再通知应用程序直接使用数据就行。

最典型的 Proactor 实现,就是 Windows 的 IOCP(完成端口),Windows 上的高并发服务器,基本都靠它。理解了 Proactor“内核包办 I/O”的思路,再看 io_uring,就会豁然开朗。

三、Reactor 模型的瓶颈

客观说,Reactor 很牛,它解决了高并发“能不能扛得住”的核心问题,但在极致性能场景下,瓶颈非常明显,尤其是百万级 IO 场景,这些开销会被无限放大,主要有3点:

• 需要频繁的 read / write 系统调用:事件就绪后,应用程序必须一次次调用 read、write 来读写数据,调用一次就有一次开销;

• 用户态与内核态频繁切换:每次系统调用,都会在用户态和内核态之间切换,切换的开销看似不大,高并发下会成为性能拖累;

• 存在一定的内存拷贝与开销:数据要从内核缓冲区拷贝到用户缓冲区,再从用户缓冲区拷贝回内核,两次拷贝会占用大量 CPU 资源。

这就是为什么,当并发量涨到 C10M 级别,Reactor 就扛不住了,这也正是 io_uring 诞生的原因。

四、io_uring 的核心设计:两个队列

很多人问,io_uring 为什么能比 Reactor 快一个量级?核心就一个设计——一对无锁队列,没有多余的花活,纯粹靠合理分工实现极致效率,这两个队列分别是:

• SQ(提交队列) :专门用来让应用程序丢 I/O 请求的队列,应用要读、要写,不用再调用 read、write 系统调用,直接把请求封装好,丢进 SQ 就行;

• CQ(完成队列) :专门用来让内核返回处理结果的队列,内核处理完 I/O 请求后,把结果(比如读取的数据长度、操作是否成功)封装好,放进 CQ,应用程序直接从 CQ 拿结果就行。

而 io_uring 的设计目标很明确:实现用户态与内核态之间无锁、低延迟、高并发通信,彻底解决 Reactor 的瓶颈。这里补充一句,两个队列是通过内存映射(mmap)实现共享的,无需额外数据拷贝,这也是它快的关键之一。

五、为什么必须两个队列

这是面试高频题,也是很多开发者的认知盲区,直接给结论:不行,而且差远了

如果只用一个队列,会出现一个致命问题:四方操作混在一起,必然乱套:

• 应用程序要写 I/O 请求;

• 内核要读 I/O 请求;

• 内核要写处理结果;

• 应用程序要读处理结果。

四方同时操作一个队列,一定会出现激烈的锁竞争、CPU 缓存颠簸,高并发根本上不去,反而比 Reactor 还慢——这就是单队列的死穴。

而两个队列的设计,完美解决了这个问题:去程一条路,回程一条路,完全解耦

• SQ:只有两个操作——应用只写、内核只读;

• CQ:只有两个操作——内核只写、应用只读。

更关键的是,每个队列都是 SPSC(单生产者单消费者)无锁队列,读写双方各改各的指针,互不干扰,天然无锁,这也是 io_uring 能实现低延迟的核心原因。

六、关键误区:多线程同时写 SQ 会炸

很多人看完 io_uring“无锁”的介绍,就误以为多线程可以随便写 SQ,结果程序直接崩掉,还找不到原因。

这里一针见血:io_uring 的“无锁”,只针对用户态 ↔ 内核态之间,默认 SQ 只支持单生产者安全。多线程并发写同一个 SQ,一定会出问题,具体表现为:

• 覆盖 SQE(提交队列条目),导致请求丢失;

• 队列指针错乱,后续请求无法正常提交;

• 出现竞态条件,数据不一致;

• 最严重的,程序直接异常崩溃。

没有其他捷径,正确做法只有三种,记死就行:

• 每个线程独立一个 io_uring 实例(最常用、最安全,线程之间互不干扰,不用加锁);

• 应用层自己加锁(比如 mutex),保证同一时间只有一个线程写 SQ;

• 开启内核支持的多生产者模式(IORING_SETUP_MULTI_PRODUCER),让内核支持多线程写 SQ,但仍需应用层保证请求正确性,不是万能的。

多线程操作 io_uring,这三种方式选一种,别瞎折腾,否则踩坑都不知道怎么回事。

七、应用写太快,队列

会满!一定会满!  别抱有任何侥幸心理。

原因很简单:应用程序写请求是 CPU 操作,速度极快;内核处理 I/O 请求是 IO 操作,速度相对较慢,一快一慢,队列迟早会被写满。

而且必须记住:io_uring 的队列是初始化时固定大小,不能动态扩容。一旦写满,继续写入会直接覆盖旧数据,导致逻辑错乱、请求丢失,最后程序异常。

所以应用程序在写入 SQ 之前,必须先判断队列是否已满,处理方式有3种,按需选择:

• 满了就停止写入,不盲目提交;

• 阻塞等待,直到队列有空闲位置;

• 返回错误,由上层做限流、重试处理。

八、工程实战:批量提交 容纳 不下怎么办

实际开发中,经常会遇到批量提交请求的场景,比如你想一次性提交 64 个 I/O 请求,但 SQ 只剩下 5 个空位,这时候该怎么办?

记住,io_uring 不会帮你处理这种情况,正确操作只有4步,一步都不能少:

• 先写入能放下的 5 个请求,不贪多;

• 剩下的 59 个请求,必须由应用自己缓存起来,io_uring 不负责缓存、排队;

• 调用 submit 方法,提交已入队的 5 个请求;

• 等待内核消费完这 5 个请求,队列腾出空位后,再继续写入缓存的剩余请求。

这里再强调一句核心原则:io_uring 只提供高速通道,不负责帮你缓存、排队、缓冲。高性能架构永远是:用户负责流量控制,内核负责高速执行,别搞反了。这和实际工程中“批量提交请求可提升效率”的思路一致,批量提交能减少系统调用次数,但前提是做好流量控制。

九、总结

看到这里,相信你已经吃透了 Reactor 和 io_uring 的核心逻辑,最后总结帮你加深记忆,也帮你理清底层设计思路:

• Reactor 解决了高并发“能不能扛住”的问题,让我们能用少量线程搞定上万个连接;

• io_uring 解决了高并发“如何跑得极致快”的问题,用双队列无锁设计,消除了系统调用、内存拷贝的开销;

• 双队列的核心价值,是解决了用户态与内核态的无锁通信,这是 io_uring 快的根本;

• 但 io_uring 不是万能的:它不解决多线程并发提交的问题,也不解决队列满后的自动缓冲;

• 理解这些设计 trade-off(取舍),不神化任何技术,才算真正懂高性能网络底层。

关注架构码叔,后续持续分享软件设计的各种技巧。