Reactor 网络模型演进:图解 7 种 Reactor 网络模型

序言

高并发服务器离不开网络模型,网络模型的优劣决定了系统的吞吐量。Reactor 是业界主流网络模型。但它从单线程到主从 Reactor 有好几种网络模型,很多人说不清彼此的关系和由来。

从「一连接一线程」出发,看 Reactor 每一个网络模型的瓶颈,以及如何演化出下一个网络模型,一直到业界常用的多主从 + 线程池网络模型。

图解方式一步步进行拆解,让你对 Reactor 网络模型有个透彻的理解。

一、Reactor 出现的背景

传统阻塞式 I/O 主要的问题是什么?

每个客户端连接对应一个线程:这是最简单的一种网络模型,也就是每来一个连接就创建一个线程处理它。初次接触网络编程时,都会接触到。

这个模型很好理解:

  • 客户端连接 1 → 线程 1;
  • 客户端连接 2 → 线程 2;
  • ......
  • 客户端连接 N → 线程 N。

每个线程里大概按 acceptread → 处理业务 → writeclose 的顺序执行。

然而每个线程都直接进行 acceptreadwrite 操作,这些系统调用都可能阻塞。假设一个客户端连接建立后迟迟不发送数据,那么这个线程就会一直阻塞在 read 上,此时这个线程也就无法处理别的客户端请求。

这种网络模型并发数取决于线程的个数。连接数少的时候,性能差异还不明显。可生产环境下,单机 QPS 并发请求数通常都在 5 万以上,因此要想达到这种并发数基本上不可能。一旦连接数上来以后,问题就非常明显了:

  • 每个连接占用一个线程,线程数量会迅速膨胀;
  • 每个线程都有栈空间,内存消耗很高;
  • 大量线程之间频繁切换,CPU 时间浪费在调度上;
  • 业务高峰时,系统可能不是被计算压垮,而是被线程管理拖垮。

因此,在生产环境下,我想没有哪个企业会使用来一个客户端连接就创建一个线程这种网络模型,这对于高并发服务器来说是灾难性的。

高并发服务器真正要解决的问题不是"如何创建更多线程来处理客户端请求",而是"如何用更少的线程来处理更多的连接"。这才是 Reactor 模型发挥作用的原因。

那使用线程池能解决这个问题吗?

答案是: 没从根本上解决问题

线程池相比"来一个客户端连接就创建一个线程"确实改善了一些,但它还是没有从根本上解决 I/O 阻塞问题。

因为一连接一线程的问题是连接数越多、线程数越多;而线程池的改进是提前创建固定数量的线程,让所有的客户端连接复用这些线程,它的模型大概是这样:

这种网络模型,通常会有一个专门的接收线程负责 accept,处理客户端连接。并将连接后的 fd 放到任务队列中,然后发送事件通知,唤醒线程池中的线程,从任务队列取数据。

线程池从任务队列中取出 fd 后,就可以对这个 fd 进行数据读写。

使用线程池后,解决了"线程创建和销毁成本太高"的问题。然而并没有解决"线程阻塞等待 I/O"的问题。如果工作线程拿到一个连接 fd 后,此时客户端依旧不发数据,线程仍然会阻塞在 read 等待客户端发数据,线程无法再处理其他请求。

因此线程池模型在高并发场景下仍然有几个问题:

  • 工作线程数量有限,请求多了以后任务会堆积在队列里;
  • 线程虽然复用了,但阻塞等待 I/O 的问题还在;
  • 线程池规模太小会排队,规模太大又会带来上下文切换和内存压力。

也就是说,线程池它没有改变阻塞 I/O 的问题。因此需要有一种方式,不再让每个线程阻塞在 read/write 等系统调用上,而是让线程集中等待大量客户端连接的 I/O 就绪事件,这就引出了 I/O 多路复用。

I/O 多路复用

统一等待、集中分发:I/O 多路复用的核心思想是,每个连接不再单独阻塞在 read/write 等系统调用上,而是阻塞在 epoll 等 I/O 多路复用上。

把多个客户端连接的读写事件统一注册到一个事件分发器中(例如 epoll)

  • 如果没有事件发生,线程只需要阻塞在 epoll_wait 系统调用,不需要通过轮询方式调用 read 判断是否有数据可读。
  • 当有事件发生时就从睡眠中返回,然后根据 fd 查找对应的事件处理函数,进行事件分发,最终调用事件处理函数。

它的整体流程可以概括为:

  1. 注册 fd 关注的读写事件;
  2. 等待读写事件的发生;
  3. epoll 发现就绪事件,并把就绪事件返回给事件循环;
  4. 事件循环分发事件,调用对应 fd 的事件处理函数;
  5. 回到事件循环,继续 epoll_wait 等待事件的发生

我们来看下这个事件循环

事件循环不是处理完一次请求就结束了,而是不断重复"等待事件"和"处理事件"。每一次循环只处理已经就绪的连接,未就绪的事件,内核根本就不会触发。

单单 I/O 多路复用还不够,这仅仅是食材,关键在于怎么利用这个食材做出一道美味佳肴来。

因此在 I/O 多路复用基础上,把这个事件循环交给几个线程、怎么分工,就有了 5 种不同的做法。

最简单的一种,是让一个线程独自承担整个事件循环,这就是单线程 Reactor

二、单线程 Reactor 网络模型

单线程 Reactor 是怎么运转的?

单线程 Reactor,就是让一个线程独占整个事件循环。这个线程内部由三个角色协作:

  • Reactor :事件循环的中枢,用 epoll 监听所有 fd,再把就绪事件 dispatch(分发)给对应角色;
  • Acceptor :专门处理客户端新连接,监听 fd 就绪时调用 accept 建立连接;
  • Handler :负责一条已建立连接上的网络 I/O 读写,依次完成 read、业务处理、send

很多人不理解这三个角色所处的位置,其实就是三个模块,职责分离,分别处理网络不同部分的功能。Acceptor 将建立连接和 Handler 处理网络 I/O 分离开来,实现解耦。但都处于同一个线程中。

对于 C 来说,它们是不同的结构体对象;C++ 来说,他们是三个不同的类对象。例如 Acceptor 类对象,会提供接口,处理客户端建立连接操作;Handler 类对象,会提供接口,处理客户端建连后的网络 I/O 读写以及业务处理。

它的运转是一个事件循环:Reactor 阻塞在 epoll_wait 上,一旦某个 fd 就绪就 dispatch 出去。

  • 如果是监听 fd 就交给 Acceptor 建连
  • 如果是已连接 fd 就交给 Handler 读写;处理完再回到 epoll 等待下一批事件。

这三个角色全都运行在同一个线程里。从接收客户端连接、到收发数据、最后业务处理,整条链路由一个线程依次执行。

Reactor、Acceptor、Handler 三个角色运行在一个线程里,从建连到回包全处理了。这既是它简单的原因。

优点是什么?

这种 Reactor 网络模型,结构最简单。只有一个线程,Reactor、Acceptor、Handler 三者之间都不需要加锁、也不存在多线程竞争和上下文切换的开销。代码好写、也方便排查问题。

适用场景:适合每个请求都能被极快处理完的场景。例如纯内存操作、命令在微秒级返回、几乎不涉及阻塞。这种需要每个请求绝不能做阻塞操作。只要单个请求足够快,一个线程处理 1 万个连接也不存在问题。

最典型的应用场景是 Redis

只用一个线程,瓶颈在哪?

单线程 Reactor 网络模型,存在两个问题:无法充分利用多核,以及慢请求会阻塞所有连接。

无法充分利用多核:单线程 Reactor 从头到尾只有一个线程在跑事件循环。放到一台 32 核的机器上,无论连接多密、流量多大,也只有 1 个核在工作,其他核处于空闲,机器的算力大半被闲置。

一个慢请求阻塞全部请求 :所有连接的事件都在这一个线程里依次处理。假设某个连接的处理函数要花 50ms 做业务逻辑,那么在这 50ms 里,Reactor 没法回到 epoll 去分发别的事件,其它连接即使数据早已就绪,也只能干等这 50ms 过去。

下面这张图,展示 Handler 里一段耗时业务时,如何阻塞整个事件循环。

从图中可以看到,症结在于只有一个事件循环,事件处理函数一慢,Reactor 就没法回去分发别的事件。

单线程 Reactor 存在的两个问题:既用不满多核、又怕慢请求。最直接的改进,就是多开几个线程,每个线程都运行一套完整的 Reactor

三、多线程 Reactor 网络模型

多线程 Reactor 是如何处理的?

可以开多个线程,每个线程内部都是一套完整的 Reactor,各有自己的 Reactor、Acceptor、Handler 和独立的 epoll

这些 Reactor 线程功能都是相同的。新连接到来后,由内核自己分发给其中一个线程,之后这条连接的 accept、读写、业务,都在这个线程自己的事件循环里完成。

那一个高并发服务器,开多少个 Reactor 线程合适呢? 这和 CPU 个数有关。通常有多少个 CPU 就开多少 Reactor 线程,这些线程通常也和 CPU 做亲和性绑定。

例如 32 核的机器可以开 32 个这样的线程,连接被分摊到 32 个事件循环上,多核就用满了。一个慢请求最多影响它所在的那个线程,波及面比单线程时小得多。

这里要特别区分一个概念:这些 Reactor 是对等的。每个线程都是一套完整的 Reactor + Acceptor + Handler,各自 accept、各自读写、各自跑业务,彼此没有分工。

多线程 Reactor 的优点

优点:把连接摊到多个事件循环上,既吃满了多核,又让单个循环的连接数大幅下降;一个慢请求的波及面,也从单线程时的「拖垮全局」缩小到「只影响自己所在的那个线程」。

适用场景:适合需要吃满多核、连接又能比较均匀分布的服务。因此高并发服务器,这种网络模型也经常被使用。

代表实现 :Nginx 的多 worker 就是这个思路。每个 worker 进程各跑一个独立事件循环、各自 accept 连接,彼此对等。

多核是用满了,但只要再往前追一步,一个绕不开的问题就冒出来了。

多线程 Reactor 网络模型存在哪些问题?

问题出在新连接的入口上。服务器对外只监听一个端口,也就是只有一个 listenfd。现在有多个对等的 Reactor,这个 listenfd 该注册到谁的 epoll 里?

惊群 :如果让多个 Reactor 都把这同一个 listenfd 注册进各自的 epoll,那么一个新连接到来时,内核会唤醒所有阻塞在这个 listenfd 上的线程。可连接只有一个,最终只有一个线程 accept 成功,其余线程被唤醒后发现无连接可取,只能重新回到阻塞状态。这种「唤醒一群、但只有一个线程能处理」的现象,就是惊群。连接建立越频繁,这种无效唤醒的开销越大。

这张图,就是多个对等 Reactor 争抢同一个 listenfd 时的惊群场面。

负载不均:如果每次只固定一个 Reactor,监听套接字上客户端的连接。建连高峰时,连接落到哪个 Reactor 缺乏调度,容易出现有的线程连接多、有的线程连接少,导致负载不均衡。

如何解决这两个问题呢?

对于惊群和负载不均衡问题,只存在于低版本的内核,如果使用低版本内核,这两个问题无法避免。

高版本 Linux 内核通过 SO_REUSEPORT 这个套接字选项解决了这个问题。

开启 SO_REUSEPORT 后,由内核根据客户端请求的四元组计算哈希,做负载均衡,挑选出一个 Reactor 线程,且只唤醒这一个 Reactor 线程。

多个 Reactor 线程监听同一个端口,那 fd 谁创建

这是一个比较重要的概念,很多人会忽略这个。如果你没写过网络库,基本上会搞错,因此有必要讲清楚。

在介绍谁负责创建监听 fd 之前,有必要引入一个概念,套接字选项 SO_REUSEPORT。这是一个非常重要的功能,高并发服务器负载均衡,以及惊群问题的解决,都靠它。

我们知道 TCP 三次握手,服务器监听套接字会使用一个半连接队列。

  • 收到客户端的第一个 SYN 包时,先将连接放到监听套接字的半连接队列

  • 收到客户端的第一个 ACK 包时,会将连接从半连接队列移动到全连接队列,此时三次握手完成,应用层调用 accept 就能获取到客户端的连接

多线程程序中,每个线程内部各自可以创建监听同一个 IP 与端口的套接字,这没问题。但如果每个线程然后对这个监听套接字进行 bind 操作,则是不允许的。这个时候就要靠 SO_REUSEPORT

SO_REUSEPORT 本身就是用于多个不同监听套接字 fd,绑定到同一个地址。一个线程要能多次调用 bind 同一个监听 IP 与端口,需要使用内核的 SO_REUSEPORT 选项才行。

开启 SO_REUSEPORT 后,每个监听套接字都有一条半连接队列。内核收到客户端连接后,会对四元组进行哈希,然后根据哈希值进行负载均衡,挑选出其中一个监听套接字,放到这个监听套接字的半连接队列中,然后唤醒监听套接字所在的线程。

多线程程序中,通常每个线程都可以创建监听套接字,并注册到该线程自己的 epoll 中。线程通常都会和 CPU 做亲和性绑定。当客户端连接来了,内核根据四元组做哈希,选中其中一个监听套接字,只会将连接放到这个监听套接字的半连接队列,然后唤醒在这个监听套接字所在的 CPU

其实 SO_REUSEPORT 不关心在哪个线程创建监听套接字,即使在同一个线程中,多次创建监听套接字也是可以的。同一个线程中多次创建监听套接字,也有多个半连接队列。

当多个监听套接字都在同一个线程中,此时收到客户端连接,也是会哈希到其中一个监听套接字,放到该监听套接字的半连接队列,然后唤醒该监听套接字所在的线程。只不过此时是单线程在跑,只会唤醒这个线程。不管是放到哪个监听套接字的半连接队列,最终唤醒的都是这个线程,无法利用 CPU 多核并发性能。

因此我们可以得出结论,SO_REUSEPORT 约束的是 「监听套接字」 在 bind 之前是否带上该选项,以及 是否允许多个监听套接字绑同一 (地址, 端口)。

由几个线程去调用 bind 并不重要:可以是多个线程各建各绑,也可以是一个线程顺序创建多个 socket,对每个都 setsockopt(SO_REUSEPORT) 再 bind/listen。

内核看的是每个监听套接字的选项与 bind 结果,不是「哪个 pthread 调用的」

SO_REUSEPORT 带来的"均衡",主要是指:新来的连接在多个监听套接字之间如何分配,这是内核在收包/建连路径里做的,不依赖你有几个用户态线程、也不依赖每个 fd 是否由不同线程 accept。

即使只有一个线程,只要多个监听 fd 都还在被使用(例如都挂在一个 epoll 上),新连接仍会被内核分散挂到不同的监听 fd 上;

所以:不能说"只有一个线程就无法负载均衡"

因此使用 SO_REUSEPORT,对每个监听套接字,最好每个线程各自监听一个套接字。而不是一个线程监听多个套接字,这属于误用。

不是 SO_REUSEPORT 能让每个监听套接字都有一个半连接队列。

需要注意的是,每个监听套接字各自都有一个半连接队列。不是 SO_REUSEPORT 让每个监听套接字都有一个半连接队列。这要区分开来:

只是如果创建多个绑定到同一个 IP 与端口的监听套接字,第二个监听套接字进行 bind 的时候,会报错,不让绑定。

有了 SO_REUSEPORT 就能绕开限制,进行绑定。因此能创建多个监听套接字,自然有多个半连接队列

在搞懂了 SO_REUSEPORT 后,回来看这个问题:"多个 Reactor 线程监听同一个端口,那 fd 谁创建?"

套接字可以在主线程中创建、也可以在各个 Reactor 线程中创建,这都可以,但还是有区别的。

如果在主线程中创建、则有多少个 Reactor 线程,就需要创建多少个监听套接字 fd,每个 Reactor 线程使用其中一个监听 fd,注册到 Reactor 线程自己的 epoll 中。

当有多个 Reactor 线程时,主线程不能只创建一个监听 fd。如果只创建一个套接字,后续各个 Reactor 线程将监听 fd 注册到 epoll 时,每个 Reactor 注册的都是同一个 fd,则收到客户端连接时会有惊群问题,即使你使用了 SO_REUSEPORT 也解决不了惊群问题,这是你自己用错了,不是内核机制有问题。

如果在 Reactor 线程中创建,则每个 Reactor 线程处理函数开始时,也是需要执行创建套接字、bind、监听套接字操作。注意: 是每个 Reactor 线程都执行这个操作,看着有点重复的感觉,但没有写错,真实就该这么操作。

因此最好是在主线程中创建,方便统一管理。例如有一个创建失败,则可以回收;而如果在 Reactor 线程中创建,存在部分 Reactor 线程创建成功,部分创建失败,不方便异常回收处理。

多线程 Reactor,每个线程是对等的。处理客户端建立连接请求、和已连接 fd 的网络 I/O 读写操作,依然混在一个线程里面。

因此网络模型进一步的演化,办法就是把它俩拆开。让一部分线程只负责接连接,另一部分只负责收发数据。顺着这个思路,就走到了最经典的主从 Reactor 网络模型。

四、主从 Reactor 网络模型

Main Reactor 和 Sub Reactor 怎么实现的?

主从 Reactor 把线程分成两拨,各司其职:

  • Main Reactor :内部是 Reactor + Acceptor。它只监听 listenfd,专门用于 accept 处理客户端新连接;接到新连接后不自己处理,而是派发给某一个 Sub Reactor。
  • Sub Reactor :内部是 Reactor + Handler。它不参与 accept,只负责已建立的连接。Main 派来的连接注册进它的 epoll 后,这条连接的 read、业务处理、send 都由它完成。

一条连接在 Main 处建立,通过线程间通信,注册交给某个 Sub,之后的读写与业务都在这个 Sub 内部完成,逻辑分离非常清晰。

这样一来,listenfd 只由唯一的 Main 持有,惊群问题不复存在;accept 和已连接读写被拆到不同线程,也不再互相争抢。

这一步拆分的是建立连接和网络 I/O 读写。建立连接由单独的 Main Reactor 负责;网络 I/O 读写,连接的关闭则由 Sub Reactor 负责。

需要说明一点:主从只是把单线程 Reactor 那套结构复制成了多份,再加上分工。每个 Main、每个 Sub 内部,仍然是一套完整的事件循环,运转方式和单线程时完全一致,改变的只是「谁负责建立连接,谁负责网络 I/O 读写」。

Main 和 Sub 线程负载均衡的算法有哪些?

Main 线程将 fd 分发给 Sub 线程,通常有以下几种方式:

  • 轮询
  • 加权轮询
  • 基于客户端的 IP 做哈希
  • 挑选负载最轻的 Sub 线程

Main 和 Sub 属于两个不同线程,如何通信?

首先通信的内容是文件描述符 fd。Main 线程建立连接后,需要将已连接 fd 传给 Sub 线程。

那 Main 线程如何将 fd 传递给 Sub 线程呢?有两种方式:

Main 线程直接调用 Sub 线程提供的接口:Sub 线程提供一个接口,Main 线程调用这个接口,把 fd 传进去。

接口内部将 fd 注册到 Sub 线程自己的 epoll 中。

好处是实现简单,缺点也很明显,无法解耦,Main 线程需要知道 Sub 线程的接口细节。

通过队列,Main 线程将 fd 放到队列中:每个 Sub 线程都维护了一个已连接 fd 队列。Main 线程将已连接 fd,通过一致性哈希、轮询等负载均衡方式,挑选出一个 Sub,将 fd 放到这个 Sub 的消息队列中。然后通过事件通知(eventfd),只唤醒其中一个 Sub 线程。

Sub 线程主循环中,无事件发生时,Sub 在 epoll_wait 上睡眠等待。事件发生时,主要做两件事情:

  • 处理网络 I/O 读写事件,连接关闭事件
  • 处理 Main 线程入队的消息,从消息队列中取 fd 进行注册处理。

收到 eventfd 事件通知,会从睡眠中唤醒,然后从队列中取出 fd,注册到 Sub 线程自己的 epoll 中。后续已连接 fd 的读写网络 I/O 事件只在这个 Sub 线程上处理

这个队列是单方向的,只需要从 Main 线程将 fd 放到队列,Sub 线程从队列取出 fd。 Sub 线程到 Main 线程这个方向,不需要有消息交互。因为 Sub 收到 fd 后,后续的网络 I/O 读写,连接的关闭都在这个 Sub 线程进行,和 Main 分离了。Main 线程负责建立连接然后分发 fd,后续的流程全部交给 Sub 线程处理

注意每个 Sub 线程各自维护了一个已连接 fd 队列,而不是全局共用一个队列。每个 Sub 线程各自维护队列,Main 线程写入,Sub 线程读取,无需加锁。

而如果全局队列,则单生产者多消费者,此时需要加锁。

业界高并发服务器,通常会使用第二种方式,通过队列通信。

优点是什么?

把 Acceptor 和 Handler 拆到不同线程,建连和收发彻底互不干扰,惊群也随之消失;连接派发还能挑一个空闲的 Sub,负载比对等模型均衡得多。

主从 Reactor 适用场景

适用场景:它是通用型服务器的默认选择------绝大多数长连接、中高并发的后端服务都适用。

代表实现:最典型的是 Netty,以及 Apache Traffic Server 这个 CDN 缓存服务器,它默认就是主从模型

单个 Main 会不会成为瓶颈?

会。Main 只有一个线程。对于海量短连接的场景,例如每秒新建几万条连接(大量 HTTP 短连接,或压测场景)。这时所有新连接都涌向唯一的那个 Main。Main 成了整个流程中建立连接的瓶颈,后面 Sub 线程却因为连接进不来而处于空闲状态。

从图里能看出,瓶颈转移到了 Main 线程,无法满足并发建立连接请求场景。

既然一个 Main 线程不够,那自然的解决方案是"把 Main 线程也变成多个",这就演进到多主从 Reactor 网络模型。

五、多主从 Reactor 网络模型

多个 Main 并行处理新连接,如何负载均衡?

一个线程处理客户端建连成为瓶颈,那就开多个 Main Reactor,每个 Main 各自 accept,相当于给服务器开出多个并行的入口。几万每秒的建连压力,被分摊到多个 Main 上,accept 不再是瓶颈。

服务器对外只监听一个「IP:端口」,多个 Main 怎么可能同时 accept 同一个端口?如果它们共享同一个 listenfd,那和多线程 Reactor 一样,惊群问题不就又回来了?

解决这个矛盾的关键,还是内核提供的一个 socket 选项:SO_REUSEPORT

没有 REUSEPORT 时 :多个线程共享同一个 listenfd。一个新连接的 SYN 到达,内核完成三次握手、把连接放进这个 listenfd 的全连接队列后,需要唤醒等待的线程,而它无法判断该唤醒谁,于是唤醒全部阻塞在该 listenfd 上的线程。这些线程一起去 accept,只有一个成功,其余全部无功而返,这就是惊群。

有 REUSEPORT 时 :内核为同一个「IP:端口」维护一组 listenfd,每个 Main 持有其中一个。新连接到来时,内核根据这条连接的四元组(源 IP、源端口、目的 IP、目的端口)做一次哈希,用哈希结果从这组 listenfd 中选定一个,把连接放进它的队列,然后只唤醒持有这个 listenfd 的那一个 Main。

这样带来两个好处:

  • 消除惊群:一个连接只唤醒一个 Main,不再有「唤醒一群、只有一个有效」的浪费;
  • 自带均衡 :不同连接的四元组不同,哈希会把它们比较均匀地分散到各个 listenfd 上,无需应用层自己实现分配逻辑,内核已经代劳。

下面这张图,把内核按四元组哈希、只投递给一个 listenfd 的分发过程画出来。

从图中可以看到,分发决策发生在内核、发生在连接刚进入的时刻,应用层无需介入。这里补一句边界:哈希基于四元组,因此它实现的是连接级别的均衡,不是严格的绝对平均;同一个客户端用同一个源端口发起的连接,可能一直落到同一个 Main。多数场景下这点偏差可以忽略。

Main 和 Sub 是如何通信的?

Main 和 Sub 通信的内容依然是文件描述符 fd。

每个 Sub 线程依然各自有一个连接队列。但连接不再是单生产者单消费者了,而是多生产者,单消费者。

Main 是生产者,Sub 是消费者,多个 Main 是可能选中同一个 Sub 的

每个 Main 线程,收到客户端连接后,通过一致性哈希等负载均衡算法,选中一个 Sub 线程,将 fd 投递到 Sub 线程的连接队列中。

优点是什么

多个 Main 并行接连接,配合内核的 SO_REUSEPORT,既解决了客户端建立连接的瓶颈,又从源头消除了惊群、自带负载均衡,是纯网络吞吐层面最能打的形态。

多主从 Reactor 网络模型的适用场景

适用场景:建连请求和网络 I/O 请求分离,非常适合海量短连接、超高频建连的场景。例如七层网关、接入网关、CDN 服务器等。并发场景下,单机 QPS 每秒 5 万以上建连请求,都能轻松处理。

目前 CDN 服务器通常就支持这种处理方案。以及很多基于 DPDK 的高性能网关,走的都是这条路。

客户端建连不再是瓶颈后,新的压力在哪?

  • 现在 Main 足够多、建立连接足够快
  • Sub 也足够多、收发足够快

客户端建连的瓶颈到此解决、网络收发包也不再成为瓶颈时,如果把继续加大流量,那新的压力在哪里呢?

业务逻辑仍然运行在 Sub 的 Handler 里。而 Handler 和 Reactor 同处一个 I/O 线程。

因此,业务逻辑不能执行耗时的业务,不然会阻塞事件循环。

一旦某个连接的 Handler 要做一次耗时的操作。例如慢 SQL、这个 Sub 线程就被阻塞。多主从 Reactor 网络模型,只是把「一个 Sub 被占住」的影响范围缩小了,并没有根治这个问题。

下面这张图,一个耗时业务阻塞所有连接。

从图里能看出,只要业务还和 I/O 处在同一个线程里,这个隐患就一直存在。

如何解决这个问题呢?必须把耗时业务从网络 I/O 线程里彻底独立出来,这就引出了最后一种网络模型,多主从 Reactor 加业务线程池网络模型。

六、多主从 Reactor + 线程池网络模型

在多主从 Reactor 网络模型基础上,加上 worker 线程池,就演进到工程里最完整的形态:多主从 Reactor 加业务线程池网络模型。

多个 Main 线程并行处理客户端连接、多个 Sub 线程并行收发数据、 worker 线程池统一处理耗时业务。

Sub 里的 Handler 此时只剩 readsend,等收发操作。

业务处理整段被挪到了 worker 线程池。客户端连接进得来、网络收发扛得住、业务不阻塞 I/O,这三个模块完成解耦,各司其职。

  1. 某个 Main 处理客户端新连接,然后将连接 fd 分发给某个 Sub ;
  2. 这个 Sub 读取数据、解码,把消息投递给 worker 池,随即返回自己的事件循环;
  3. worker 执行完,把结果回投给当初那个 Sub;
  4. 这个 Sub 取到结果,编码后把响应发送给客户端。

有个地方需要注意: 业务线程处理结果,要把结果投递给发起这次请求的那个 Sub,而不是任意一个 Sub。

因为这条连接的网络 I/O 读写,是注册到某个特定 Sub 线程的 epoll 中、发送缓冲区、接收缓冲区也由那个 Sub 维护,只有它才能安全地读写这条连接。

所以在跨线程通信时,消息里通常要带上「这条连接归属哪个 Sub」的信息,worker 才知道把结果送回给谁。

一次请求沿着这个流程推进:从 Main 进入,在 Sub 收发,到 worker 计算,再回投到原 Sub 发送。

到这里,Reactor 网络模型的演进就走到了它最成熟的形态,也是用得最多的一种 Reactor 网络模型。

下面就对着这张图里面涉及的内容进行展开分析:

为什么必须给 网络 I/O 线程配一个业务线程池?

网络 I/O 线程的职责只处理数据收和编解码:把数据读进来、解码,再把结果编码、发出去。这些操作都很快,不会长时间占用线程。

而真正耗时的操作------数据库查询、RPC 调用、复杂计算。不能放在网络 I/O 线程里执行。

解法是额外引入一个独立的业务线程池(worker 池)。

网络 I/O 线程读取并解码后,把这次要执行的业务打包成一个消息,投递给 worker 池;

worker 从消息队列中取出消息执行完,再把结果交回。

这样一来,无论业务耗时多长,都只占用 worker 线程,网络 I/O 线程始终保持轻量,可以随时响应其它连接的读写。

这里要记住一条分界线:I/O 归 I/O,计算归计算。要看清 Sub Reactor 和 worker 池如何工作。先抛开多主从的复杂度,用一个最简单的形态来讲解

Sub Reactor 和 worker 如何通信?

一次请求的流转是这样的:

  1. Reactor 线程的 epoll_wait 返回,某个连接可读;
  2. Reactor 线程调用 read 读出数据,完成协议解码;
  3. Reactor 线程把解码后的请求打包成任务,投递给 worker 池,随即返回 epoll_wait 继续处理别的连接;
  4. worker 池中的某个线程取出任务,执行耗时业务,得到结果;
  5. worker 把结果回投给 Reactor 线程,而不是自己发送;
  6. Reactor 线程取到结果,编码后调用 write 发送给客户端。

这里有一个最容易被忽略、却至关重要的点:

worker 不能直接写 socket。这条连接的读写状态和发送缓冲区都由 Reactor 线程维护,如果 worker 线程绕过 Reactor 直接调用 write,就会和 Reactor 线程对同一个连接并发操作。

所以结果必须回投原来的网络 I/O 线程。通过 eventfd 唤醒,或往 Reactor 的任务队列里放一条消息,由 Reactor 线程完成最后的发送。

响应包必须回到 Reactor 线程进行发送,好处是:

  • 虽然多了一次队列通信,但这点延迟相比加锁的开销,会小得多。
  • 而且 Reactor 线程和业务线程完全的解耦了。业务线程无需关心网络发送的具体细节。

reactor 线程和业务线程通过什么通信?

Reactor 线程和业务线程,使用队列通信。这里的队列是双向的。

Reactor -> 业务线程: Reactor 收包后,将报文放到 worker 线程的消息队列,业务线程从消息队列中取出消息进行处理。

业务线程 -> Reactor: 业务线程处理完业务,需要发送响应时。或者业务线程需要主动发送数据时,构造好报文,放到 Reactor 线程的消息队列。Reactor 线程从消息队列中取出报文进行发送。

因此每个业务线程各自有一个消息队列,给 Reactor 线程入队使用。这是一个多生产者,单消费者的队列。各个 Sub Reactor 线程是生产者,业务线程自己是消费者。

每个 Reactor 线程除了一个连接队列外,还有一个消息队列,给业务线程入队使用。这也是一个多生产者,单消费者的队列。各个业务线程是生产者,而 Sub Reactor 自身是消费者。

业务线程要如何才能感知到消息队列中有数据?

业务线程通常对收到的报文进行业务逻辑计算。但业务线程如何感知到消息队列中有报文要处理呢。有两种方式:

业务线程自身是一个死循环: 每次循环都会检查队列中是否有数据,有就从队列中取出消息进行处理。

感觉很 low 是吧,但目前各个大厂高性能云网络转发平台,基本上都是这么处理。单独隔离出一部分 CPU,CPU 利用率 100%,榨干 CPU 资源。业务线程一方面轮询来自网卡的报文,另一方面轮询投递给自己消息队列中的消息

通知事件通知唤醒 : worker 线程自身可以睡眠等待条件变量发生。Sub Reactor 线程通过条件变量唤醒 worker 线程。这是一种方式。

更主流的做法是,业务线程也使用 epoll。没有业务要处理时,睡眠阻塞在 epoll_wait 中,让出 CPU。

双方使用 eventfd 通信,或者可以使用管道。以 eventfd 为例,业务线程将 eventfd 注册到自己的 epoll 中。

当 Sub Reactor 线程往业务线程的队列中投递了消息后,接着往 eventfd 写入一个任意数字,唤醒业务线程。业务线程唤醒后发现事件通知 eventfd 可读了,读取完这个任意数字后,知道消息队列中有数据了,就可以取出消息。

Sub Reactor 线程如何感知到消息队列中有数据?

这和业务线程的处理逻辑是类似的,也是通过消息队列加上 eventfd。只不过方向变了。

业务线程往 eventfd 写入任意数据,唤醒 Sub 线程。

需要注意的是,这里的 eventfd 和业务线程中的 eventfd 不是同一个。

Sub 和 worker 线程通信,消息格式如何设计?

通常线程间通过队列进行通信。队列只有一个,但业务消息很多,每种类型的消息格式是不一样的,如何让公共的队列支持不同的消息呢?

方法很多,为了不过于发散,脱离主线,先分享 3 种方式。后续专门写一篇文章,详细介绍多线程通信消息格式的设计。

1、消息内容为函数指针以及一个指向任意结构的指针:

这种场景下,消息队列的数据结构就定下来了。消息队列中每种消息,格式为函数指针和指向任意结构的指针。

arduino 复制代码
typedef void (*fn_cb)(void* arg);

struct msg_t 
{
  fn_cb fn;
  void*     arg;
} msg_t;
  • 指向任意结构的指针: 每种类型的消息,都有自己的数据结构。通过这个指针,可以指向每个消息自己的数据结构。例如登录消息,有登录消息的数据结构;注册消息有注册消息的数据结构。
  • 函数指针: 和指向任意结构的指针一样,每种消息都各自有一个函数指针,用于解析每种消息自己的数据结构。

有多少种类型的消息,就有多少个函数指针和对应的数据结构。使得公共的消息队列可以支持任意的消息。

需要注意的是,指向任意结构的指针,不能在栈上分配。因为接收线程收到后,如果发现是栈上的数据,已经不能再访问了。因此生命期至少要到接收线程使用完成后。

有没有项目这么使用呢?目前 muduo 多线程通信,就采用这种方式。这十几年在深信服、腾讯、阿里所接触项目来看,也不少采用这种方案。

2、定义通用的消息,支持有限类型

这也是一种主流的做法。提前定义好所有需要的消息,每个消息一个结构。 union 中,每个结构体代表一种消息,具体使用哪种消息,由 type 这个消息类型决定。

ini 复制代码
typedef enum 
{
  MSG_LOGIN    = 0, //登录
  MSG_REGISTER = 1, //注册
} MSG_TYPE;

struct msg_t 
{
  MSG_TYPE type;         
  union 
  {           
    //登录消息
    struct login_msg
    {
        uint64_t user_id;
        char  passwd[16];
    };
    ......
  } u;
} ;

对于消息数量有限,通常会采用这种方案。

但不支持扩展,需要增加消息,则需要修改消息接口,在 union 增加一种消息结构。

另外,一旦消息类型膨胀到几百个以上,那这种方式就不合适了。

因此,对于消息类型量大的场景,会采用 TLV 消息格式。

3、TLV 消息格式,支持任意类型

TLV 是业界通用消息定义的简称。t 是消息类型,l 是消息长度,v 是 value 的简称。使用这种方式通信,消息队列中的消息,全部都是 msg_t 这种结构。消息队列只认这种消息结构,而不关心具体的消息内容。

每种消息 id 都对应一个消息结构,value 指针指向消息 id 对应的结构。

正因为 value 是通用类型指针,消息队列传递的消息,无需关注具体的消息内容。使得消息队列可以承载任意消息。

arduino 复制代码
typedef struct 
{
  uint32_t msg_id;      
  uint32_t len;     
  void*    value;     
} msg_t;

当要增加新的消息 id 时,也只需要定义新的消息结构,value 指向消息结构就行。消息队列根本不关心这些消息。扩展性就非常好。

需要注意的是 value 指向的数据结构,发送端进行分配,接收方负责释放。生命周期至少要维持到接收方使用完成后。不然接收方会使用了一个已经释放了的指针。

发送方每次发送都需要分配空间,接收方释放空间,对于高并发服务器,malloc 开销是很大的,改进方式是使用内存池。发送方从内存池中申请空间,接收方使用完毕后,不需要释放,只是放回到内存池。

TLV 这种方式,业界也用的很多,几乎稍微大一点规模的软件,都能看见它的身影。

消息队列哪种好,要如何选择?

要支持高并发,消息队列的性能也至关重要。

  • 首先是无锁队列。多线程通信,如果对队列加锁,性能必然大打折扣。
  • 接着是内存池,消息队列中的消息必须从内存池中分配,不然频繁的分配和释放,也是一笔不小的开销。

对于队列,通常有 4 种类型:

  • SPSC 单生产单消费
  • MPSC 多生产单消费
  • SPMC 单生产多消费
  • MPMC 多生产多消费

如果完全自己写一个无锁队列,该怎么处理,至少得考虑这么几个问题。

  • 首先需要考虑无锁实现,降低因为加锁而等待的性能开销
  • 接着是内存池,降低内存分配的开销
  • 另外得考虑是有界的消息队列,还是无界的消息队列。使用不当,无界队列将会导致内存暴涨。
  • 得考虑经典的 ABA 问题,引入 CAS 解决

自己来写,难度很大,要考虑的问题非常多,不太现实。业界已经有很多比较好的开源项目,可以拿来用。例如 DPDK 的 rte_ring 无锁队列,工业级标杆。

虽然有很多开源的实现,但你得了解实现一个无锁队列需要面临的问题,掌握了原理后,再去实现一个 demo 级的无锁队列练练手。

它的优缺点、适用场景和代表实现

优点:I/O 与业务彻底解耦,I/O 线程永远保持轻量,业务再慢也不会拖垮网络收发;三层(Main、Sub、worker)还能各自按需扩容,哪层是瓶颈就加哪层。

缺点:代价是复杂度和额外延迟。跨线程投递、结果回投带来上下文切换和任务排队,一次请求要在 I/O 线程和 worker 线程之间来回一趟;线程模型更复杂,线程数、队列长度这些参数也更讲究。

适用场景:适合业务处理耗时且不可控的场景------涉及数据库、远程调用、复杂计算的后端服务和 RPC 框架,这类场景绝不能让业务卡住 I/O。

不管是企业自己实现的,还是开源高并发框架,几乎都支持这种模型。

以 CDN 为例,网络线程处理客户端的请求,业务线程(也就是磁盘 I/O 线程)读取文件的内容,读取后交给网络线程发送给客户端。

总结

一连接一线程为什么扛不住高并发 QPS 请求,Reactor 又是怎么一步步演化为现在这个样子的。

我们把这条演进路线再过一遍,每一种网络模型都是为了解决前一个网络模型存在的问题。

形态 解决了什么 又暴露了什么
一连接一线程 写法简单 线程数被连接数绑死,阻塞在 read 上
线程池 省去线程反复创建销毁 线程仍阻塞在 read,没从根本解决
单线程 Reactor 一个线程抗上万连接 无法充分利用多核,一个慢请求阻塞全部
多线程 Reactor(对等) 用满多核,分散负载 争抢同一个 listenfd,惊群、负载不均
主从 Reactor 接连接与收发分家,消除惊群 单个 Main 的 accept 成为瓶颈
多主从 Reactor 多个 Main 并行 accept,靠 REUSEPORT 均衡 业务计算仍压在 Sub 的 I/O 线程里
多主从加业务线程池 I/O 与业务彻底解耦,各司其职 跨线程回投带来的复杂度

有两个地方容易混淆,需要分清:

  • 「每线程一个 Reactor」(对等)和「主从 Reactor」(分工)不是一回事。前者每个 Reactor 都是全能角色,各自 accept、各自读写;后者 Main 和 Sub 有明确分工。
  • 两次拆分,拆的对象不同。主从拆的是「建连和读写」,业务线程池拆的是「I/O 和计算」。

如果只记一句话,那就是这条主线:

先拆分连接与 I/O,再拆分 I/O 与业务。

写在最后

从一连接一线程,一路走到多主从加业务线程池,这些 Reactor 网络模型的演进过程,每个模型的出现,都是为了解决上一个模型的不足。

我尽可能站在读者的角度把每个网络模型的框架讲清楚,同时也回答读者看到网络模型后,可能会有的疑问。但为了不脱离文章主线,我没有过渡的去发散。

每一个网络模型,都没有那么简单,背后的细节还很多。没法在这一篇文章中全部讲透。

想必读者肯定还有很多问题。有任何疑问,我们评论区里交流。

相关推荐
YuePeng1 小时前
一个注解起家,25 个模块收尾:Erupt 的生态版图长这样
后端·github
睡觉时不困4421 小时前
7.从底层讲清楚Git仓库
后端
神奇小汤圆2 小时前
解放双手!SpringBoot 6种自动填充公共字的手段,这些代码真没必要手写!
后端
鱼人2 小时前
多态底层原理:向上转型、动态绑定,一文彻底搞懂
后端
神奇小汤圆2 小时前
吃透Redis缓存核心:淘汰策略、LRU/LFU区别与四大缓存问题全解
后端
无责任此方_修行中2 小时前
搓了一个国产大模型与 AI Agent 比价工具
前端·后端·ai编程
yunyi3 小时前
改个 SSH 端口,我把自己锁在了服务器外面
运维·后端
元界metalite4 小时前
MyBatis多数据源常见难题-事务开始了为何数据源还没选出来
后端·mybatis