进程模型:master + worker、单线程 reactor、为什么不用线程池
quicX 的执行骨架是 一个 master + N 个 worker,每个 worker 内部跑单线程 reactor。这是 QUIC 实现里相对常见的选择,但具体落到代码上仍然有几条不那么显然的设计取舍。本文尝试回答以下问题:
- master 与 worker 各自管什么,为什么不让 worker 自己
recvfrom; - 一个 UDP 数据报从 socket 到
BaseConnection::OnPackets()中间经过哪几层、跨几次线程; - 既然 worker 都是干活的,为什么不用线程池而要把每条连接绑死在一个 worker 上;
- 单线程 + 多线程两种部署模式共用同一套类层次时,哪些地方用了
*_with_thread.h、哪些地方退化为同线程。
阅读时建议同时打开 src/quic/quicx/master.h / master_with_thread.h / worker.h / worker_with_thread.h 四个头文件——它们一共 < 350 行,是整张图最值得花时间的部分。
1. 总览:一图看清五条线
Section titled “1. 总览:一图看清五条线”三色块对应三类角色:
- 🟧 IO 层:UDP socket(可能多个:listener、client、connection migration 临时 fd);
- 🟩 master 线程:
Master+ 唯一的IReceiver+ 唯一的EventLoop,只做收包与路由; - 🟦 worker 线程:N 份,每份持有自己的
EventLoop与Worker对象(实际类型是ServerWorker或ClientWorker),承载所有连接状态机。
两条粉色管道 connection_op_queue_ 与 packet_queue_ 是 master/worker 之间仅有的两条跨线程通道——除此之外两侧不直接共享任何可变状态。
2. master:只做”听 + 分”两件事
Section titled “2. master:只做”听 + 分”两件事”2.1 master 的职责清单
Section titled “2.1 master 的职责清单”src/quic/quicx/master.h:
class Master: public IMaster, public IPacketReceiver, public IConnectionIDNotify, public std::enable_shared_from_this<Master> { // ... void OnPacket(std::shared_ptr<NetPacket>& pkt) override; // IPacketReceiver
void AddConnectionID(ConnectionID& cid, const std::string& worker_id) override; void RetireConnectionID(ConnectionID& cid, const std::string& worker_id) override;
protected: bool ecn_enabled_; std::shared_ptr<IReceiver> receiver_; std::unordered_map<uint64_t, std::string> cid_worker_map_; // CID hash → worker std::unordered_map<std::string, std::shared_ptr<IWorker>> worker_map_; mutable std::mutex cid_map_mutex_; // 保护 cid_worker_map_};它只承担三件事:
| 职责 | 实现位置 | 关键点 |
|---|---|---|
| 持有 UDP socket、批量收包 | IReceiver(封装 recvmmsg/recvfrom) | 由 EventLoop::RegisterFd 挂在 master 自己的 loop 上 |
| 路由(CID → worker) | Master::OnPacket(master.cpp:94-139) | 哈希查表→分发;查不到时按包 DCID 哈希确定性选一个 worker(仅 Initial 包,见 §2.2) |
| 维护路由表 | cid_worker_map_(cid_map_mutex_ 保护) | worker 在收到 NEW_CONNECTION_ID / RETIRE_CONNECTION_ID 时反向通知 master 更新表 |
2.2 路由:Master::OnPacket 的核心逻辑
Section titled “2.2 路由:Master::OnPacket 的核心逻辑”void Master::OnPacket(std::shared_ptr<NetPacket>& pkt) { // ... PacketParseResult packet_info; if (MsgParser::ParsePacket(pkt, packet_info)) { std::shared_ptr<IWorker> worker; { std::lock_guard<std::mutex> lock(cid_map_mutex_); auto iter = cid_worker_map_.find(packet_info.cid_.Hash()); if (iter != cid_worker_map_.end()) { auto w = worker_map_.find(iter->second); if (w != worker_map_.end()) worker = w->second; // 已知连接:精确路由 } }
if (!worker) { // deterministic pick:按包 DCID 哈希取模选 worker(典型为 server 收到 Initial) size_t idx = packet_info.cid_.Hash() % worker_map_.size(); auto iter = worker_map_.begin(); std::advance(iter, idx); worker = iter->second; } worker->HandlePacket(packet_info); }}注意两点:
MsgParser::ParsePacket不解密、不解 frame,只剥短/长包头取出 DCID。完整解密在 worker 内做——即 master 永不接触加密 keys。这是把 keys 全部留在 worker 的关键约束,§5 会再讨论它的影响。- 陌生 CID 分支不是随机挑选,而是按包 DCID 哈希确定性路由:同一连接的 Initial 重传(复用同一 DCID)必须落在同一个 worker。曾经的
rand()实现在多 worker 下让重传 Initial 落到不同 worker、创建出重复ServerConnection,直接破坏握手。
2.3 路由表更新:worker → master 的反向通道
Section titled “2.3 路由表更新:worker → master 的反向通道”worker 在以下时机需要让 master 把新 CID 加到路由表:
- 服务端首次构造
ServerConnection给客户端发出的 SCID(master 后续就能收到带这个 CID 的包); NEW_CONNECTION_IDframe 从对端到达;- 连接迁移 / preferred_address 等场景。
worker 通过 IConnectionIDNotify 接口(if_worker.h:12-16)向 master 通知。在多线程模式下,这条调用路径是跨线程的:
// master_with_thread.cpp:61void MasterWithThread::AddConnectionID(ConnectionID& cid, const std::string& worker_id) { connection_op_queue_.Push({ADD_CONNECTION_ID, cid, worker_id}); // 入队(无锁竞争域) if (auto loop = event_loop_.lock()) loop->Wakeup(); // 唤醒 master}
// 由 master loop 在 FixedProcess 阶段消费(master 线程)void MasterWithThread::DoUpdateConnectionID() { ConnectionOpInfo op_info; while (connection_op_queue_.Pop(op_info)) { if (op_info.operation_ == ADD_CONNECTION_ID) Master::AddConnectionID(op_info.cid_, op_info.worker_id_); else Master::RetireConnectionID(op_info.cid_, op_info.worker_id_); }}这意味着多线程模式下 cid_worker_map_ 的所有更新都被收敛到 master 线程执行。在此之上,Master 还用 cid_map_mutex_ 把每次读写显式加锁(master.h:50-55)——这是一道硬保险:一旦有调用方绕过队列直接跨线程调用 Master::AddConnectionID,无锁的 unordered_map 并发访问在多 worker 压力下会导致错路由与随机握手失败。worker 侧完全不直接接触这张表,只能通过 connection_op_queue_ 间接更新。
3. worker:单线程 reactor + 全部连接状态
Section titled “3. worker:单线程 reactor + 全部连接状态”3.1 类层次
Section titled “3.1 类层次” IWorker (if_worker.h) common::Thread (common/thread/thread.h) │ │ ▼ │ Worker (worker.h) ┌──────── WorkerWithThread (worker_with_thread.h) ────┐ │ ↑ 持有 │ │ └─────────────── 持有 worker_ptr_ ────────────────────────────────────────┘ ├── ServerWorker (worker_server.h) —— Retry / IP rate limiter / handshake watchdog └── ClientWorker (worker_client.h) —— version negotiation / connect timeoutWorker 是真正持有连接的”业务核心”,WorkerWithThread 只是把它包进一个独立线程 + EventLoop 的薄壳。在单线程模式下根本不构造 WorkerWithThread,Worker 直接挂在 master 的 EventLoop 上跑(见 §4)。
3.2 worker 的核心成员
Section titled “3.2 worker 的核心成员”worker.h:83-116:
bool do_send_;bool ecn_enabled_;bool enable_key_update_;uint32_t quic_version_;std::string worker_id_;
std::shared_ptr<ISender> sender_;std::shared_ptr<TLSCtx> ctx_;
common::DoubleBuffer<std::shared_ptr<IConnection>> active_send_connections_;std::unordered_set<std::shared_ptr<IConnection>> connecting_set_;std::unordered_map<uint64_t, std::shared_ptr<IConnection>> conn_map_;
connection_state_callback connection_handler_;std::weak_ptr<common::IEventLoop> event_loop_; // observer,不持有RegisterSocketCallback register_socket_cb_;关键点:
conn_map_是 worker 私有,按 CID hash 索引连接;只有当前 worker 线程访问。active_send_connections_用 double buffer:一个 buffer 当前帧”待发送 conn”,另一个 buffer 给”产生新发送需求的 conn”挂钩——Worker::Process()在每轮 EventLoop 收尾时 swap 两块缓冲区,避免在遍历 send 列表时再次插入造成 invalidation。event_loop_用weak_ptr:所有权属于QuicClient/QuicServer,worker 只是 observer。这一选择与BaseConnection::event_loop_同因——见ownership_and_memory.md。
3.3 worker 的主循环(多线程模式)
Section titled “3.3 worker 的主循环(多线程模式)”worker_with_thread.cpp:40-58:
void WorkerWithThread::Run() { auto loop = event_loop_.lock(); if (!loop || !loop->Init()) { ready_promise_.set_value(false); return; } ready_promise_.set_value(true); // 唤醒主线程:worker 已就绪
while (!Thread::IsStop()) { loop->Wait(); // ① epoll_wait(带最近一个定时器的 deadline) ProcessRecv(); // ② 把 packet_queue_ 里的包喂给 worker_ptr_->HandlePacket if (worker_ptr_) worker_ptr_->Process(); // ③ 触发 worker 的 ProcessSend / 心跳 }}Wait() 一次循环里做三件事(event_loop.h:30):
- 跑到期定时器;
epoll_wait至下一定时器 deadline;- dispatch IO 回调 + drain
PostTask队列。
ProcessRecv 按预算批量 drain(预算 = kMaxRecvBatch,即 64 个包):
// worker_with_thread.cpp:73void WorkerWithThread::ProcessRecv() { // ... const uint32_t kDrainBudget = kMaxRecvBatch; PacketParseResult packet_info; for (uint32_t i = 0; i < kDrainBudget; ++i) { if (!packet_queue_.TryPop(packet_info)) { return; // 队列已空:正常返回 } worker_ptr_->HandlePacket(packet_info); } // 预算用尽但队列非空:主动 Wakeup,下一轮 loop 继续 drain if (!packet_queue_.Empty()) { if (auto loop = event_loop_.lock()) loop->Wakeup(); }}为什么是”预算 + 再唤醒”,而不是两个极端?因为两个极端都被实践否决过:
- 单次 TryPop(旧实现)会塌缩吞吐:
UdpReceiver::OnRead()一批最多收 64 个包并逐个Wakeup(),但 kqueue 的 wakeup pipe 会被一次read()全部喝掉——64 次生产者唤醒塌缩成 1 次消费者唤醒。实测服务端每 ~1.3s 才处理 64 个包,客户端 Initial 在队列里排 ~9s 后 PTO 三次放弃(已修复); - 无限 drain 会饿死定时器:连续来 1 万个包时,定时器与
Worker::Process()永远等不到执行机会。
预算 64 恰好对齐收包批量;预算用尽且队列不空时再主动 Wakeup(),在”及时清空队列”与”定时器公平”之间取得平衡。
3.4 入口排队:packet_queue_
Section titled “3.4 入口排队:packet_queue_”worker_with_thread.h:46:
common::ThreadSafeBlockQueue<PacketParseResult> packet_queue_;ThreadSafeBlockQueue(common/structure/thread_safe_block_queue.h)就是 mutex + std::queue + condition_variable 的最朴素实现。Push/Pop 路径都是 O(1),但每次都要 lock。这里没有用 lock-free MPSC ring buffer,是有意的:
- 生产者只有一个(master 线程),消费者也只有一个(worker 线程)——SPSC 场景;
- 但即便在 SPSC 下,使用 mutex 的吞吐也已经 > 10⁶ 包/秒,远高于真实 QUIC 包率(万级到十万级);
- 节约的复杂度(不需要写 lock-free 代码、不需要处理 ABA)远比省下的几百 ns 重要。
当你在生产性能场景下需要把这里换成 SPSC ring buffer 时,接口边界是清晰的:只需要替换 packet_queue_ 的实现类型即可,HandlePacket / ProcessRecv 调用点都不变。
4. 单线程 vs 多线程:同一套类的两种部署
Section titled “4. 单线程 vs 多线程:同一套类的两种部署”quicX 同时支持两种部署模式:
| 模式 | master 类 | worker 类 | EventLoop 数 | 线程数 |
|---|---|---|---|---|
| 单线程 | Master | Worker(直接,不裹 WorkerWithThread) | 1 | 1 |
| 多线程 | MasterWithThread | WorkerWithThread 包 Worker | N+1 | N+1 |
4.1 单线程模式
Section titled “4.1 单线程模式”master 与 worker 共享同一个 EventLoop。worker 通过 event_loop_->AddFixedProcess(shared_from_this(), Worker::Process) 挂为定时回调。Master::OnPacket 解出 PacketParseResult 后直接同步调用 worker->HandlePacket(...)——根本不进 packet_queue_。
这种模式下完全没有跨线程通道,但所有 work 串行:UDP recv、解包、HandlePacket、connection 状态机、加密、发包都在同一线程。它的最大价值是简化生命周期——event_loop_ weak_ptr 锁不到的窗口几乎不存在,单元测试与本机 example 可以专注业务逻辑。
4.2 多线程模式
Section titled “4.2 多线程模式”master 与每个 worker 各自跑一个 Thread + EventLoop。Master::OnPacket 解出包后调用的是 WorkerWithThread::HandlePacket:
// worker_with_thread.cpp:33void WorkerWithThread::HandlePacket(PacketParseResult& packet_info) { packet_queue_.Emplace(std::move(packet_info)); // 跨线程入队 if (auto loop = event_loop_.lock()) loop->Wakeup(); // 唤醒目标 worker}注意这里 WorkerWithThread 既是 Thread 也是 IWorker:master 看到的”worker”实际是它,业务核心 Worker 被它持有。Master::OnPacket 不需要知道当前是单线程还是多线程,分发时调用的都是 IWorker::HandlePacket——多线程模式由 WorkerWithThread 的覆盖把它转为入队 + Wakeup。
Master 自己也有同样结构:MasterWithThread 继承 Master + Thread,把 Master::Process 也挂成 FixedProcess 在自己的线程跑。master_with_thread.cpp:37:
loop->AddFixedProcess(shared_from_this(), [this]() { Process(); });4.3 选择哪种模式
Section titled “4.3 选择哪种模式”QuicClient / QuicServer 构造时根据 QuicConfig::worker_thread_count 决定:
worker_thread_count == 0(默认)→ 单线程模式(master 与唯一的 worker 共享 EventLoop);worker_thread_count >= 1→ 多线程模式(master + N 个独立 worker 线程)。
5. 为什么不用线程池?
Section titled “5. 为什么不用线程池?”这一节回答的是:既然有 N 个 worker 线程,为什么不简单地把每个数据包丢进一个工作池抢?
5.1 连接亲缘绑定(thread affinity)
Section titled “5.1 连接亲缘绑定(thread affinity)”QUIC 连接是有状态的——一条连接拥有:
- TLS 握手状态机 + 加密 keys(4 个 packet number space × 2 方向);
- 拥塞控制状态(cwnd / pacing / RTT 估计);
- 流量控制窗口(per-stream + connection-level);
- 一组定时器(idle / loss detection / PTO / handshake watchdog);
- 数十条 stream 的收/发缓冲与排序队列。
如果两个数据包进了不同 worker,则要么所有以上状态全用锁保护(QUIC 高频事件下锁竞争极重),要么必须有跨线程的状态同步协议(复杂度爆炸)。
quicX 选择第三条路:CID hash → 固定 worker。一条连接一旦在某个 worker 上建立,就永远只在那个 worker 上处理。这条 thread affinity 由 cid_worker_map_ 强制保证:master 路由后包一定落到曾经登记过这个 CID 的 worker。
5.2 worker 内单线程 reactor 的好处
Section titled “5.2 worker 内单线程 reactor 的好处”绑定到单线程后:
- 无锁化:worker 内所有
Worker::conn_map_/ 每条BaseConnection/ 每条Stream都是单线程访问,整个 worker 内没有一把 mutex(除了入口的packet_queue_); - 状态推进顺序确定:A 包先到、B 包后到,那么 A 触发的 cwnd 更新一定先于 B 看到的 cwnd;这对 loss recovery / pacer 这类对事件顺序敏感的算法是天然保护;
- EventLoop 的
AssertInLoopThread()守住边界:在 worker 内调用EventLoop::AddTimer/RegisterFd等会断言当前线程是 loop 线程,任何错误调用直接 abort,bug 暴露在第一现场(见if_worker.h:50-57Shutdown 段的注释)。
5.3 线程池的代价
Section titled “5.3 线程池的代价”如果改成线程池,QUIC 实现会被迫做以下其中之一:
- 给每条连接加大锁——高频包路径下锁竞争 + cache line bouncing 会让吞吐下降 5×;
- 给每个状态字段加细粒度锁——复杂度爆炸,几乎不可能不出 race;
- 给每条连接维持”事件队列 + worker 抢占”——这等价于每条连接退化为单线程,但管理开销更高。
工业实现里 NGINX、HAProxy、msquic、quiche 全都是 CID 亲缘 + 单线程 reactor,原因相同。
5.4 代价:负载不均
Section titled “5.4 代价:负载不均”亲缘绑定的代价是 N 个 worker 之间负载可能不均——CID 哈希分布近似随机但样本小时方差大。当某些连接是大流量长连接(音视频流)时尤其明显。
quicX 当前用的是 cid.Hash() → worker_id 直接哈希到字符串,没有做带权再均衡。在学习实现里这是合理的简化;生产实现会做的是连接迁移触发 worker 切换 + master 维护 per-worker load 计数——这部分 quicX 留给将来扩展(无 TODO 也无 FIXME,只是边界开放)。
6. 端到端走查:一条 UDP 报文从 socket 到 frame
Section titled “6. 端到端走查:一条 UDP 报文从 socket 到 frame”以多线程服务端模式为例,跟踪一条携带 STREAM frame 的 1-RTT 包:
① master 线程 · UDP socket 可读 epoll_wait 唤醒 → IReceiver::ReceiveBatch(recvmmsg) 取出 N 个 NetPacket ↓② Master::OnPacket(pkt)(每包一次) · ParsePacket:剥包头取 DCID · cid_worker_map_.find(DCID.Hash()) → "worker_id_3" · worker_map_["worker_id_3"]->HandlePacket(packet_info) ↑ 这里实际类型是 WorkerWithThread ↓③ WorkerWithThread::HandlePacket(master 线程仍在执行) · packet_queue_.Emplace(std::move(packet_info)) [跨线程入队] · loop->Wakeup() [pipe write 唤醒 worker] ↓ ━━━━━━━━━━━━━━━ 线程切换:master → worker #3 ━━━━━━━━━━━━━━━ ↓④ Worker 线程 · EventLoop::Wait 返回 ↓⑤ WorkerWithThread::ProcessRecv · packet_queue_.TryPop(packet_info) · worker_ptr_->HandlePacket(packet_info) ↑ 这里实际类型是 ServerWorker(继承 Worker) ↓⑥ Worker::HandlePacket → ServerWorker::InnerHandlePacket · conn_map_.find(DCID.Hash()) → BaseConnection* · conn->OnPackets(...) ↓⑦ BaseConnection 内部分发 (后续路径详见 packet_lifecycle.md / connection_anatomy.md §5) · DecryptPacket → FrameProcessor → StreamManager → 触发 SendControl::OnPacketAcked / OnPacketSent ... · 任何想发回的数据通过 active_send_connections_ 登记 ↓⑧ Worker::Process(同一轮 loop 收尾时执行) · double buffer swap · 遍历 active 连接 → conn->BuildPackets() → sender_->Send(...) ↓⑨ sender_->Send 把 datagram 写回 UDP socket (注意 sender_ 是共享的,多 worker 并发写同一 socket 由内核保证原子性)整个路径只有 ②→③ 与 ⑨ 是真正的跨线程点:前者是 master→worker 单向投递,后者是多 worker 同时调用一个 sender 写 socket。worker 内部从 ⑤ 到 ⑧ 全部串行单线程,无锁。
7. 关键不变量
Section titled “7. 关键不变量”写代码 / 读代码 / 改代码时永远成立的事实:
cid_worker_map_由cid_map_mutex_保护——多线程模式下 worker 必须通过connection_op_queue_间接更新它(更新被收敛到 master 线程执行);绝不能在不持锁的前提下直接持有Master*指针调AddConnectionID。- 每条连接终身绑定一个 worker——CID 路由表只追加 / 删除条目,不”迁移”条目(连接迁移在 QUIC 协议层是地址迁移,不是 worker 迁移)。
- worker 内无锁 ——
Worker::conn_map_/BaseConnection/Stream三层都假设单线程访问。任何想从 worker 外触达连接状态的代码必须走EventLoop::PostTask。 - EventLoop 的所有权属于宿主——
QuicClient/QuicServer创建并独占shared_ptr<IEventLoop>,master/worker/connection/stream 全用weak_ptr引用。这避免循环引用。 - 入口队列只有一个——
packet_queue_。除了它,master 与 worker 不共享任何可变状态。connection_op_queue_是反方向的小通道,承载的不是热数据。 - master 永不接触加密 keys——它只剥包头取 CID。任何解密路径必须在 worker 内完成。
8. 关联文档
Section titled “8. 关联文档”packet_lifecycle.md—— 第 ⑦ 步进入BaseConnection::OnPackets()之后的路径。connection_anatomy.md—— worker 持有的BaseConnection子树结构。ownership_and_memory.md—— 为什么Worker::event_loop_用weak_ptr、为什么Worker::Shutdown()必须在 join 后调用。timer_design.md——EventLoop::Wait内部用的双层定时器机制。metrics.md—— per-worker 队列深度 / 包率指标怎么暴露。
9. 关联 RFC
Section titled “9. 关联 RFC”进程模型本身不属于 RFC 9000 范围——QUIC 协议不规定实现的并发模型。但以下条款会影响这里的设计:
- RFC 9000 §5.1 Connection ID:
cid_worker_map_路由表的合法性来源; - RFC 9000 §5.2 Matching Packets to Connections:Initial 包用 server-chosen DCID 之前需要一次”陌生 CID”分发,对应 §2.2 的 random pick 分支;
- RFC 9000 §9 Connection Migration:地址迁移期间 CID 不变,所以 worker 不需要迁移;这正是亲缘绑定能与 migration 共存的关键。
