网络层(一) why reactor and one-loop per thread

从零写一个高并发网络运行时(一):总体架构

从零写一个高并发网络运行时(一):总体架构

这是一个系列博客,记录我毕设项目 high-concurrency-runtime 的网络层设计与实现。 作为对照系,我会反复参考几个工业级实现:nginxenvoymuduo... 具体参考附录。 第一篇先讲清"为什么是 Reactor + One-Loop-Per-Thread"——这是后面所有故事的底座。


0. 一个最朴素的问题

我认为绝大多数人包括我, 在初次学习网络编程时都写过一个服务端客户端的程序。 服务端: 创建监听socket, 绑定端口, 启动监听, 接受连接, 处理业务逻辑, 关闭服务。 客户端: 创建连接socket, 配置并连接服务端, 处理读写, 关闭连接。

在最基本的两个TCP服务端与TCP客户端中, 服务端阻塞等待连接, 然后连接到了等待客户端写数据。 阻塞这时候可能是很糟糕的事情, 服务端被卡住了然后它无法处理新的连接。

当时的解决方案: 主服务端只处理连接问题, 读写单独开一个进程/线程。thread-per-connection模型

伪代码:

cpp
while (true) {
  int conn = accept(listen_fd, ...);
  std::thread([conn] { handle(conn); }).deatch();
}

这是一种好且容易想到的方案, 至少当时如此。

问题在于, 随着互联网的兴起,一个 HTTP 服务器,要支持 10K 并发连接,怎么做? Igor Sysoev: nginx 的作者, 在俄罗斯的一家公司上班时, 被 Appache 在高并发下进程数爆炸的情况逼疯了, 自此 nginx 诞生。

这是臭名昭著的 C10k 问题。 为什么一线程一连接的模型顶不住了?

  1. 进程: Linux 的文件句柄数量有限, 这意味着进程/线程开的数量有上限, 理论上并没有达到处理百万并发的数量。 真实的原因并不是这个, 这个最大值只是一个理论值。
  2. 内存: Linux 的线程 默认 8MB, 10k 连接是 80GB 的虚拟内存。 无论实际上只用了少数的虚存,这种吃内存的开销仍不容忽视。
  3. 调度: 内核级线程切换涉及 TLB 失效、缓存污染。 当线程数大于CPU的核心数量, 那么线程之间上下文的开销就很明显。 CPU 大部分时间花在切换而不是真实干活上!
  4. 竞态: 线程之间共享状态。 如何设计锁,原子变量,CAS, 无锁数据结构。 这是一个重要话题, 避免一个线程独占资源导致整个服务器性能急剧下降。

C10k 二十年前提出的核心矛盾。 现代高性能网络框架——从 nginx 到 envoy 到 Node.js——给出的答案都是同一个词:

事件驱动(event-driven)

具体落到设计模式上,就是 Reactor 模式


1. Reactor 是什么

提问: 一个线程只能处理一个连接吗?

Reactor 模式的核心只有一句话:

用一个线程,通过 I/O 多路复用,把"等"换成"被通知"。

不是"每个连接一个线程在 read() 上阻塞"——而是把所有 fd 注册到一个多路复用器(epoll/kqueue/IOCP)上,问内核:"这堆 fd 谁就绪了,告诉我。"内核返回就绪列表,应用线程依次处理。

最小化的 Reactor 循环长这样:

cpp
while (running) {
    events = poller.wait(timeout);   // 阻塞,但只阻塞一个线程
    for (event : events) {
        dispatch(event);             // 同步处理就绪的 fd
    }
}

就是如此简单。 所有复杂性——SubReactor、线程池、定时器、连接池——都是围绕这个循环的扩展。

1.1 Reactor 的四个角色

按 Schmidt 在 POSA2 里的定义,Reactor 模式包含四个角色:

角色职责本项目对应
Handle操作系统资源句柄(fd)int sockfd
Event Demultiplexer调用 epoll_wait 等待事件runtime::net::Poller(基类)+ EPollPoller
Event Handler事件回调接口runtime::net::Channel 持有的 read/write/close/error 回调
Reactor注册 Handler、运行事件循环、分发事件runtime::net::EventLoop

代码上一一对应(include/runtime/net/event_loop.h):

cpp
class EventLoop : public NonCopyable {
public:
  void Loop();                          // 事件循环主体
  void UpdateChannel(Channel* channel); // 注册 Handler
  ...
private:
  std::unique_ptr<Poller> poller_;            // Event Demultiplexer
  std::vector<Channel*> active_channels_;     // 本轮就绪的 Handler 列表
};

真实的EventLoop 循环。拿到内核通知就绪的fd连接, 然后处理

cpp
  while (!quit_) {
    active_channels_.clear();
    poll_return_time_ = poller_->Poll(kPollTimeMs, &active_channels_);

    for (Channel* channel : active_channels_) {
      channel->HandleEvent(poll_return_time_);
    }

    DoPendingFunctors(); // 跨线程投递队列
  }

总结: 单 Reactor就是从原来单线程处理连接读写, 改为注册fd, 等通知, 处理。 这样可以处理多个就绪的连接。


2. 为什么单 Reactor 不够:从 Reactor 到 Multi-Reactor

单 Reactor 的瓶颈很明显:只用一个 CPU 核

如果机器有 32 核,那剩下 31 个核都在闲着等单核处理完事件——这在 10Gbps 网卡和 100K+ QPS 场景下完全不可接受。

业界的两类扩展方案:

2.1 Multi-Process(nginx 流派)

nginx 用 master + worker 多进程 模型。master 进程创建监听 socket 后,fork() 出 N 个 worker 进程(N 通常是 CPU 核心数)。每个 worker 进程独立运行一个完整的 Reactor 循环,共享同一个 listen fd,靠内核的 accept() 互斥(旧 Linux 上还要用 accept_mutex 显式互斥避免惊群;新内核有 SO_REUSEPORT 后这个就不需要了)。

text
                                 ┌──────────┐
                                 │  master  │ (配置加载、信号、worker 监控)
                                 └────┬─────┘
                                      │ fork
                ┌───────────┬─────────┼─────────┬───────────┐
                ▼           ▼         ▼         ▼           ▼
            ┌───────┐   ┌───────┐ ┌───────┐ ┌───────┐   ┌───────┐
            │worker │   │worker │ │worker │ │worker │   │worker │ ...
            │epoll  │   │epoll  │ │epoll  │ │epoll  │   │epoll  │
            └───────┘   └───────┘ └───────┘ └───────┘   └───────┘

采用多进程而不是线程的优势在于, 进程之间天然隔离独占资源, 一个崩溃不能带崩其它。 缺点: 进程之间通信麻烦(共享内存); 进程比线程更重,启动更慢。 连接不能跨 worker 间迁移。

2.2 Multi-Thread(envoy / muduo 流派)

envoy 和 muduo 选择 多线程 + 多 EventLoop。一个主线程("Main Reactor")只负责 accept,N 个工作线程各运行一个 EventLoop("Sub Reactor"),新连接被分发到某个 Sub Reactor 上独占。

这就是 One-Loop-Per-Thread

text
              ┌──────────────────┐
              │   Main Loop      │  accept() 新连接
              │   (Acceptor)     │
              └────────┬─────────┘
                       │ round-robin 分配
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
   ┌─────────┐   ┌─────────┐   ┌─────────┐
   │ Sub Loop│   │ Sub Loop│   │ Sub Loop│ ...
   │ epoll   │   │ epoll   │   │ epoll   │
   │ conn[]  │   │ conn[]  │   │ conn[]  │
   └─────────┘   └─────────┘   └─────────┘

关键不变量:一条连接一旦分配给某个 Sub Loop,它的所有 I/O、状态、回调都只在那个线程上跑,直到关闭。线程之间不共享连接状态。

优点:单线程 简单,连接对象可以挂任何 C++ 对象;同一线程天然无锁和竞争。 缺点:线程崩溃带走全部连接;线程数固定,不像进程那样能用 cgroup 隔离。

2.3 本项目的选择

这个项目走的是 One-Loop-Per-Thread(受 muduo 影响很深)。原因有三:

  1. C++ 写多线程更顺手:一个进程一份配置、一份连接池、一份指标,比多进程方便得多。
  2. 目标场景是 API 网关:网关本身要持有大量上游连接池、健康检查状态、限流器,这些状态在多进程模型里需要复杂的共享内存或者 IPC,多线程模型里直接 shared_ptr + 锁。
  3. 作为毕设,更适合讲清楚 C++ 现代特性std::anyshared_from_thisstd::function 这些在多线程模型里有自然的舞台。

3. 四个对照系:异同表

把四个参照系按几个关键维度并排:

维度nginxenvoymuduolibevent本项目
并发模型多进程 + 单 Reactor/进程多线程 + 多 EventLoop多线程 + 多 EventLoop单 event_base(也可多)多线程 + 多 EventLoop
语言CC++14/17C++11CC++20
主要平台Linux/UnixLinuxLinux跨平台(含 Windows)Linux
多路复用抽象event module(epoll/kqueue/select 静态选)dispatcher(基于 libevent2)Poller 基类(运行时选)event_base + backend(编译期选)Poller 基类(运行时选)
内存管理池(ngx_pool_t标准 C++ + tcmalloc标准 C++C malloc/free标准 C++ + ObjectPool(定时器)
连接对象ngx_connection_t,池化复用ConnectionImplunique_ptrTcpConnectionshared_ptr用户管理TcpConnectionshared_ptr
模块/扩展编译期模块(phase handler)编译期 filter chain无(库性质)无(库性质)HTTP Router + std::any context
跨线程通信N/A(多进程,无共享)dispatcher::post(),eventfd 唤醒runInLoop,eventfd 唤醒event_active,pipe/socket 唤醒RunInLoop/QueueInLoop,eventfd 唤醒
定时器红黑树libevent min-heap红黑树min-heap侵入式红黑树 + ObjectPool
背压隐式(recv 节流)显式 watermark callback显式 HighWaterMark callback

几个值得展开的差异:

3.1 nginx:多进程 + phase handler

nginx 是这一类里最"古典"的——1999 年的设计,要在那个年代的硬件上扛 1 万连接,多进程是当时唯一靠谱的选择。它的 event module 是编译期决定的(./configure --with-poll_module),运行时不能切。

nginx 的 phase handler 模型把一个 HTTP 请求拆成 11 个阶段(POST_READ、SERVER_REWRITE、FIND_CONFIG……),每个阶段可以挂多个模块。这种设计优先稳定性和可调试性——nginx.conf 写错了配置不会让进程崩,因为模块是松耦合的。

我学到的:配置的稳定性比代码的优雅性重要。这一点这个项目目前还没做到——Router 配置是硬编码的,下一阶段要加配置文件解析时会回头借鉴。

3.2 envoy:service mesh 时代的 Reactor

envoy(2017)是这一类里最年轻的。它的 dispatcher 底层其实就是 libevent2,但在上层加了非常多东西:filter chain、connection manager、watermark buffer、circuit breaker、admin API……envoy 的代码量是 muduo 的几十倍,因为它要做的事情多得多。

envoy 最值得学的是 watermark buffer——也就是我刚加的 HighWaterMarkCallback 的原型。envoy 在每条连接的 read/write buffer 上都设有高低水位,触发时反压上游。本项目目前只在 TcpConnection 的写方向加了高水位,read 方向的反压还没做,这是下一步。

3.3 muduo:C++ 网络库的教科书

muduo(陈硕,2010)是本项目最直接的灵感来源。它把 One-Loop-Per-Thread 的思想做得极其干净:Channel/Poller/EventLoop/TcpConnection 四件套,几乎是后来所有 C++ 网络库的模板。

但 muduo 是 ,不是 运行时——它没有 HTTP 路由、没有负载均衡、没有限流熔断。这个项目相当于"muduo 风格的 net 层 + nginx 风格的 gateway 层"。

3.4 libevent:跨平台的代价

libevent 是这四个里唯一认真做跨平台的(支持 Windows IOCP、BSD kqueue、Solaris event ports)。代价是它的抽象层级比 muduo 多一层:event_baseevent → 用户回调。事件的注册是 C 风格的 event_new + event_add,灵活但容易写错。

我没有跟 libevent 那条路——本项目只跑 Linux,所以 Poller 抽象只是为了便于测试(poll/select 用来对照 epoll 的正确性),不为了真正可移植。


4. 一条连接的生命周期:本项目实现

讲完原理,看代码。一条 TCP 连接从 accept 到 close 在本项目里的流程:

几个关键设计:

4.1 跨线程通信靠 eventfd

Main Loop 把新连接交给 Sub Loop 时,不能直接调用 Sub Loop 上的函数——那是另一个线程,会撞数据竞争。正确做法是把 lambda 塞进 Sub Loop 的 pending 队列,然后写一个字节到它的 eventfd 上:

cpp
// EventLoop::QueueInLoop()
void EventLoop::QueueInLoop(Functor cb) {
  {
    std::lock_guard lk{mutex_};
    pending_functors_.push_back(std::move(cb));
  }
  if (!IsInLoopThread() || calling_pending_functors_) {
    Wakeup();   // write(eventfd, ...)
  }
}

Sub Loopepoll_wait 因为有 eventfd 上的可读事件而立刻返回,处理完正常的 IO 事件后调用 DoPendingFunctors() 跑队列里的 lambda。

这是 muduo 的发明,envoy 也用同样的机制(envoy 叫 Dispatcher::post)。libevent 用的是 pipe 而不是 eventfd(早期 Linux 没 eventfd),效果一样但开销略大。

4.2 shared_from_this 解决 use-after-free

TcpConnectionstd::shared_ptr 管理生命周期。Channel 里的回调持有 weak_ptr<void>(通过 Channel::Tie() 设置),在每次 HandleEvent 之前 lock() 一次:

cpp
// Channel::HandleEvent
void Channel::HandleEvent(Timestamp receive_time) {
  if (tied_) {
    std::shared_ptr<void> guard = tie_.lock();
    if (guard) {
      HandleEventWithGuard(receive_time);
    }
    // 如果 lock 失败,说明 TcpConnection 已被销毁,直接跳过
  } else {
    HandleEventWithGuard(receive_time);
  }
}

这解决了一个非常隐蔽的 bug:连接关闭后,epoll 队列里可能还有"已经过期"的事件等待处理。如果不做 tie,回调会访问已释放的内存。

nginx 不需要这个机制,因为 ngx_connection_t 是从池里分配的——池在 worker 进程生命期内不释放,所以指针永远有效,但代价是连接对象上的字段必须手动 reset。

4.3 状态本地化:std::any context_

每条 TcpConnection 有一个 std::any context_ 字段。HTTP 层在这上面挂 HttpContext(解析状态机),Gateway 在这上面挂上下游关联信息。所有这些状态都在连接归属的 Sub Loop 线程上访问,没有全局 map,没有锁

这是 One-Loop-Per-Thread 的精髓:让数据本来就不需要被并发访问,而不是用锁去保护它。


5. 这次写代码时被绊了几次

讲点真实的——架构图画起来漂亮,写代码时被以下几个问题折磨过:

5.1 Channel 的 Index/SetIndex 暴露问题

Channel 有一个 index_ 字段,记录它在 Poller 里的注册状态(新/已添加/已删除)。我最初把 Index()/SetIndex() 设为 public——结果 HTTP 层的代码就有人写了 channel_->Index() 来"查状态",但语义完全错了。

正确做法是把它 private,然后 friend 给三个具体 Poller 实现:

cpp
class Channel : public NonCopyable {
  ...
private:
  friend class EPollPoller;
  friend class PollPoller;
  friend class SelectPoller;

  int Index() const { return index_; }
  void SetIndex(int idx) { index_ = idx; }
};

教训:封装不是写 private,是约束接口的语义边界。

5.2 Send 失败时没有信号

最初 TcpConnection::Send 返回 void,连接关闭后调用 Send 静默丢弃——调用者完全无感知。这就是上周加 bool Send(...) 返回值的原因。配合新加的 HighWaterMarkCallback,上层可以:

cpp
if (!conn->Send(payload)) {
  metrics_.dropped++;          // 连接断了,丢的
}

conn->SetHighWaterMark(64 * 1024 * 1024);
conn->SetHighWaterMarkCallback([](auto& c, size_t n){
  LOG_WARN() << "slow downstream, buffered=" << n;
});

这套语义是从 envoy 的 watermark buffer 直接借的。

5.3 命名一致性

最容易被忽略但最容易爆炸的问题。Google C++ Style 规定成员变量后缀 _,但项目里曾经混了 ownerLoop_(驼峰)和 owner_loop_(snake_case)。每次新人/未来的自己来看代码都要确认一下:"这个项目到底用哪种风格?"

最近一次大清理后,全部统一为 snake_case_。这种事情做的时候很无聊,不做的话半年后会成为离职原因


6. 跑一个压测:把架构选择落到数字上

光讲原理没意思,跑组数据看看。

6.1 测试环境

CPUAMD EPYC 9754(虚拟化后 2 vCPU
内核Linux 6.1
服务端examples/demo_http_server(response body = "OK",2 字节)
客户端wrk 1 线程,HTTP/1.1 keep-alive
测试时长每组 10s(c=10000 那组 15s)

注意:2 vCPU 是个很挤的环境——wrk 自己也要占 CPU,所以 server 实际能拿到的只有约 1 个核。绝对数字不能跟生产机器比,但配置之间的相对差有参考价值。

6.2 ET vs LT 在小报文下基本打平

ET 环境变量分别设为 0/1,扫连接数:

并发连接ET=1 RPSLT RPS平均延迟(LT)
16743082451.16 ms
64776879343.92 ms
2567092806614.02 ms
10247616779847.52 ms

结果反直觉:LT 在所有并发档位都略快(2–14%)。

为什么?2 字节响应下,ET 模式"读到 EAGAIN 才停"的优势几乎不存在(每次 read() 就一个报文),但 ET 的事件分发逻辑(要在用户态处理"已经 ready 但没读完"的状态)反而多了几个分支。报文越小,ET 越没优势

预期是:响应变大(比如 100KB 文件)、或者高并发下大量 short-lived 连接(HTTP/1.0 风格),ET 才会反超。后面的 Buffer 一篇会用 100KB body 重测这个对比。

6.3 io_threads=1 比 io_threads=2 快 10–16%

这是这次跑数据最意外的发现。把 IO_THREADS 从 2 降到 1:

并发连接io=2 RPSio=1 RPS提升
6479349241+16%
25680669298+15%
102477988602+10%

直觉上"加线程加吞吐",实际上 thread > core 是反优化。原因:

text
io_threads=2 时的线程:
  - main loop (Acceptor + connections map)
  - sub loop A
  - sub loop B
  - wrk thread
合计 4 个活跃线程,争 2 个核 → 不停 context switch + cross-thread eventfd 唤醒
text
io_threads=1 时:
  - main loop
  - sub loop A
  - wrk thread
3 个线程,依然超 2 核,但少了一组跨 sub-loop 的 wakeup

教训:One-Loop-Per-Thread 的最优线程数 ≈ 物理核心数,超过就开始亏。nginx 默认 worker_processes auto 也是按 CPU 核数来的,envoy 的 --concurrency 默认值同理。我之前默认设 io_threads=2 是想"反正多线程总比少线程好",被数据打脸。

6.4 C10K 实测:1 万长连接

最后压一组 10000 keep-alive 连接(LT, io=2,跑 15s):

text
Running 15s test @ http://127.0.0.1:18080/
  1 threads and 10000 connections
    Latency   571.57ms  365.87ms   1.76s    69.56%
    Req/Sec     6.45k     1.23k    8.95k    64.29%
  90384 requests in 15.25s, 7.33MB read
Requests/sec:   5925.20

Socket errors: 0。1 万连接稳定挂着,吞吐降到低并发的 ~75%,没有连接被踢掉。这就是 epoll + One-Loop-Per-Thread 模型相对 thread-per-connection 的根本胜利——thread-per-connection 在这台 2 核 VM 上跑 10K 线程会直接 OOM。

p99 延迟 1.76s 不算好看,但这是 2 vCPU 的物理瓶颈——10K 连接竞争 1 个 sub-loop 线程的服务能力,没有魔法。

6.5 这些数字给后续设计的启示

  • 默认线程数应该 = nproc,但要让用户能调:后面 GatewayServer 的配置项需要暴露这个
  • 背压在这个量级还不是瓶颈:5925 RPS × 2 字节 ≈ 12KB/s,离 HighWaterMark 的 64MB 阈值远得很。等做完上游代理(响应可能几 MB)才会真正有戏
  • Poller 抽象(select/poll/epoll 可切)的开销可以忽略:上面的数字都是经过虚函数派发的,离 epoll 直接调用差距 < 1%。下一篇会用 perf 给出具体数

7. 收尾:这个系列接下来会写什么

按重要性排:

  1. 总体架构(本篇)
  2. Poller 抽象层——为什么保留 select/poll 而不是只用 epoll?跨实现的契约怎么定?
  3. Channel 与 fd 事件分发——Tie 机制的来龙去脉、ET vs LT 的取舍
  4. Buffer 的两种实现——muduo 三段式 vs nginx ngx_chain,本项目两套都有
  5. TcpConnection 生命周期——shared_ptr 模型 vs 对象池模型
  6. TimerQueue:红黑树 vs 小根堆 vs 时间轮
  7. 背压机制 HighWaterMark——和 envoy watermark buffer 的对比
  8. HTTP Router:Trie 实现——和 nginx phase handler 的对比

每一篇都会带:

  • 对应的源码引用(file_path:line_number
  • 至少一个 benchmark 数字
  • 对照 nginx/envoy/muduo/libevent 中至少一个的具体实现

如果你觉得哪个话题特别想看,告诉我,我可以调顺序。


参考资料

下一篇见。

md
<!-- 你可以在此处书写大纲,并在上方完成文章 -->
内存池仿Nginx_C++实现
KMP-(Knuth-Morris-Pratt)

评论区

评论加载中...