Skip to content

多进程架构

背景

C++ 语言服务器面临一个独特的工程挑战:它依赖 Clang 来解析代码,而 Clang 在处理不完整、不合法的代码时存在大量未经充分测试的路径。用户在编辑器中输入的代码在绝大多数时刻都是不完整的——缺少分号、括号不匹配、模板实例化中断——这些都是 Clang 设计时未重点考虑的场景。这导致两个严重的问题:

内存泄漏与膨胀:Clang 的设计假设编译是一次性的短生命周期操作。编译器进程分配内存,编译完成后退出,操作系统回收所有内存。但语言服务器是长期运行的进程——每次用户编辑都触发重新编译,Clang 内部累积的内存无法被有效回收。在 clangd 社区中,用户频繁报告服务器运行数小时后内存占用达到 10GB+,最终被系统 OOM killer 终止。典型场景包括:模板加宏的组合导致后台索引耗尽 16GB 内存;合法代码(如大数组声明 arr[50][6000000])因常量初始化检查导致 OOM。

崩溃:不完整的代码会触发 Clang 内部的各种断言失败和空指针解引用。在 clangd 的 issue 中,大量报告涉及模板实例化崩溃、枚举声明段错误、catch 子句命名空间损坏等场景。由于 clangd 是单进程架构,任何一次崩溃都会导致整个语言服务器终止,用户丢失所有编辑状态——即使崩溃只与某一个文件相关。

优先级倒置:在单进程/单线程架构中,后台索引和前台交互请求(hover、补全等)竞争同一线程。当后台索引正在处理一个大文件时,用户的 hover 请求可能需要等待数秒才能得到响应。即使使用线程池,线程间的优先级控制也很困难——操作系统的线程调度器不理解"用户正在等待 hover 结果"这种语义。

设计方案

核心思想

clice 通过多进程架构解决上述问题:每个编译任务运行在独立的工作进程中,主进程只负责状态管理和请求路由。工作进程的崩溃或内存泄漏被隔离在进程边界内,不影响主进程和其他工作进程。

进程模型

text
主进程(MasterServer)
├── 事件循环(kota)
├── LSP/Agentic 协议处理
├── 状态管理(Workspace、Session)
├── 编译调度(Compiler、Indexer)

├── 有状态工作进程 × N
│   ├── SF-0:持有 AST,服务查询
│   ├── SF-1:持有 AST,服务查询
│   └── ...

└── 无状态工作进程 × M
    ├── SL-0:执行一次性任务
    ├── SL-1:执行一次性任务
    └── ...

所有工作进程通过 stdin/stdout 管道与主进程通信,使用 bincode 序列化(高效的二进制格式,基于 kotatsu 的 BincodePeer)。每个工作进程的 stderr 重定向到独立的日志文件,方便单独调试。

主进程的角色

主进程是整个系统的协调者,运行单线程的事件循环。它不执行任何 CPU 密集型的编译工作——所有编译都委托给工作进程。主进程的职责包括:

  • 接收和路由 LSP/Agentic 请求
  • 管理全局状态(Workspace、Session 映射)
  • 调度编译任务和后台索引
  • 监控工作进程的健康状态
  • 处理文件变更通知和级联更新

单线程设计使得主进程不需要任何锁——所有状态修改都在事件循环中串行执行。这大大简化了状态管理的复杂度。

为什么是两种工作进程

语言服务器的编译任务天然分为两类,它们的特征差异决定了需要不同的调度策略:

需要持有状态的任务:用户打开一个文件后,会反复查询它的 hover 信息、语义高亮、符号大纲等。这些查询都基于同一个 AST。如果每次查询都重新编译,延迟不可接受(一次完整编译可能需要数秒)。因此需要在内存中保留编译好的 AST,供后续查询复用。这类任务需要文件亲和性——每个文件固定绑定到一个进程。

一次性任务:PCH 构建、PCM 构建、后台索引、代码补全、签名帮助、格式化等都是一次性的——执行完毕后不需要保留状态。这类任务需要负载均衡和优先级调度——任何可用的工作进程都可以执行,但用户交互任务应该优先于后台任务。

将它们分为两种工作进程,可以各自使用最合适的调度策略。

有状态工作进程

有状态工作进程的核心职责是持有编译好的 AST 并服务查询请求。

文件亲和性路由

每个打开的文件通过 path_id 绑定到一个有状态工作进程。这个绑定关系存储在主进程的路由表中。当该文件的查询请求到达时,主进程根据路由表将请求发送到对应的工作进程。

新文件的分配策略是最小负载——选择当前拥有最少文档的工作进程。这确保了文档在工作进程之间的均匀分布。

编译与查询流程

  1. 主进程发送 CompileParams(源码文本、编译参数、PCH/PCM 路径等)
  2. 工作进程编译 AST,缓存在内存中的 DocumentEntry 中
  3. 后续的 QueryParams(hover、semantic tokens、document symbol 等)复用缓存的 AST
  4. 当文件内容变化(didChange),主进程发送 DocumentUpdate 通知
  5. 下次编译请求到来时,工作进程用新内容重新编译 AST

每个文档的请求通过 per-document 的互斥锁串行化,确保编译和查询不会在同一文档上并发执行。

文档淘汰

当工作进程持有的文档数量超过限制时,通过 LRU 策略淘汰最久未使用的文档,释放 AST 占用的内存。淘汰时工作进程向主进程发送通知。后续对该文档的请求会触发重新分配到工作进程并重新编译。

无状态工作进程

无状态工作进程执行一次性编译任务,采用优先级感知的调度策略。

两级优先级队列

  • 高优先级:用户交互相关的任务——代码补全、签名帮助。这些需要即时响应,用户正在等待结果。
  • 低优先级:后台任务——索引构建、PCH/PCM 构建、格式化。这些可以延迟执行,不影响用户体验。

高优先级任务总是优先获取工作进程资源。低优先级任务受并发限制——同一时间运行的低优先级任务数量有上限(low_limit),确保始终有工作进程可以响应高优先级请求。

此外,低优先级任务的系统进程优先级也会被调低(通过 nice 系统调用),减少它们对系统其他进程(包括编辑器本身)的 CPU 影响。

动态并发控制

低优先级任务的并发上限根据系统状态动态调整:

内存压力反馈:主进程定期(每 3 秒)检查系统可用内存。当可用内存低于总量的 20% 时,将 low_limit 减 1;当可用内存恢复到 40% 以上时,将 low_limit 加 1。这种线性调整使系统在内存紧张时平缓地减少后台负载。

崩溃退避:当无状态工作进程崩溃时,将 low_limit 乘以 3/4(乘法下降)。崩溃通常意味着遇到了触发 Clang bug 的代码,继续高并发可能使更多工作进程遇到同样的问题。乘法下降比线性下降更激进,能更快地降低系统负载。

这种组合策略——内存压力线性调整 + 崩溃乘法退避——确保系统在高负载下优雅降级,而不是突然 OOM 或级联崩溃。

崩溃恢复

进程隔离的核心价值在于崩溃恢复——将一个工作进程的故障控制在该进程内部,不影响整体服务。

有状态工作进程崩溃

  1. 主进程通过监控任务检测到工作进程退出
  2. 清除路由表中该工作进程的所有绑定关系
  3. 如果未超过最大重启次数,启动新的工作进程
  4. 后续对这些文档的请求会自动路由到新的(或其他可用的)工作进程,并触发重新编译
  5. 用户可能感受到一次短暂的延迟(AST 需要重新编译),但不会丢失任何编辑内容——文本缓冲区存在主进程的 Session 中

无状态工作进程崩溃

  1. 主进程检测到退出
  2. 触发崩溃退避,降低并发上限
  3. 崩溃工作进程上正在执行的任务丢失;只有在后续请求或调度周期重新入队时才会重做
  4. 如果未超过最大重启次数,启动新的工作进程

最大重启次数

每个工作进程槽位有最大重启次数限制。如果同一个槽位反复崩溃(例如每次启动后立即崩溃),说明存在系统性问题,不再重启以避免无限循环。主进程在减少的工作进程数量下继续运行——功能可能降级(如后台索引变慢),但不会完全中断。

设计决策与权衡

为什么是多进程而不是多线程? 多线程无法隔离崩溃——一个线程的段错误会终止整个进程。多线程也无法隔离内存泄漏——所有线程共享同一个地址空间。多进程虽然增加了 IPC 开销(bincode 序列化/反序列化),但提供了真正的故障隔离。对于 Clang 这种已知存在大量崩溃和泄漏路径的库,进程隔离是工程上的必要选择。

为什么有状态和无状态分开而不是统一的工作池? 两者的调度策略根本不同。有状态工作进程需要文件亲和性(确保 AST 复用),无状态工作进程需要负载均衡和优先级队列。统一的工作池要么牺牲亲和性(频繁重编译),要么牺牲调度灵活性(无法区分优先级)。分开的设计让每种工作进程专注于自己的调度需求。

为什么主进程是单线程的? 主进程的工作量很轻——它只做路由和状态管理,不做编译。单线程避免了所有并发控制的复杂性(锁、原子操作、竞态条件),同时基于协程的事件循环足以应对 I/O 密集的路由工作。