AI 写代码像盖楼。前几层盖得又快又稳——函数命名、逻辑实现、测试覆盖,都不错。但楼越往上,问题就来了。高层靠的不是砖头砌得多快,而是图纸画得对不对。图纸就是架构。
事件引擎是 jsbench 的地基之一。这篇做的事只有一件:把事件引擎的复杂度封装起来,不让它蔓延到系统的其他部分。
最终的结果是:epoll_wait()——事件引擎最核心的系统调用——从散落在多个文件变成只存在于一个地方。调用者从写二十行 epoll 样板代码变成写一行 js_epoll_poll(100)。但这个结果不是一开始就规划好的。我们只是觉得事件引擎不够干净,然后一步步改,每改一步就发现下一个障碍,直到最后回头一看——四次重构,刚好把复杂度封装完了。
为什么事件引擎是核心
jsbench 做的事情很简单:同时管理大量网络连接,发请求,收响应。但"同时管理大量连接"本身就是一个经典的系统编程问题——C10K 问题的核心。
解决这个问题的标准模型是事件驱动:不为每个连接分配独立线程,而是用一个事件循环统一监听所有连接的状态变化,哪个连接有数据了就处理哪个。Linux 上做这件事的系统调用是 epoll。
在 jsbench 里,无论是 C-path(纯性能路径)的 65536 个并发连接,还是 JS-path 的 fetch() 并发请求,底层都是 epoll 在驱动。事件引擎是整个软件的基础设施。 基础设施的设计如果不对,上面搭什么都不稳。
旧的设计:能用,但不够好
之前的事件分发长这样:
for (int i = 0; i < n; i++) {
if (events[i].data.ptr == NULL) {
/* 定时器 */
read(tfd, &expirations, sizeof(expirations));
timer_expired = true;
break;
}
js_conn_t *c = events[i].data.ptr;
js_conn_handle_event(c, events[i].events);
if (c->state == CONN_DONE) { /* ... */ }
else if (c->state == CONN_ERROR) { /* ... */ }
}
这段代码能工作,但有几个问题。
用 NULL 区分事件类型。 定时器注册时传 NULL 指针,连接注册时传连接指针。分发的时候先检查是不是 NULL,是就当定时器处理,不是就当连接处理。这不是一个可扩展的模式——如果将来加信号、管道、UDP,每加一种事件类型就要加一层 if-else。
事件类型定义了但没用。 代码里定义了 JS_EVENT_CONN 和 JS_EVENT_TIMER 两个类型标签,js_conn_t 里也嵌入了 js_event_t 作为首字段。但实际分发根本不看这个标签,靠的是 NULL 检查。设计了一半。
处理逻辑是集中式的。 所有连接事件都走 js_conn_handle_event(),一个函数里 switch 连接状态、处理读写、处理 TLS 握手。分发循环必须知道"这是连接"才能调用它。事件引擎和连接逻辑耦合在一起。
总结一下:事件类型的判断散落在分发循环里,epoll 的依赖散落在调用者手里,连接处理逻辑散落在事件循环里。复杂度到处蔓延,而不是被封装在它该在的地方。
核心思想:让事件自己知道怎么处理自己
如果用一句话总结这次重构的核心:把"分发逻辑判断事件类型再调用对应处理函数"改成"每个事件自带处理函数,分发只管调用"。
不熟悉 C 的读者,可以这样理解。
想象一个前台接线员(事件分发循环),有电话进来。旧的做法是:接线员先看来电号码,查表确认是哪个部门的电话,然后把电话转给对应部门。如果增加一个新部门,接线员的查表流程就得更新。
新的做法是:每条线在安装时就绑定好了处理人。电话响了,接线员不需要查表,直接转给线上绑定的人就行。新增部门只需要装一条新线,绑好处理人,接线员的工作流程完全不变。
在 C 语言里,"绑定处理人"就是函数指针:
struct js_event_s {
int fd; /* 文件描述符 */
void *data; /* 事件关联的自定义数据 */
js_event_handler_t read; /* 可读时调用 */
js_event_handler_t write; /* 可写时调用 */
js_event_handler_t error; /* 出错时调用 */
};
这就是 js_event_t。每个注册到 epoll 的事件,都是一个 js_event_t,自带 read、write、error 三个处理函数。事件自己知道怎么处理自己。
这背后的架构思维叫抽象。连接是一种事件,定时器也是一种事件,将来可能还有信号、管道。它们的底层实现完全不同,但对事件引擎来说,它们都是同一个东西——一个 js_event_t,有 fd、有 handler,仅此而已。事件引擎不需要知道"连接怎么处理读事件"或"定时器怎么处理超时",它只需要知道"有事件来了,调 handler"。
抽象的价值在于:它定义了一个边界。 边界以上,所有事件都一样;边界以下,各自不同。事件引擎只跟边界以上打交道,所以它不需要随着事件类型的增加而修改。这是系统能够扩展的根本原因。
这个模式在系统编程中非常经典。nginx 的事件模型就是这个思路。
具体改了什么
改动围绕"抽象"展开,让 js_event_t 真正成为统一的事件接口。连接创建时绑定自己的 read/write/error handler,定时器也一样。对事件引擎来说,它们没有区别,都是一个 js_event_t。
分发循环变成完全通用的了:
js_event_t *ev = events[i].data.ptr;
if (e & (EPOLLERR | EPOLLHUP)) {
if (ev->error) ev->error(ev);
} else {
if ((e & EPOLLOUT) && ev->write) ev->write(ev);
if ((e & EPOLLIN) && ev->read) ev->read(ev);
}
没有 NULL 检查,没有类型判断,不关心 ev 是什么。只管调 handler。将来加任何新的事件类型,这段代码不需要改一个字。这就是抽象的效果——边界以上的代码不随边界以下的变化而变化。
改完跑测试,全部通过。提交。
改动涉及 6 个文件,净增 73 行。全程交给 AI 做的。我只做了三件事:提出方向(引入 handler-based event)、review 中间结果、纠正命名。没有引入新的抽象层,只是把已有的 js_event_t 从一个空壳变成了真正有用的东西。这就是第四篇说的"刚好足够"。
代码变更: d8d8f8f
继续改进,AI 卡住了
Handler-based dispatch 搞定之后,我让 AI 继续改进 epoll 的使用。它改不动了。
问题在于:每次 epoll 操作(add、mod、del)都要传 epfd——epoll 实例的文件描述符。这是一个依赖问题:所有 epoll 操作都依赖调用者提供 epfd,而调用者必须自己负责持有、传递、管理这个值。
不熟悉编程的读者可以这样理解"依赖":你家小区有个快递柜,每次取快递都需要一个取件码。如果每个快递 APP 都要你手动输入快递柜的编号,你得自己记住编号,每次都传给它。万一记错了,快递就取不了。这个"快递柜编号"就是一个不必要的依赖——APP 完全可以自己知道你家是哪个快递柜。
具体来说,这个依赖带来三个问题。
调用者要自己管理 epfd。 Worker 线程把它存在局部变量里,事件循环把它存在结构体里。两个地方各管各的,创建、传递、关闭都得自己操心。
接口不够简洁。 事件自己已经知道自己的 fd(ev->fd),知道自己的 handler。但 epoll 操作还要额外传一个 epfd 进来,就像每次打电话都要先报一遍自己在哪栋楼。
扩展时容易出错。 如果新增一种事件类型——比如信号、管道——写代码的人得记住把 epfd 传对。传错了编译不会报错,运行时才会出问题。
AI 看出这些不够好,但不知道往哪个方向改。它尝试了几种方案——把 epfd 存到 js_event_t 里、搞一个全局变量——都不太对。
一句话就够了
我给它的提示是:每个线程有且只有一个 epoll。
就这一句话,AI 立刻知道怎么做了。
为什么这句话有用?因为它点明了一个架构事实:在 jsbench 里,每个 worker 线程创建一个 epoll 实例,这个线程里所有的事件操作——添加连接、设定时器、改监听状态——都发到同一个 epoll 上。epfd 不是"参数",它是线程的基础设施——每个线程有且只有一个。
既然是线程级别的唯一资源,就不需要到处传递。这个依赖可以消除。
不熟悉 C 的读者,可以这样理解。想象一个办公室(线程),里面有一块白板(epoll)。以前每次往白板上写东西,都要先问"白板在哪"。但每个办公室只有一块白板,位置是固定的。你不需要每次都问——走进办公室就能直接用。
AI 用了 C 语言的线程局部存储(__thread)——每个线程有自己独立的一份 js_epfd,线程之间互不影响。有了这个,epoll 接口就变了:
// before: 调用者每次都要传 epfd
js_epoll_add(epfd, &conn->socket, EPOLLIN | EPOLLOUT | EPOLLET);
js_epoll_mod(epfd, &c->socket, mask);
js_epoll_del(epfd, &c->socket);
// after: epfd 是线程内部的事,调用者不用管
js_epoll_add(&conn->socket, EPOLLIN | EPOLLOUT | EPOLLET);
js_epoll_mod(&c->socket, mask);
js_epoll_del(&c->socket);
少了一个参数,但意义不只是少打几个字——epfd 这个依赖被彻底消除了。
接口语义更清晰了。 "把这个事件加到 epoll"——调用者只需要关心"哪个事件、监听什么",不需要关心"哪个 epoll"。就像寄快递只需要写收件地址,不需要知道快递站在哪。
不可能传错了。 以前如果有人手误传了一个错误的 epfd,事件会注册到错误的 epoll 上,debug 起来很痛苦。现在这种错误从源头消除了——你根本没有机会传错。
资源管理集中了。 创建和关闭 epoll 的逻辑封装在 js_epoll_create() 和 js_epoll_close() 里,线程开始时 create,结束时 close,一进一出,干干净净。
这次改动还不够彻底——epoll_wait() 仍然需要 epfd,调用者还是得自己写事件循环。但没关系——每次重构应该是独立的、有价值的。 这次的价值很明确:消除了 epfd 依赖,简化了 epoll 操作接口。剩下的是下一步的事。
改完跑测试,全部通过。提交。
回头看,这次改动的代码量很小——头文件改了 4 行接口签名,实现文件加了一个 __thread 变量和一个 close 函数,调用方删掉了所有 epfd 参数。但这 4 行改动背后,是一个架构判断:epoll 实例是线程级别的基础设施,不是需要传递的参数。 AI 不缺执行力,缺的是这种判断。它能看出代码不够优雅,但不知道该怎么改——因为"怎么改"不是语法问题,是对系统架构的理解。但只要你把判断告诉它,它立刻就能把判断转化成正确的实现。有时候 AI 不需要长篇大论的 prompt,它需要的是一个准确的判断。就像带新人——有时候不需要写一页文档,只需要一句"这个东西不应该是参数,应该是线程的属性",他就懂了。
代码变更: 10e23aa
下一步改不动了——不是 epoll 的问题
消除了 epfd 依赖之后,下一步很自然:引入 js_epoll_poll(),把 epoll_wait() 加事件分发封装成一个函数。
js_epoll_poll() 要做的事很简单——调用 epoll_wait() 等待事件,然后对每个就绪的事件调用它的 handler。这样调用者就不需要自己写 epoll_wait 循环、自己遍历事件数组、自己判断是读还是写了。一个函数搞定。
不熟悉编程的读者可以这样理解:之前我们把"白板在哪"这个问题解决了(epfd 不用传了),现在想更进一步——你连"去白板前看一眼、通知对应的人"这套动作都不用做了,有个助理帮你完成,你只管等结果。
这就是第四篇说的"把复杂度装进盒子里"——对外只露出简单的接口,内部的复杂度被藏起来。
但我发现改不动。
看一眼当前事件循环的代码(js_worker.c):
for (int i = 0; i < n; i++) {
js_event_t *ev = events[i].data.ptr;
uint32_t e = events[i].events;
/* 第一部分:事件分发——通用的,跟 epoll 有关 */
if (e & (EPOLLERR | EPOLLHUP)) {
if (ev->error) ev->error(ev);
} else {
if ((e & EPOLLOUT) && ev->write) ev->write(ev);
if ((e & EPOLLIN) && ev->read) ev->read(ev);
}
/* 第二部分:连接后处理——专属的,跟 conn 有关 */
js_conn_t *c = (js_conn_t *)ev;
if (c->state == CONN_DONE) {
/* 统计、keep-alive、重连... 几十行 */
} else if (c->state == CONN_ERROR) {
/* 重连... */
} else {
/* 更新 epoll 监听状态... */
}
}
问题一目了然:事件分发和连接处理混在一起了。 第一部分是通用的 epoll 逻辑,可以封装。第二部分是连接专属的业务逻辑——检查连接状态、记录统计、处理重连——这些不应该在 epoll 层面。
如果硬要写 js_epoll_poll(),它要么得知道"连接"是什么(破坏了抽象),要么得在事件分发后返回让调用者自己处理(那封装的意义就不大了)。
js_loop.c 里的事件循环也是同样的问题:分发完之后立刻把 ev 强转成 js_conn_t *,检查连接状态,处理 Promise 的 resolve/reject。epoll 层和连接层纠缠在一起,拆不开。
这就是耦合。不是 epoll 的问题,是 conn 的问题——连接的处理逻辑散落在事件循环里,而不是收拢在连接模块自己内部。
AI 当然也试了。它给了好几种方案——比如给 js_epoll_poll() 加一个回调参数,让调用者传一个"后处理函数"进去;或者让 js_epoll_poll() 返回一个事件列表,调用者自己遍历处理。
这些方案能解决眼前的问题,但都是"解决问题"的思维,不是架构思维。有个经典的笑话:我有一个问题,我引入了一个方案来解决它,现在我有两个问题。回调参数让 epoll 接口变复杂了,返回事件列表只是把循环挪了个位置——问题没有消除,只是换了个形式。
架构思维是:不是想办法绕过耦合,而是消除耦合本身。 连接的处理逻辑不应该在事件循环里,它应该在连接模块自己内部。把这个根因解决了,js_epoll_poll() 自然就能写得干净。
先重构 conn
所以方向是:先改 conn。
之前连接的读写处理函数是 static 的,签名是 (js_event_t *ev),第一件事就是把 ev 强转成 conn。这意味着连接处理跟事件系统的类型绑死了——你只能通过 js_event_t * 间接调用它们。
重构的方向是:让连接处理函数直接接收 js_conn_t *,并且变成公开接口。 同时,js_conn_create() 不再自动绑定 handler——handler 的绑定由调用者决定。
这个改动看起来很小——函数签名换了,static 变 public,去掉了自动绑定。但它解开了一个关键的耦合:连接的处理逻辑不再依赖事件系统的类型。 调用者可以在 handler 里先调 js_conn_process_read(),再做自己的后处理(统计、重连、resolve Promise),连接的归连接,事件的归事件。
这是后面引入 js_epoll_poll() 的前置条件。conn 的处理逻辑收拢到 handler 里之后,js_epoll_poll() 就只需要做 epoll 自己的事——等待事件、调用 handler——不需要知道连接的存在。
改完跑测试,全部通过。提交。
代码变更: e8c5254
回到起点:js_epoll_poll()
三步铺垫做完,现在可以引入 js_epoll_poll() 了。它做的事很简单:调用 epoll_wait() 等待事件,遍历就绪事件,调用每个事件的 handler。调用者不需要知道 epoll_wait 怎么用、事件数组怎么分配、读写错误怎么判断。一个函数,一个参数(超时时间),搞定。
效果立竿见影:
// before: 20 行——分配数组、调 epoll_wait、遍历、判断读写错误、调 handler
struct epoll_event events[256];
while (!atomic_load(&w->stop) && active > 0) {
int n = epoll_wait(epfd, events, 256, 100);
// ... 十几行分发逻辑
}
// after: 3 行
while (!atomic_load(&w->stop) && active > 0) {
if (js_epoll_poll(100) < 0) break;
}
js_loop.c 也是一样——十几行变一行。
为什么能做到?因为前面三步把障碍一个个清除了:
- handler-based dispatch——事件自带处理函数,分发逻辑不需要知道事件类型
- thread-local epfd——epoll 操作不需要传 fd,
js_epoll_poll()内部直接用线程局部变量 - conn 解耦——连接的后处理逻辑在 handler 里,不在事件循环里
三个条件缺一个,js_epoll_poll() 都写不干净。没有 handler-based dispatch,分发逻辑还得在外面写。没有 thread-local epfd,函数签名还得带 epfd 参数。没有 conn 解耦,分发完还得在循环体里处理连接状态。
这就是为什么之前改不动。不是 js_epoll_poll() 本身有多难写——它只有 20 行。难的是让这 20 行能独立存在,不依赖外部的任何东西。
还有一个变化值得注意:epoll_wait()——整个事件引擎的核心调用——从之前散落在 js_worker.c 和 js_loop.c 两个地方,收拢到了 js_epoll.c 一个地方。以前要理解事件引擎怎么工作,你得在多个文件里找 epoll_wait;现在只有一处。事件引擎的核心逻辑,集中在了事件引擎自己的模块里。这是应该的。
这是一个很好的例子来理解"隐藏复杂性"到底意味着什么——不需要懂技术也能理解。
事件分发的逻辑——怎么等待事件、怎么判断读还是写还是错误、怎么调用对应的处理函数——这些是有复杂性的。之前这些复杂性暴露在每个调用者面前:js_worker.c 写一遍,js_loop.c 写一遍。如果将来要改分发逻辑——比如加个优先级、改个缓冲区大小、加个错误处理策略——你得找到所有写了 epoll_wait 的地方,一个个改,还得保持一致。复杂性在蔓延。 用的地方越多,蔓延得越广,改起来越容易漏。
现在这些复杂性被装进了 js_epoll_poll() 这个盒子里。调用者看到的只有一行:js_epoll_poll(100)。怎么等、怎么分发、怎么处理错误,它不知道也不需要知道。如果将来要改分发逻辑,改一个地方就够了。复杂性被隔离了,不会蔓延。
这就是第四篇说的"把复杂度装进盒子里——对外简单,对内清晰"。js_epoll_poll() 对外只有一个参数(超时时间),对内是 20 行清晰的逻辑。外面不需要关心里面,里面改了不影响外面。这是架构的基本功——不是什么高深的设计模式,就是把该藏的藏好,把该露的露出来。
改完跑测试,全部通过。整个改动净减 11 行——功能没少,代码更短了。提交。
代码变更: 672c70c
AI 与重构
四次改动,每次都不大,但架构一步一步往前走。全程交给 AI 做的。我做的事始终是三件:提出方向、review 中间结果、纠正细节。
AI 在重构方面的效率非常高。具体来说:
机械性改动零出错。 c->fd 全部改成 c->socket.fd,涉及十几处分散在不同文件里。这种改动人工做容易漏,AI 一次做完,编译零警告。
模式转换很准确。 给它说"用 handler-based dispatch 替换 type tag",它理解模式的含义,能把旧的 switch-case 正确拆分成独立的 handler 函数,保持状态机语义不变。
保持上下文一致。 改 js_event_t 的定义之后,所有使用它的地方——epoll 接口、连接创建、事件分发、定时器注册——都要跟着改。AI 能跨文件保持一致,不会改了接口忘了调用方。
重构是目前 AI 编程最有价值的场景之一。重构的特点是:方向明确、模式清晰、改动面广、机械性强。这恰好是 AI 擅长的——大量的、分散的、但模式一致的修改。人提供方向和判断,AI 负责执行和一致性。
顺便说一下工具的使用方式。像 Claude Code 这类 AI agent 通常有两种模式:全自动模式(AI 自主决策、自主执行)和手动确认模式(每一步需要人确认)。做重构的时候,我建议用手动确认模式。原因很简单:重构涉及架构判断,每一步该不该做、做到什么程度,需要人来把关。全自动模式适合方向已经完全确定的机械性任务,但重构过程中经常需要临时调整方向——就像这篇里,想改 epoll 结果发现要先改 conn。这种判断交给 AI 自动决策,它很可能会硬着头皮往错误的方向走下去。
还有一点值得注意:重构是最容易出现过度设计和设计不足的时刻。改得太少,耦合还在,后面的重构照样推不动。改得太多,引入了当前不需要的抽象层,反而增加了复杂度。比如这次解耦 conn,如果只改函数签名就够了,就不要同时搞一套 conn 生命周期管理框架。但如果该改的不改干净,后面引入 js_epoll_poll() 的时候还是得回来补。这个度没有公式可算,只能靠你自己对代码的判断。
说到重构,这个概念值得多说几句。如果你还不熟悉"重构",建议专门学习一下。Martin Fowler 的《重构》是这个领域的奠基之作,但我更推荐 Joshua Kerievsky 的《重构与模式》(Refactoring to Patterns)——可读性更好,把重构手法和设计模式结合在一起讲,实操性很强。重构的核心思想是:在不改变外部行为的前提下,改善代码的内部结构。 功能不变,测试照过,但代码变得更清晰、更容易扩展。
测试在重构中的角色很明确:它不告诉你该怎么改,但它告诉你改完之后有没有搞坏东西。我们每次重构完都会跑测试,这是基本纪律。改结构,不改行为,用测试验证——这就是重构的流程。
前几篇说过,架构是 AI 的短板。但重构——按人给定的架构方向去调整代码——AI 做得很好。"想清楚该怎么改"是人的事,"把改动正确地落到每一行代码"是AI 的事。这个分工很自然。所以我的建议是:学会重构,然后让 AI 来做。 你负责识别代码中的坏味道(bad smell),判断该往哪个方向改;AI 负责把改动落到每一行代码上。这个分工效率极高。
小结
回头看这四次重构,做的其实是同一件事:把事件引擎的复杂度一层层封装起来。
第一步,用抽象定义边界——边界以上所有事件都一样,边界以下各自不同。事件引擎只跟边界以上打交道,所以它不随事件类型的增加而修改。第二步,消除依赖——如果一个东西是基础设施,就不该让每个人都扛着它跑。第三步,解开耦合——不是绕过问题,而是让每个模块的逻辑回到它自己内部。第四步,隐藏复杂度——把实现细节装进盒子里,不让它蔓延到系统各处。
最终,epoll_wait() 只出现在一个地方。事件引擎的复杂度被完全封装了。
这四个动作——定义边界、消除依赖、解开耦合、隐藏复杂度——不是 epoll 专属的技术,是所有架构工作的基本功。无论你写的是 C 还是 JavaScript,做的是服务端还是前端,当你觉得代码"改不动了",往往就是这四个方向中的某一个没做好。
而且这个过程不是一开始就看得清的。我们没有画一张四步路线图然后按部就班执行——是每做完一步,下一个问题才浮现出来。想封装 epoll_wait,发现 epfd 是个依赖;消除了 epfd,发现 conn 耦合在事件循环里;解了耦合,js_epoll_poll() 才水到渠成。好的架构不是设计出来的,是在解决一个个具体问题的过程中长出来的。 但前提是你得有方向感——知道复杂度在哪里,知道它不该在哪里。
说 AI 在架构上有短板,其实不完全准确。用得越多越发现,AI 其实具备这些架构知识——抽象、解耦、封装,它都懂。只是在实际使用中,它更倾向于把选择权交给你:列出几种方案,但不会替你拍板走哪个方向。这可能跟训练方式有关——我不是这方面的专家,只是使用中的观察。
但 AI 确实是一个强大的助手。它的执行力已经非常强了,如果将来它在架构判断上更主动一些,协作效率还会再上一个台阶。而即便到了那一天,理解架构仍然是有价值的——因为你需要判断 AI 给的方向对不对。你的一个架构判断,AI 能帮你落到几十个文件的每一行代码上。判断越准确,杠杆越大。
事件引擎的封装到这里告一段落。架构改进还在继续,下一篇的主题是重新设计定时器——会用到 Igor Sysoev 写的红黑树,我认为这是最好的实现版本。
GitHub: github.com/hongzhidao/…
更多文章和后续更新,关注微信公众号:程序员洪志道