多进程架构
背景
C++ 语言服务器面对的几乎总是不完整的代码,而 Clang 从未针对这类输入做过强健容错;服务器还要连续运行数天,而 Clang 假定自己只是一个短生命周期的进程。由此带来三个问题:
- 内存。 Clang 的内存占用会随反复重编译不断累积且无法回收。clangd 用户常报告运行几小时后内存涨到 10 GB 以上,直到被系统的 OOM killer 终止。
- 崩溃。 写了一半的代码容易触发 Clang 的断言失败和空指针解引用。在单进程架构下,一次崩溃就会终结整个服务器,所有已打开文件的状态也随之丢失。
- 优先级反转。 后台索引与用户请求争抢同一组线程,而线程调度器并不知道用户正在等待一次悬停结果。
设计
每次编译都在独立的 worker 进程中运行;主进程维护状态并路由请求。worker 的崩溃或内存泄漏都局限在该 worker 内部。
Master process
├── event loop: LSP and control requests, state, invalidation
├── compile scheduling (the task graph) and background indexing
│
├── stateful workers × N hold open files' ASTs, answer queries on them
└── stateless workers × M one-shot work: PCH and module builds, index runs,
completion, signature help, formatting各个 worker 用二进制编码经管道与主进程通信,并各自将日志写入独立的文件。
主进程是单线程的:它不执行任何编译,路由和状态管理都能放进单个协程事件循环中,状态也无需加锁。
工作分为两类,因为它们的调度需求截然相反。针对已打开文件的查询——悬停、语义 Token、大纲——复用同一棵 AST,因此需要亲和性(affinity):文件固定交给持有其 AST 的那个有状态 worker。一次性工作不保留任何状态,因此需要负载均衡与优先级:任何空闲 worker 都能接手,且用户触发的工作优先执行。
有状态 worker
新编译的文件会分配给当前持有文件最少的有状态 worker。主进程决定保留哪些 AST:大约每 GB 内存保留一棵,总数在 8 到 32 之间;超出上限时,驱逐最久未被查询的文件。驱逐并非失效——编译所读取的内容并未改变——因此被驱逐的文件仍能照常回答此前编译已算出的整篇文档请求(语义 Token、折叠范围、大纲、文档链接、诊断,以及最近一次请求范围内的内联提示);其他请求只需基于其 PCH 重新编译一次,耗时仅几十毫秒。
无状态 worker
每次派发只要有用户请求在等待——代码补全、签名帮助、格式化、悬停所需的 PCH——即为高优先级,后台索引则为低优先级。优先级属于派发而非任务本身:后台正在构建的 PCH 一旦有用户请求等待它,优先级会立即提升为高。
- 前台优先。 用户空闲时,后台工作最多让
[workers] background个 worker 保持忙碌。用户操作期间,后台并发降为单个任务:各个编译共享处理器的功耗预算,每多一个后台编译都会拖慢用户自己的编译。运行中的后台任务会在下一个取消点停下并重新入队,因此输入绝不会排在索引构建后面等待。索引任务还会以较低的操作系统调度优先级运行。 - 内存控制。 无状态工作运行期间,主进程每秒采样一次机器(以及 clice 所在 cgroup)的可用内存,并始终留出一部分预留内存。只有当剩余内存减去运行中任务预计还会增长的量后,仍容得下新任务的预期峰值(即该文件上次建立索引时其 worker 达到的内存峰值),后台任务才会启动。内存吃紧时,最新启动的后台任务会被终止——它已完成的工作最少,后续增长空间却最大——留待稍后重试;单独运行的任务可以先动用一部分预留内存。即便单独运行也放不进内存的任务,在文件改动前不会再次运行,
clice index也会单独列出它。前台工作绝不会因内存而受阻。
崩溃恢复
崩溃由谁引起
有状态 worker 会同时处理多个请求,单凭进程退出看不出是哪一个请求引发了崩溃。因此,即将退出的 worker 会在 stderr 的最后一行写明出错线程当时正在运行的请求。该请求会被归咎;其余正在执行中的请求只是受其牵连,会重新发送一次。若退出时未能指明任何请求——如收到外部信号,或崩溃严重到无法写出报告——所有执行中的请求都会重发一次,只有当某个请求两次作为 worker 上唯一的请求运行时都导致退出,才会归咎于它。内存耗尽不会归咎任何请求。
崩溃暂停的范围
归咎按文档和工作类别分别记录——编译、Preamble、导入的模块、各类查询、代码补全、签名帮助、格式化——并且只暂停该文档上的那一类工作。编译崩溃会让文档处于无 AST 状态,并在第一行显示警告说明崩溃位置——紧随编辑之后的首次崩溃除外,那多半是代码刚写到一半所致;悬停崩溃则只暂停悬停。启用 clang-tidy 时若编译崩溃,会先关闭 clang-tidy 重试一次,重试成功则只暂停 clang-tidy。面对同一份文档内容,与刚刚崩溃的请求同类的请求会直接返回空结果,避免再次把 worker 弄崩。
文档未改动时,已暂停的工作绝不会自动重试。当文档或其包含的文件发生变化后,它会自动重试,最多允许连续崩溃三次——写了一半的代码补完整后通常就不再崩溃——而保存文件则视为显式重试,会立即触发。后台索引遇到崩溃的文件会将其跳过,直到该文件发生变化。读取缓存 PCH 时若发生崩溃,会先重建该 PCH(损坏的缓存文件同样会引发崩溃),只有重建后仍然崩溃才会归咎于文档。
重启
退出的 worker 会立即由新进程替补,它原本持有的文档会在下次收到请求时重新编译;文档文本保存在主进程中,因此已输入的内容不会丢失。等待中的请求会等替补进程就绪,而不会直接报错。每个槽位都有独立的崩溃预算,只有原因不明的退出才会消耗它——已归咎于具体请求、内存不足、会话结束或终止空闲 worker 都不计入预算:耗尽预算的槽位会休眠三十秒后再恢复启用,使 worker 池在系统性故障下平缓降级并自行恢复,全程不中断主进程。
超过时限的请求——构建为十分钟,其余操作为两分钟,无状态工作按 CPU 时间计量——会被判定为 worker 卡死,主进程随即终止该 worker 并将该请求视同崩溃归咎;CPU 时间连续五分钟停滞不前的无状态 worker 也按同样方式处理。
设计决策
- 为什么用多进程而不是多线程? 线程既隔离不了崩溃,也隔离不了内存泄漏:一次段错误就会终止整个进程,所有线程还共享同一个堆。进程虽然带来了进程间序列化的开销,面对 Clang 这样崩溃和泄漏路径繁多的库,却能换来真正的故障隔离。
- 为什么分两类 worker? 合并成同一个池,要么放弃亲和性而反复重编译 AST,要么放弃优先级而把用户操作与后台任务混在一起。
已知限制
- 前台工作没有内存上限。 已打开文件编译的极端翻译单元可能让其 worker 内存持续膨胀,直至系统 OOM killer 介入。在 Linux 上,持有工作任务的 worker 在 OOM 评分中排在主进程之前,因此被终止的是该 worker,而不是整个服务器。
- 较小的内存上限会把后台工作压到只跑单个任务,且所需内存超出剩余额度的翻译单元在文件改动前不会建立索引。
- 启动阶段不报告进度。 加载编译数据库、预热工具链缓存和首次依赖扫描都要在服务器开始响应之前完成;在超大型项目上,启动后的一小段时间内可能看起来没有响应。
