多进程架构
背景
C++ 语言服务器面临一个独特的工程挑战:它依赖 Clang 解析代码,而 Clang 在处理不完整或不合法的代码时,有许多代码路径未经过充分测试。编辑器中的代码几乎总是不完整的——缺少分号、括号不匹配、模板实例化中断——这些场景都不是 Clang 最初设计要稳健处理的。这会导致两个严重问题:
内存泄漏与膨胀:Clang 的设计假定编译是短生命周期的一次性操作。编译器进程分配内存、完成编译后退出,操作系统会回收所有资源。但语言服务器是长期运行的进程——用户每次编辑都会触发重新编译,而 Clang 内部累积的内存无法得到有效回收。在 clangd 社区中,用户经常报告服务器运行数小时后内存占用达到 10 GB 以上,最终被系统的 OOM killer 终止。典型场景包括:模板与宏的组合导致后台索引耗尽 16 GB 内存;合法代码(如大型数组声明 arr[50][6000000])因常量初始化检查而导致 OOM。
崩溃:不完整的代码会触发 Clang 内部的各种断言失败和空指针解引用。clangd 的问题跟踪器中有大量报告涉及模板实例化崩溃、枚举声明段错误、catch 子句中的命名空间损坏等场景。由于 clangd 采用单进程架构,任何一次崩溃都会终止整个语言服务器,导致用户丢失所有编辑状态——即使崩溃只与一个文件有关。
优先级倒置:在单进程 / 单线程架构中,后台索引和前台交互请求(悬停、代码补全等)会争用同一个线程。当后台索引正在处理大型文件时,用户的悬停请求可能需要等待数秒才能得到响应。即使使用线程池,也很难控制线程之间的优先级——操作系统的线程调度器并不理解“用户正在等待悬停结果”这类语义。
设计方案
核心思想
clice 通过多进程架构解决上述问题:每个编译任务都在独立的工作进程中运行,主进程只负责状态管理和请求路由。工作进程的崩溃或内存泄漏被隔离在进程边界内,既不会影响主进程,也不会影响其他工作进程。
进程模型
Master Process (MasterServer)
├── Event loop (kota)
├── LSP / control protocol handling
├── State management (workspace, sessions, invalidation)
├── Compile scheduling (task graph: PCH / PCM / AST / TU-run families)
├── Background indexing (index store + pump)
│
├── Stateful Worker Processes × N
│ ├── SF-0: holds AST, serves queries
│ ├── SF-1: holds AST, serves queries
│ └── ...
│
└── Stateless Worker Processes × M
├── SL-0: executes one-shot tasks
├── SL-1: executes one-shot tasks
└── ...所有工作进程都通过 stdin/stdout 管道与主进程通信,并使用 bincode 进行序列化(这是一种基于 kotatsu 的 BincodePeer 的高效二进制格式)。每个工作进程的 stderr 都会重定向到独立的日志文件,以便单独调试。
主进程的角色
主进程是整个系统的协调者,运行单线程事件循环。它不执行任何 CPU 密集型编译工作——所有编译都交由工作进程完成。主进程负责:
- 接收和路由 LSP / 控制请求
- 管理全局状态(Workspace、Session 映射)
- 调度编译任务和后台索引
- 监控工作进程的运行状况
- 处理文件变更通知和级联更新
单线程设计意味着主进程无需加锁——所有状态修改都在事件循环中串行执行。这大幅降低了状态管理的复杂度。
为什么需要两类工作进程
语言服务器的编译任务天然分为两类,这两类任务的特性不同,因此需要采用不同的调度策略:
需要持有状态的任务:用户打开文件后,会反复查询其悬停信息、语义高亮、符号大纲等。这些查询都基于同一个 AST。如果每次查询都触发重新编译,延迟将难以接受(一次完整编译可能需要数秒)。因此,必须将编译后的 AST 保留在内存中,供后续查询复用。这类任务要求具备文件亲和性(file affinity)——每个文件都固定绑定到一个进程。
一次性任务:PCH 构建、PCM 构建、后台索引、代码补全、签名帮助、格式化等都是一次性的——执行完毕后不需要保留状态。这类任务需要负载均衡和优先级调度——任何可用的工作进程都可以执行,但用户交互任务应该优先于后台任务。
将它们分配给两类工作进程,可以让每类工作进程采用最合适的调度策略。
有状态工作进程
有状态工作进程的核心职责是持有已编译的 AST 并处理查询请求。
文件亲和性路由
每个打开的文件通过 path_id 绑定到一个有状态工作进程。这个绑定关系存储在主进程的路由表中。当该文件的查询请求到达时,主进程根据路由表将请求发送到对应的工作进程。
新文件采用最低负载分配策略——选择当前持有文档最少的工作进程。这可以确保文档均匀分布在各工作进程之间。
编译与查询流程
- 主进程发送 CompileParams(源码文本、编译参数、PCH/PCM 路径等)
- 工作进程编译 AST,并将其缓存在内存中的 DocumentEntry 中
- 后续的 QueryParams(悬停、语义 Token、文档符号等)复用缓存的 AST
- 当文件内容发生变化(didChange)时,主进程发送 DocumentUpdate 通知
- 下次收到编译请求时,工作进程使用新内容重新编译 AST
每个文档的请求都通过该文档专属的互斥锁串行执行,确保不会在同一文档上并发执行编译和查询。
文档淘汰
当工作进程持有的文档数量超过限制时,会通过 LRU 策略淘汰最久未使用的文档,释放其 AST 占用的内存。工作进程会向主进程发送淘汰通知。后续对该文档的请求会触发工作进程的重新分配和重新编译。
无状态工作进程
无状态工作进程执行一次性编译任务,并采用考虑优先级的调度策略。
两级优先级队列
- 高优先级:用户请求正在等待其完成的工作——交互式构建(代码补全、签名帮助)、格式化,以及前台请求所依赖的 PCH/PCM 构建。
- 低优先级:后台索引任务,可以推迟执行而不影响用户体验。
优先级是调度的属性,而不是任务类型的属性:同一个 PCH 构建,在用户请求等待其完成时以高优先级调度,由后台索引产生时则以低优先级调度。
高优先级任务始终优先获取工作进程资源。低优先级任务受并发限制——同时运行的低优先级任务数量有上限,确保始终有工作进程可用于处理高优先级请求。后台索引任务还会降低其操作系统进程优先级(通过 nice 系统调用),减少其 CPU 占用对系统中其他进程(包括编辑器本身)的影响。
动态并发控制
低优先级任务的并发上限根据系统状态动态调整:
前台感知预算:检测到前台活动(有用户请求正在处理)时,后台工作最多使用约 30% 的无状态工作进程;前台空闲后,后台可以使用全部容量。当前台活动恢复时,工作进程资源会被快速收回——运行中的低优先级任务到达协作式取消检查点后会自行重新入队,若超时则以强制终止进程作为兜底——因此突发的连续输入绝不会被大量索引构建阻塞。
内存压力反馈:主进程定期(每 3 秒)检查系统可用内存。当可用内存低于总量的 20% 时,后台额度减 1;当可用内存恢复到 40% 以上时,后台额度加 1。在内存压力严重时,额度可以一直降至零,从而完全暂停后台工作。
崩溃退避:当无状态工作进程崩溃时,后台额度乘以 3/4(乘性降低)。崩溃通常表明遇到了会触发 clang 缺陷的代码;继续保持高并发可能导致更多工作进程遇到同样的问题。乘性降低比线性降低更激进,能更快地降低系统负载。
这种组合策略——前台优先的预算、根据内存压力进行线性调整,以及发生崩溃时采用乘法退避——可确保系统在负载下平稳降级,而不是突然出现 OOM 或连锁崩溃。
崩溃恢复
进程隔离的核心价值在于崩溃恢复——把单个工作进程的故障限制在该进程内,不影响整体服务。
有状态工作进程崩溃
- 主进程通过监控任务检测到工作进程退出
- 清除路由表中该工作进程的所有绑定关系
- 如果未超过最大重启次数,启动新的工作进程
- 后续对这些文档的请求会自动路由到新的(或其他可用的)工作进程,并触发重新编译
- 用户可能会感受到一次短暂延迟(AST 需要重新编译),但不会丢失任何编辑内容——文本缓冲区保存在主进程的 Session 中
无状态工作进程崩溃
- 主进程检测到进程退出
- 触发崩溃退避,降低并发上限
- 进行中的构建请求会向健康的工作进程重发一次(构建任务是幂等的;连续导致两个工作进程崩溃的请求会被视为有毒请求,直接报告失败,而不是无限重试)。调度器会将后台索引任务重新入队
- 如果未超过最大重启次数,启动新的工作进程
崩溃预算与恢复
每个工作进程槽位都有崩溃预算,并在两次重启之间采用指数退避。持续正常运行一段时间后,预算会重置,因此偶发崩溃不会累积到让槽位彻底停用。耗尽预算的槽位会停止重启——但并非永久停止:冷却期结束后,其预算会恢复,槽位也可以按需重新启用。因此,在系统性故障期间,工作进程池会暂时降级(工作进程更少、后台索引变慢),并在诱因消失后自行恢复,整个过程中主进程始终不会中断。
设计决策与权衡
为什么选择多进程而不是多线程? 线程无法隔离崩溃——一个线程的段错误会终止整个进程。线程也无法隔离内存泄漏——所有线程共享同一个地址空间。多进程增加了 IPC 开销(bincode 序列化/反序列化),但提供了真正的故障隔离。对于 Clang 这样已知存在大量可能导致崩溃和内存泄漏的执行路径的库,进程隔离是工程上的必要选择。
为什么要分设有状态和无状态工作进程,而不是采用统一的工作进程池? 两者的调度策略根本不同。有状态工作进程需要文件亲和性(确保 AST 复用);无状态工作进程需要负载均衡和优先级队列。统一的工作进程池要么牺牲亲和性(导致频繁重编译),要么牺牲调度灵活性(无法区分优先级)。将两者分开,可以让每种工作进程专注于各自的调度需求。
为什么主进程是单线程的? 主进程的工作量很轻——它只做路由和状态管理,不做编译。单线程避免了所有并发控制的复杂性(锁、原子操作、竞态条件),而基于协程的事件循环足以应对 I/O 密集的路由工作。
已知限制
不强制限制单个工作进程的内存。 工作进程的内存使用既没有上限,也不会在达到水位线时被淘汰;有问题的翻译单元可能导致某个工作进程的内存占用不断增长,直到操作系统的 OOM killer 介入,此时常规的崩溃恢复机制会接管。系统级内存压力只会限制后台并发,不会限制任何单个工作进程的内存占用。
没有卡死检测。 仅通过进程是否退出判断工作进程的健康状况。如果工作进程卡在 Clang 内部(死循环而非崩溃),系统不会自动检测或重启该进程;受影响的请求会一直等待,直至被取消。
同步启动。 服务器在开始响应请求之前,会先完成编译数据库加载、工具链缓存预热和初始依赖扫描,而且目前还没有进度报告——在超大型项目中,服务器启动后的一段时间内可能看起来像无响应。
