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)各自炒菜(处理请求),各用各的一份菜谱副本,谁也不用等谁、不用抢。这就是无锁的前提。
图注:读配置=看自己那份菜谱(零锁);改配置=主厨给每个厨师发新菜谱(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 台)转发请求 |
| t1 | post 一个"换菜谱"任务到 worker1 队列 | 还在用旧菜谱(任务只是排进队列) |
| t2 | 去通知别的 worker | 手头请求处理完,事件循环第 6 步执行任务:本线程 shared_ptr 换成新对象 |
| t3 | — | 之后的请求读到新菜谱(3 台),全程零锁、无中断 |
👶 小白:主厨发新菜谱的瞬间,厨师正拿着旧菜谱炒菜,不会读到"改了一半"的乱菜谱吗?
👨🏫 老师:不会。关键在"换的是一个整体的新对象,而不是就地一个字段一个字段地改旧对象"。厨师要么还拿着完整的旧菜谱、要么已经拿着完整的新菜谱,绝不会拿到"改一半"的。而且"换指针"这个动作发生在厨师自己的线程里(不是主厨伸手进来改),所以天然没有竞争,也就不需要锁。这就是"不可变对象 + 消息投递"胜过"共享可变 + 加锁"的地方。
L07
无锁热路径
招牌设计一句话总结:配置解析/更新只在主线程;主线程通过
这是 Envoy 能在多核上近乎线性扩展、扛百万连接、且延迟稳定的根本原因。你在 Day 13(线程本地集群)已经见过它的应用,后面 Day 18(stats 无锁)还会再见。"读多写少的共享状态 → 每线程副本 + 消息投递更新" 是可以带走用到任何高并发系统的设计模式。
Slot::set/runOnAllThreads 用 dispatcher.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 04:Dispatcher 事件循环——每个线程"阻塞在事件循环"到底在干什么?基于 libevent 的
event_base_loop、非阻塞 IO(event_assign)、单轮循环的步骤、post/延迟删除。