功能没变,但引擎造好了

5 阅读13分钟

上一篇把事件引擎封装成了一行 js_epoll_poll(100)。这一篇引入 jsbench 最核心的结构:工作引擎。

最终的结果是:一个叫 js_engine_t 的结构体——每个线程一个,同时拥有事件分发(epoll)和定时器管理。它是一个线程的完整工作引擎。但这个结果不是一步到位的——我们先花了一整个 commit 打地基,然后才发现地基打好了还不够,零件是散的,还得把它们组装起来。

三种定时器,同一件事

先说问题。

jsbench 有三种完全不同的定时器机制。C-path 用操作系统提供的定时器文件描述符,时间到了 epoll 收到事件。JS-path 在每次循环开头手动取当前时间,跟截止时间比较,到了就退。请求超时在事件循环里——每轮遍历所有 pending 请求,逐个检查。

三种方式做的是同一件事:"等一段时间,然后做点什么"。但实现方式完全不同,互不知道对方的存在。想统一它们,需要一个通用的定时器模块。但定时器模块需要数据结构、时间类型这些基础设施。你不能在沙地上盖房子。

选数据结构:红黑树,不是最小堆

定时器要做三件事:添加("x 毫秒后叫我")、删除("不用了,请求已经完成了")、找最近的("下一个闹钟几点响")。

想象你管理一堆待办事项,每件有截止时间。你需要随时知道哪件最紧急,能快速加新的,也能在完成后快速划掉。

最简单的方法:一摞纸随便堆。 找最紧急的?把整摞翻一遍。这就是当前请求超时的做法。

红黑树:一个自动排序的文件柜。 放进去自动排好,最紧急的永远在最前面。加一件、删一件、看最前面——每个操作都很快。100 个待办最多 7 次比较,1000 个最多 10 次,65536 个最多 16 次。

还有一种常见选择叫最小堆——看最前面只要 1 次,加一件也很快。但有个问题:如果你要划掉的不是最前面那件(比如某个请求提前完成了,要取消它的超时),最小堆得先在一大堆里找到它。红黑树不存在这个问题——你手上拿着那件待办的引用,直接划掉。红黑树在三个操作上没有短板。

AI 会建议用最小堆——教科书确实这么教。但教科书的场景通常不需要频繁删除任意节点。jsbench 需要。选型不是选"最常见的",而是选"最适合你的场景的"。

确定了数据结构,下一个问题是用谁的。红黑树的实现涉及旋转和重新着色,细节容易出错,不需要自己写。Igor Sysoev 2002 年写 nginx 的时候就实现了一棵。过去 20 多年里,这棵树在全球数以亿计的连接上经受了考验。后来他写 njs(nginx 的 JavaScript 引擎)时做了一版改进——更灵活、更通用。在我看来,这是最好的红黑树实现。

这棵树最重要的设计特点是嵌入式节点——树节点不是一个独立分配的对象,被直接嵌入到使用者的结构体里。不熟悉编程的读者可以这样理解:给一堆文件夹按日期排序,一种方式是给每个文件夹做一张索引卡然后排列卡片,另一种是在文件夹上直接贴标签。Sysoev 的设计是后者。添加定时器不需要额外分配内存,删除也不需要释放。零动态内存分配。 上一篇里事件对象嵌入到连接对象里就是同样的思路。

说到这棵树,有段个人经历。在加入 nginx 团队之前,我给 Igor 的红黑树提交过一个小改进。Igor 亲自 review 了,然后合并了。Igor 的代码我读了很多年,能给他的代码贡献一点东西,哪怕很小,比写一千行自己的代码都有成就感。所以现在把这棵树引入 jsbench,不只是"选了一个好的实现"——我对它的每一行代码都很熟悉。

好的选型不是选最新的或最流行的,而是选你最理解、最信任的。 这种信任不是看了文档就有的,它来自长期的使用和阅读。

一层一层搭上去

有了红黑树,还需要时间的度量。之前 jsbench 到处裸写系统调用获取时间,没有统一的时间类型。如果定时器模块建在这上面,时间相关的细节就会泄漏到每一行代码里。所以引入了一组时间类型和三个时钟函数。很小——类型不到 10 行,函数不到 30 行——但它们划定了一个边界:定时器模块只跟"毫秒"这个抽象打交道,不关心毫秒是怎么从操作系统获取的。

然后是定时器模块本身。红黑树提供排序,时间类型提供度量,定时器模块把它们组装成四个操作:添加、删除、查找最近的、触发已到期的。对外只露出这四个动作,内部的红黑树操作和溢出处理全部藏在里面。这个模块是独立的——它不知道什么是连接、什么是 epoll、什么是 jsbench。通用的东西就应该是通用的。

引入这些基础设施的同时,拆分了头文件。之前所有声明都在一个 350 行的头文件里,一直没拆——不需要。但红黑树是通用数据结构,不属于任何业务逻辑;时间类型也一样。把通用的东西塞进专属的头文件里,就像把数学教材塞进一本小说的目录——它不属于那里。重构的最好时机不是"有空的时候",而是有具体需求的时候。 AI 在项目第一天就会建议你"按职责拆分头文件"——在没有通用组件时拆是过度设计,在引入通用组件后才拆是刚好。时机和内容一样重要。

最终形成了五个独立的层:

js_unix.h     ← 操作系统接口
js_clang.h    ← C 语言工具
js_time.h     ← 时间类型和时钟封装
js_rbtree.h   ← 红黑树
js_timer.h    ← 定时器模块

每一层只依赖下面的层,不知道上面的层的存在。依赖方向是单向的,从上往下。 这是基础设施最重要的性质。无论你用什么语言、做什么项目,基础设施的依赖方向都应该是单向的。数据库工具不应该依赖业务逻辑,日志模块不应该依赖 HTTP 框架。如果你的基础设施"知道"上层的存在,它就不再是基础设施,而是上层的一部分。

代码变更: ca705bf

地基打好了,但零件是散的

到这里,近 700 行新代码,10 个文件。跑一下 jsbench——和之前完全一样。没有统一任何定时器,epoll_wait(100) 的 100 还在那里。

地基确实打好了。但红黑树在一边,epoll 在另一边,它们之间没有关系。定时器模块不知道 epoll 的存在,epoll 也不知道定时器的存在。

回到那个 100 的问题:要让它变成"距离最近的定时器还有多久",epoll 必须能向定时器查询。它们必须在某个地方被关联起来。

工作引擎

在哪里关联?答案是:引入一个新的结构体,把 epoll 和定时器组合成一个整体。

这就是 js_engine_t——工作引擎。

不熟悉编程的读者可以这样理解。一辆车有发动机和仪表盘。发动机负责驱动,仪表盘负责显示时间和速度。它们各自可以独立存在,但只有装在同一辆车里,才能协同工作——仪表盘告诉你"还有 5 公里到目的地",发动机才知道该加速还是减速。工作引擎就是这辆车:epoll 是发动机(驱动事件),定时器是仪表盘(管理时间),组合在一起才是一个完整的工作引擎。

每个工作线程有一个 engine。engine 创建时初始化 epoll 和定时器,销毁时一起清理。线程级别的所有基础设施,都归 engine 管。

引入 engine 的同时,也把 epoll 相关的接口拆分到了独立的 js_epoll.h 头文件里。js_main.h 里通用的部分已经陆续拆分出去了——系统接口、语言工具、时间类型、红黑树、定时器、epoll、工作引擎,各自回归各自的模块。当模块多了,让每个模块的声明回到它自己的地方,就是自然的事。

有意思的是,引入 engine 意味着推翻上一篇的一个决定。上一篇把 epoll 的文件描述符做成了线程局部变量——每个线程有自己的一份,调用者不需要传递。现在它又变回了需要传递的东西——在 engine 里面,调用 epoll 操作时要把 engine 传进去。

这是不是自相矛盾?不是。上一篇消除的是裸传一个整数——传错了编译不报错。现在传的是 engine——一个有明确语义的对象,代表"这个线程的工作引擎"。抽象级别提高了。更关键的是,engine 不只有 epoll,它还有定时器。如果继续用线程局部变量,定时器也得做成线程局部的,然后 epoll 和定时器之间的关联又散落在各处。engine 把它们收拢到一起。之前的决定不一定是错的,但随着新需求的出现,更好的设计会浮现出来。 架构不是一步到位的,它在每一步做当时最合理的选择,随着系统的演进不断调整。

聚合:一个实用的重构技巧

engine 背后用到的模式值得单独说一下:聚合——把相关的东西放到一个对象里。

这个模式听起来简单到不值一提,但它解决了代码里最常见的一类混乱:相关的东西散落在各处。 引入 engine 之前,epoll 和定时器之间的关系是隐含的——"epoll 的超时应该由定时器决定",这个关系只存在于程序员的脑子里,代码里看不到。引入 engine 之后,这个关系变成了显式的——它们住在同一个结构体里,关联是代码本身表达的。当相关性被代码表达出来,而不是靠人记住,系统就更不容易出错。

这个模式的应用远不止 C 语言。在任何项目里,当你发现几个变量总是一起出现——一起创建、一起传递、一起销毁——它们很可能应该被聚合到一个对象里。数据库连接和连接池配置总是一起用?聚合成一个 DatabaseClient。用户 ID 和权限列表总是一起传?聚合成一个 UserContext

聚合不只是"放在一起方便"。封装——散落的东西没有边界,聚合之后可以通过接口控制访问。生命周期统一——一次 create 全部就绪,一次 destroy 全部清理,不会"创建了 A 忘了创建 B"。

判断什么时候该聚合,有一个简单的信号:如果你发现自己在多个地方重复传递同一组参数,或者在多个地方重复同一组初始化/清理步骤,那就是聚合的时机。 就像这次——worker 和 loop 都要 create epoll、都要 init timers、都要 close epoll,当这个模式重复出现,就该把它们装进一个对象里。

代码变更: 8f25458

AI 的角色

两次改动,AI 的角色始终是一样的:人做判断,AI 做执行。

第一次改动——打地基——关键判断是选型。用红黑树而不是最小堆、用 Sysoev 的版本而不是自己写、现在拆头文件而不是之前。AI 可以在你说出"用红黑树"之后快速写出正确的实现,但它不会主动说"用 Sysoev 的版本,因为它的嵌入式设计和 jsbench 的事件模型一脉相承"。它不知道你对这棵树的了解程度,不知道这个选择背后有十年的阅读经验。选型的质量,取决于你的经验、你的品味、你对问题域的理解深度。 这些不是 prompt 能教给 AI 的。

第二次改动——造引擎——关键判断是架构:epoll 和定时器应该组合成一个对象,每个线程持有一个。但把这个判断落到代码上,涉及 6 个文件的改动。这类工作交给 AI,它做得强到没边

AI 在重构方面有一个人类很难匹敌的优势:它不会漏。 人工改 20 处函数调用,总有可能漏一处。AI 扫一遍代码,所有调用点一次改完,签名对齐、参数一致、没有遗漏。这不是"比人快"的问题,是"比人可靠"的问题。

这次的体感尤其强烈——因为这次是推翻之前的设计:把线程局部变量改回显式参数。上一篇 AI 改的那些代码,这次又要被 AI 改到一个新的抽象层面上。AI 对此毫无怨言,也不会因为"上次刚改过"就犹豫。它把每次改动都当成全新的任务,机械地、完整地执行。

重构是目前 AI 编程最有价值的场景之一。 方向明确、改动面广、机械性强——恰好是 AI 最擅长的。人提供方向,AI 负责把方向落到每一行代码上。你的一个架构判断,AI 能帮你落到几十个文件的每一行代码上。判断越准确,杠杆越大。

小结

回头看这篇做的事:引入红黑树、时间类型、定时器模块,打好五层地基;然后引入 js_engine_t,把 epoll 和定时器组装成一个完整的工作引擎。每个线程一个 engine,engine 拥有该线程的全部基础设施。

epoll_wait(100) 的 100 还没有变。三种定时器还没有统一。但现在 engine 同时持有 epoll 和定时器,让 epoll_wait 根据最近的定时器动态计算超时——只差最后一步。当结构对了,功能就是水到渠成的事。

地基本身不是目标,但没有它,目标到不了。 很多人觉得基础设施是浪费时间——"写了半天,功能一点没变"。但好的基础设施让后面的每一步都更简单、更稳固。反过来,如果跳过地基直接写功能,写出来的东西要么是脆弱的,要么是重复的,要么改不动。

这棵红黑树不是我们写的。Igor Sysoev 20 多年前就写好了。好的架构不只是会设计,还要会选型——知道什么时候该自己造,什么时候该拿别人造好的来用。如果一个问题已经被最好的工程师用最好的方式解决了,你需要做的不是超越他,而是理解他的设计,然后把它用在正确的地方。


GitHub: github.com/hongzhidao/…

更多文章和后续更新,关注微信公众号:程序员洪志道