Day 03 / 共 20 天 · 第 1 周 架构与线程

线程模型(招牌设计)

Envoy 为什么这么快?核心答案就是今天的线程模型:1 个主线程 + N 个 worker 线程 + 线程本地存储(TLS),让请求处理的热路径完全无锁。这是 Envoy 最值得学的设计。

📍 第 1 周(架构与线程)· 你在这里(今天是招牌设计,最值得学)
D1 全景 D2 启动路径 D3 线程模型 D4 事件循环 D5 请求入口
L01

1 主 + N worker

🤔 痛点:多线程一起改同一份数据,会出什么事故? 最朴素的多线程做法:所有线程共享一份"集群列表/路由表"。一个线程正在读它转发请求,另一个线程同时在改它(配置更新)——读到一半数据被换掉,轻则转发到错误后端,重则程序崩溃。传统解法是"加锁":改之前把数据锁住,谁也别动。但锁一多,几十个线程互相等锁,快不起来。Envoy 每秒要处理百万请求,加锁这条路走不通。
💡 本质:与其"抢一份共享数据+加锁",不如"每人发一份副本"(今天的类比世界观:厨房) Envoy 的招牌解法:把厨房分成"主厨"和"多个厨师"主厨(主线程)只负责定菜单、备料(管配置),不炒菜;厨师(worker 线程,N 个)各自炒菜(处理请求),每人手里一份自己的菜谱副本。厨师看菜谱时(读配置)根本不用问别人、不用抢——因为是自己那份。这就把"加锁抢共享数据"变成了"各看各的副本",热路径彻底无锁。今天全程用这个"主厨+厨师+菜谱副本"的比喻。
分工:主线程"管理",worker"干活" 主线程(main thread):负责"管理"——加载/更新配置、维护集群列表、协调。它不处理业务请求worker 线程(N 个):负责"干活"——每个 worker 独立处理一批网络连接的请求。关键:配置的"读写分离"——只有主线程能改配置,worker 只读配置的本地副本。好比一个厨房:主厨(主线程)定菜单、备料(配置),多个厨师(worker)各自炒菜(处理请求),各用各的一份菜谱副本,谁也不用等谁、不用抢。这就是无锁的前提。
1 主厨 + N 厨师,每人一份菜谱副本(TLS) 主线程(主厨) 改配置 = 定菜单/备料,不炒菜 worker 0(厨师)独立事件循环📕 本线程菜谱副本 worker 1(厨师)独立事件循环📕 本线程菜谱副本 worker N(厨师)独立事件循环📕 本线程菜谱副本 配置更新时:主厨用 post 给每人"发一份新菜谱"(不是去抢改共享数据)
图注:读配置=看自己那份菜谱(零锁);改配置=主厨给每个厨师发新菜谱(post 投递,见 L06)。
L02

worker 数量

// source/server/options_impl.cc:82-83
// --concurrency 默认值 = std::thread::hardware_concurrency()(CPU 硬件线程数)
// :273 concurrency_ = std::max(1U, concurrency.getValue());  // 至少 1

// worker 创建 source/common/listener_manager/listener_manager_impl.cc:424
for (uint32_t i = 0; i < server.options().concurrency(); i++) {
  workers_.emplace_back(worker_factory.createWorker(i, ..., absl::StrCat("worker_", i)));
}
读法:worker 数默认 = CPU 核数(--concurrency 可调)。这样每个 CPU 核跑一个 worker,充分利用多核、又不过度切换。ListenerManager 构造时按这个数创建 worker。
L03

每 worker 独立

// source/server/worker_impl.cc:35 createWorker
Event::DispatcherPtr dispatcher(api_.allocateDispatcher(worker_name, ...));  // 每 worker 独立事件循环
auto conn_handler = getHandler(*dispatcher, index, ...);
// :52 WorkerImpl 构造时把自己的 dispatcher 注册进 TLS(非主线程)
tls_.registerThread(*dispatcher_, false);
// :145 threadRoutine(线程主体)
dispatcher_->run(Event::Dispatcher::RunType::Block);  // :156 worker 阻塞在自己的事件循环
每个 worker = 一个线程 + 一个独立事件循环 每个 worker 有自己独立的 Dispatcher(事件循环,Day 04),各自阻塞在自己的 run(Block) 里处理自己那批连接。worker 之间互不干扰、不共享可变状态。一个连接一旦被分配给某个 worker,它的整个生命周期都在这个 worker 线程里处理(不会跨线程跳)。这样单个连接的处理全程在一个线程内,无需为"连接状态"加锁。SO_REUSEPORT 让每个 worker 有自己的监听 socket 分片,内核帮忙分发新连接。
L04

ThreadLocal 核心

// envoy/thread_local/thread_local.h
class Slot {                                          // :21 一个 TLS 槽位
  virtual void set(InitializeCb cb) PURE;             // :62 在每个线程上分别构造该线程自己的对象
};
using InitializeCb = std::function<ThreadLocalObjectSharedPtr(Event::Dispatcher&)>;  // :61
template<class T> class TypedSlot {                    // :111 类型安全封装
  // operator-> / operator*(:161/:170)直接拿本线程对象
};
ThreadLocal(TLS)= "每个线程一个私人保险箱" 想让某份数据"每个线程各有一份、互不干扰"?用 TLS。你申请一个 Slot(槽位),然后 set() 一个"构造函数"——这个构造函数会在每个线程各跑一次,为该线程造一份属于它自己的对象。之后每个线程访问这个 slot,拿到的都是自己那份(operator->)。集群列表、路由表、连接池…这些都放在 TLS 里,所以每个 worker 读到的是自己的副本,读时零锁。这就是 Day 13 "线程本地集群"的底层机制。
L05

物理落点

// source/common/thread_local/thread_local_impl.cc:17 —— 真正的线程本地变量
thread_local InstanceImpl::ThreadLocalData InstanceImpl::thread_local_data_;
// ThreadLocalData(thread_local_impl.h:72)= {
//   Event::Dispatcher* dispatcher_;
//   std::vector<ThreadLocalObjectSharedPtr> data_;   // data_[slot_index] = 该 slot 在本线程的对象
// }
读法:C++ 的 thread_local 关键字保证 thread_local_data_ 每个线程一份。data_ 是个 vector,按 slot 索引存对象。读取时 data_[index] 直接访问——O(1) 无锁这一行 thread_local ... 就是整个无锁模型的物理基础。分配槽位 allocateSlot:27)只允许主线程调。
L06

set 跨线程更新

// thread_local_impl.cc:124 set 如何更新所有线程
// 对每个已注册线程 dispatcher.post(...),在各自事件循环里执行 setThreadLocal(index, cb(dispatcher))
// 主线程直接执行。—— 跨线程更新走 post(),不是加锁!

// runOnAllThreads(:179):把回调 post 到每个线程;带完成回调的版本
//   用 shared_ptr 自定义 deleter,在最后一个线程完成后回主线程触发回调
配置更新怎么"无锁"地传到每个 worker? 主线程算好新配置后,要更新到每个 worker 的副本。直接去改 worker 的数据(那要加锁),而是给每个 worker 的事件循环投递一个任务dispatcher.post):"等你有空时,把你自己的副本换成这份新的。"worker 在自己的线程里执行这个替换——因为是在它自己线程里改自己的数据,没有竞争、无需锁。这就是"用消息投递代替共享内存加锁"的并发哲学(Actor 模型的思想)。still_alive_guard_ + weak_ptr 确保 slot 已销毁时回调不会 use-after-free。
📝 举个例子:给某集群加一台后端,配置怎么传到 3 个 worker 运维把 order-cluster 从 2 台改成 3 台 → 主线程算出"新集群对象(含 3 台)" → 对 worker 0/1/2 各调一次 dispatcher.post(替换成新对象) → 三个 worker 在各自的事件循环里、有空时把本线程那份 shared_ptr 指向新对象。全程没有一把锁,正在处理请求的 worker 也不会被打断。
时刻主线程(主厨)worker 1(厨师)在干什么
t0算好新菜谱(3 台后端的集群对象)正用旧菜谱(2 台)转发请求
t1post 一个"换菜谱"任务到 worker1 队列还在用旧菜谱(任务只是排进队列)
t2去通知别的 worker手头请求处理完,事件循环第 6 步执行任务:本线程 shared_ptr 换成新对象
t3之后的请求读到新菜谱(3 台),全程零锁、无中断

👶 小白:主厨发新菜谱的瞬间,厨师正拿着旧菜谱炒菜,不会读到"改了一半"的乱菜谱吗?

👨‍🏫 老师:不会。关键在"换的是一个整体的新对象,而不是就地一个字段一个字段地改旧对象"。厨师要么还拿着完整的旧菜谱、要么已经拿着完整的新菜谱,绝不会拿到"改一半"的。而且"换指针"这个动作发生在厨师自己的线程里(不是主厨伸手进来改),所以天然没有竞争,也就不需要锁。这就是"不可变对象 + 消息投递"胜过"共享可变 + 加锁"的地方。

L07

无锁热路径

招牌设计一句话总结配置解析/更新只在主线程;主线程通过 Slot::set/runOnAllThreadsdispatcher.post 把"新配置对象"推送到每个 worker;worker 在自己的事件循环里替换本线程的 shared_ptr因此每个请求在 worker 内读配置/集群/路由,都是访问本线程私有对象——热路径完全无锁、无竞争

这是 Envoy 能在多核上近乎线性扩展、扛百万连接、且延迟稳定的根本原因。你在 Day 13(线程本地集群)已经见过它的应用,后面 Day 18(stats 无锁)还会再见。"读多写少的共享状态 → 每线程副本 + 消息投递更新" 是可以带走用到任何高并发系统的设计模式。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 主线程和 worker 线程各干什么?(管理 vs 干活)
  • worker 数默认多少?为什么?
  • 为什么单个连接全程在一个 worker 线程处理?
  • ThreadLocal 是什么?物理落点是哪一行?
  • 配置更新为什么用 post 而非加锁?"无锁热路径"如何达成?

✋ 动手

cd /Users/bitmart/work/codes/github/higress-group/envoy
grep -n 'thread_local InstanceImpl' source/common/thread_local/thread_local_impl.cc
sed -n '21,115p' envoy/thread_local/thread_local.h | head -50
sed -n '145,170p' source/server/worker_impl.cc
明天预告 · Day 04Dispatcher 事件循环——每个线程"阻塞在事件循环"到底在干什么?基于 libevent 的 event_base_loop、非阻塞 IO(event_assign)、单轮循环的步骤、post/延迟删除。
← Day 02 启动路径 Day 04 · Dispatcher 事件循环 →