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

Dispatcher 事件循环

每个线程"阻塞在事件循环"里到底在干什么?今天拆开 Dispatcher——基于 libevent 的事件驱动引擎,非阻塞 IO 的核心。这是"单线程扛海量连接"的秘密。

📍 第 1 周(架构与线程)· 你在这里(拆开昨天说的"阻塞在事件循环")
D1 全景 D2 启动路径 D3 线程模型 D4 事件循环 D5 请求入口
L01

事件驱动

🤔 痛点:一个 worker 要盯着 1 万条连接,怎么盯得过来? Day 03 说每个 worker 线程管一批连接。可一条连接不是随时有数据——它可能空闲几秒才来一个包。如果 worker "站在每条连接前傻等它来数据",1 万条连接就得 1 万个线程轮流等,切换开销爆炸。怎么让一个线程高效地"同时盯住"上万条大部分时间在发呆的连接?
💡 本质:不主动傻等,而是"谁就绪了叫我"(今天的类比世界观:餐厅服务员) 答案是事件驱动:worker 不逐个连接去问"你有数据没",而是把所有连接的"就绪通知"交给操作系统,自己只处理"已经就绪"的那几条。就像一个服务员看几十桌:不站在一桌前等他们慢慢点,而是哪桌举手(就绪)就去哪桌。这个"不停看哪桌举手、去处理、再看"的循环,就是今天的主角 Dispatcher 事件循环
阻塞 vs 事件驱动 传统写法:一个线程处理一个连接,读数据时"阻塞"等着(read() 卡住直到有数据)。1 万个连接就要 1 万个线程——内存爆炸、切换开销巨大。事件驱动完全不同:一个线程管上万个连接,用"非阻塞 IO + 事件循环"——不傻等,而是问操作系统"哪些连接有数据可读了?",只处理就绪的那些。好比餐厅一个服务员看着几十桌:不站在一桌前等他们点完,而是哪桌举手(就绪)就去哪桌。这就是 Envoy 单个 worker 能扛海量连接的原因——事件循环(Dispatcher)就是那个"看着所有桌子的服务员"。
L02

Dispatcher 接口

// envoy/event/dispatcher.h:112
class Dispatcher : public DispatcherBase, public ScopeTracker {
  FileEventPtr createFileEvent(...);        // :129 监听 fd 的可读/可写事件
  TimerPtr createTimer(TimerCb cb);         // :136 定时器
  ServerConnectionPtr createServerConnection(...);  // :203 服务端连接
  void deferredDelete(DeferredDeletablePtr&&);       // :234 延迟删除
  void run(RunType type);                   // :272 跑事件循环
  void post(PostCb);                        // :73(DispatcherBase)线程安全投递任务
};
enum class RunType { Block, NonBlock, RunUntilExit };  // :264
读法:Dispatcher 提供三类能力:注册 IO 事件(fd 就绪)、定时器、跨线程投递任务(post)。run(Block) 启动循环。post线程安全的(Day 03 跨线程更新就靠它),其余方法只能在本线程调(大量 ASSERT(isThreadSafe()))。
L03

libevent 底座

// source/common/event/libevent_scheduler.cc
// :18 构造 event_base_new() 建立 libevent 事件基
// :47 run() 本质就是 event_base_loop(libevent_.get(), flag)
//   Block 用默认阻塞、NonBlock 非阻塞、RunUntilExit 用 EVLOOP_NO_EXIT_ON_EMPTY
// :64 loopExit = event_base_loopexit
libevent 是什么? libevent 是一个成熟的 C 事件通知库——它封装了操作系统的 epoll(Linux)/kqueue(Mac)等高效 IO 多路复用机制,提供跨平台的"告诉我哪些 fd 就绪了"能力。Envoy 不重造轮子,而是站在 libevent 上:Dispatcher 是对 libevent 的面向对象封装。event_base_loop 就是那个"不停问 OS 谁就绪、处理就绪事件"的循环。你只需理解 Dispatcher 这层抽象,libevent 是它的引擎。

👶 小白:既然有现成的 libevent,Envoy 为什么还要包一层 Dispatcher,直接用 libevent 不好吗?

👨‍🏫 老师:三个好处。① 换引擎不动上层:哪天想从 libevent 换成别的(Envoy 确实在演进),只改 Dispatcher 内部,上万行业务代码不用动。② 加 Envoy 专属能力post 跨线程投递、deferredDelete 延迟删除、线程安全断言,这些 libevent 没有,是 Dispatcher 补的。③ 类型安全的 C++ 接口:libevent 是裸 C API,容易用错;Dispatcher 用 C++ 对象包好、更难写出 bug。这就是"面向接口而非面向具体库"的价值——你只认 Dispatcher,引擎随便换。

L04

单轮循环步骤

libevent_scheduler.h:19-56 的注释写得极清楚,一轮循环:

1. 算 poll 超时(下一个定时器多久到期)
2. prepare 回调
3. poll fd 事件(阻塞等待,直到有 fd 就绪或超时)
4. check 回调
5. 处理到期的定时器
6. 执行 work list(post 的任务、延迟删除、activate)
7. 未退出则回到 1
读法:这就是事件循环的"一次心跳":等 IO 就绪(第 3 步是唯一"阻塞"点)→ 处理就绪的 IO → 处理到期定时器 → 执行投递的任务 → 循环。post/deferredDelete 都是第 6 步的 work list 项。这段注释是理解事件循环的黄金教材。poll_delay_us/loop_duration_us 直方图统计每轮耗时(可观测)。
事件循环的一次"心跳"(转一圈再回到起点) ① 算超时(下个定时器多久到) ③ poll 等就绪 ★唯一阻塞点 ⑤ 处理到期定时器 ⑥ 跑 work list(post/删除) ④ check 回调 ② prepare 回调 ⟳ 循环
图注:一圈里只有第③步"poll"会真的阻塞(等 OS 说谁就绪),其余步骤都是快速处理。处理完回到①,无限转。
📝 举个例子:1 万条连接里只有 3 条来了数据 worker 盯着 1 万条连接。这一轮 poll 阻塞等待 → OS 返回"fd 87、fd 512、fd 9001 可读了" → 事件循环只对这 3 条调回调处理数据,其余 9997 条完全不占 CPU → 处理完回到 poll 继续等。所以"盯 1 万条"实际只花在"真有事的那几条"上。
L05

非阻塞 IO 挂接

// source/common/event/file_event_impl.cc:55 assignEvents
// event_assign(..., EV_PERSIST | EV_ET/EV_READ/EV_WRITE/EV_CLOSED, callback, this)
// 把 socket 的 可读/可写/关闭 事件注册进 libevent
// 回调把 libevent 的 what 翻译成 Envoy 的 FileReadyType
读法:这是"socket 就绪 → 触发回调 → 进入过滤器链"的最底层入口。EV_READ=可读了就调回调、EV_ET=边缘触发(高效)。Day 05 会看到:连接可读时,这个回调最终驱动网络过滤器链处理数据。定时器同理用 evtimer_assigntimer_impl.cc:12)。
L06

post 跨线程

// source/common/event/dispatcher_impl.cc:263 post
// 加 post_lock_ 把回调塞进 post_callbacks_
// 仅当队列由空变非空时 scheduleCallbackCurrentIteration() 唤醒事件循环
// —— 这是唯一带锁的地方,锁只护"队列"这一瞬间,回调执行时不持锁
// :355 runPostCallbacks:std::move 整个队列出来后释放锁再逐个执行
post 是线程模型的"胶水" Day 03 说主线程用 post 把配置更新投递到各 worker——就是这个 post它是唯一允许"跨线程"调用的方法:别的线程把一个任务塞进目标线程的队列,目标线程在自己的事件循环第 6 步执行它。注意它加锁的范围极小——只在"塞进队列"那一瞬间锁,执行任务时不持锁(先 std::move 整个队列出来再逐个跑)。这种"锁只护队列操作、不护业务执行"的设计把锁竞争降到最低。这就是 Envoy 并发模型的核心胶水。
L07

延迟删除

// source/common/event/dispatcher_impl.cc:244 deferredDelete
// 把待删对象放入 current_to_delete_(双缓冲 to_delete_1_/2_)
// 下一轮循环统一删 —— 避免在栈上正被使用的对象被立刻销毁
为什么要"延迟删除"? 想象一个过滤器在处理请求时,触发了"关闭这个连接"——如果立刻销毁连接对象,但调用栈上层还在用它,就会 use-after-free 崩溃。延迟删除的招数:不立即删,而是把对象放进"待删列表",等这一轮事件处理完、栈完全退出后,下一轮循环开头统一删。用双缓冲(两个列表交替)防止"删的过程中又有新的待删"导致的问题。这是事件驱动 C++ 里管理对象生命周期的经典技巧——异步世界里"什么时候能安全删对象"是个真问题。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 事件驱动为什么能"单线程扛海量连接"?
  • Dispatcher 提供哪三类能力?
  • libevent 是什么?run() 底层是什么调用?
  • 单轮事件循环的步骤?唯一的阻塞点在哪一步?
  • post 为什么锁范围极小?延迟删除防什么?

✋ 动手

cd /Users/bitmart/work/codes/github/higress-group/envoy
sed -n '19,56p' source/common/event/libevent_scheduler.h   # 单轮循环黄金注释
sed -n '263,300p' source/common/event/dispatcher_impl.cc   # post
sed -n '55,80p' source/common/event/file_event_impl.cc     # event_assign
明天预告 · Day 05(第1周收官)请求入口全景——把前 4 天串起来:一个连接从 accept 到进入过滤器链的完整链路——监听 socket 就绪 → ActiveTcpListener → 监听器过滤器 → 选过滤器链 → 网络过滤器 → HCM。
← Day 03 线程模型 Day 05 · 请求入口全景 →