sccache 架构全解:哈希决策、Direct Mode 预处理器缓存与双执行模式剖析
【免费下载链接】sccacheSccache is a ccache-like tool. It is used as a compiler wrapper and avoids compilation when possible. Sccache has the capability to utilize caching in remote storage environments, including various cloud storage options, or alternatively, in local storage.项目地址: https://gitcode.com/GitHub_Trending/sc/sccache
sccache 是一个 ccache 风格的编译器缓存工具,通过包装编译器调用来复用缓存结果。本文以 docs/Architecture.md 为主线,结合仓库源码(src/commands.rs、src/protocol.rs、src/cache/ipc_storage.rs、src/compiler/preprocessor_cache.rs 等)展开,系统讲解 sccache 高层架构:一次编译调用如何通过哈希决定“直接编译”还是“复用缓存”、C/C++ 的 Direct Mode(预处理器缓存)如何跳过预处理,以及服务端模式与客户端模式(SCCACHE_CLIENT_SIDE)两种执行模式的分工与演进方向。读完本文,你将能理解 sccache 的缓存命中链路、两种模式的异同与配置方式,并能读懂其核心源码。
一、整体架构:一次编译调用的决策流程
sccache 的核心决策模型非常简单:把一次编译的所有关键输入折叠成一个哈希键,用该键去存储后端(本地磁盘、S3、Redis、Memcached、GCS 等)查询缓存;命中则直接下载并复用结果,未命中则真正执行编译并把产物上传回存储。
sccache 高层决策流程图
下面的流程图(源自 docs/Architecture.md 原文)直观地展示了这一决策过程:
1.1 哈希输入:什么会被折叠进缓存键
上图中的蓝色输入节点——环境变量、编译器二进制、编译器参数、文件内容——共同决定了缓存键的取值。任何一项发生变化都会导致哈希变化,从而产生缓存未命中。具体到语言实现,docs/Caching.md 给出了详细清单:
- Rust 编译:对每个编译文件生成 blake3 摘要,同时把 rustc 可执行文件路径、Host triple、rustc sysroot 路径、
$sysroot/lib下所有共享库的摘要、rlib 依赖(dist-client 场景)以及解析后的 rustc 参数一并纳入哈希。 - C/C++ 编译:哈希基于预处理后文件(
-E产物)的 blake3 摘要,并额外纳入编译器二进制哈希、汇编器二进制哈希与版本、编程语言、语言编译标志、依赖生成参数、预处理参数、架构参数、需要哈希内容的额外文件、覆盖率/剖析数据标志、颜色模式以及环境变量等。 - C/C++ 预处理器(即下文 Direct Mode):在 C/C++ 编译器键的基础上,额外加入输入文件路径与输入文件内容哈希。
1.2 命中与未命中
- 命中(Storage 返回 yes):从存储下载缓存的目标产物(object 文件),跳过编译,直接返回。
- 未命中(Storage 返回 no):真正执行编译(Compile),随后把产物上传(Upload)回存储,供下次复用。
需要说明的是,上述缓存逻辑无论运行在哪种执行模式下都完全一致,区别只在于“由哪个进程实际运行编译器、谁与存储后端对话”——这正是本文第三部分要展开的两大执行模式。
二、Direct Mode:预处理器缓存(C/C++ 专属优化)
对于 C/C++,sccache 还提供一种额外的缓存:缓存预处理结果本身,从而让一次缓存查找可以完全跳过预处理步骤。这一设计灵感来自 ccache 的 direct mode,其细节记录在 docs/Local.md 中。
2.1 工作方式
在计算 object 缓存键之前,sccache 会先以一个“预处理器缓存条目”作为键去查询存储。该条目的键由源文件(路径 + 内容)与预处理参数共同决定,条目中记录着源文件所包含(include)的每一个文件。逻辑如下:
- 若条目存在,且其中记录的所有被包含文件都未发生变化,则复用该条目,完全跳过预处理;
- 否则正常执行预处理,并写回一条新的条目。
该步骤默认开启(对应流程图中蓝色区域),但在以下场景会被跳过:
- 非 C/C++ 语言;
- 显式禁用(见下文的
SCCACHE_DIRECT/use_preprocessor_cache_mode); - 命令行中存在
-Wp,*或-Xpreprocessor等标志(在 src/compiler/c.rs 中体现为too_hard_for_preprocessor_cache_mode判定)。
2.2 源码实现:PreprocessorCacheEntry
src/compiler/preprocessor_cache.rs 中定义了核心数据结构PreprocessorCacheEntry(src/compiler/preprocessor_cache.rs#L49-L59):
results: BTreeMap<String, Vec<IncludeEntry>>:按 object 缓存键(result key)组织的一组被包含文件清单。之所以允许一个源文件对应多个 result,是因为“头文件变了但源文件没变”时,旧条目不能立刻作废。IncludeEntry(src/compiler/preprocessor_cache.rs#L446-L459)记录每个被包含文件的绝对路径、内容摘要(digest)、文件大小、mtime 与 ctime。
命中判定lookup_result_digest(src/compiler/preprocessor_cache.rs#L176-L191)会按“最新优先”顺序遍历 results,对每个条目调用result_matches:先比较文件大小,再根据配置比较 mtime/ctime,最后回退到内容摘要比对。源码还做了健壮性兜底:条目总数上限MAX_PREPROCESSOR_CACHE_ENTRIES = 100、被包含文件信息上限MAX_PREPROCESSOR_CACHE_FILE_INFO_ENTRIES = 10000(src/compiler/preprocessor_cache.rs#L45-L47),超限时直接清空重建,防止条目无限膨胀拖慢查找。
2.3 时间宏的特殊处理
预处理结果可能受到__DATE__、__TIME__、__TIMESTAMP__等时间宏的影响,因此 src/compiler/preprocessor_cache.rs 对它们做了专门处理:扫描文件时若发现__TIME__,直接禁用预处理器缓存模式(因为同一秒内的命中概率极低且结果会过期);若发现__DATE__/__TIMESTAMP__,则把日期/时间信息折叠进摘要,并考虑SOURCE_DATE_EPOCH环境变量的影响。相关测试见该文件末尾的test_find_time_macros_*系列用例。
2.4 相关配置项
docs/Local.md 中给出了预处理器缓存模式的可配置项:
use_preprocessor_cache_mode(默认true):是否启用预处理器缓存模式;单次调用可用环境变量SCCACHE_DIRECT(取true/on/1或false/off/0)覆盖。ignore_time_macros(默认false):为true时忽略源码中的__DATE__、__TIME__、__TIMESTAMP__,可加速缓存命中但可能产生过期结果。skip_system_headers(默认false):为true时预处理器缓存只把系统头文件的路径计入缓存键而忽略其内容。
三、CLI 与守护进程:两种执行模式的分工
sccache 被拆分成两个进程(docs/Architecture.md):
- 短生命周期的 CLI 进程:每次编译器调用(例如
make -jN为每个编译任务)都会启动一个,负责解析命令行、与守护进程通信; - 长生命周期的守护进程(daemon):常驻后台,持有存储后端与累计统计信息。
两者通过本地 IPC 连接通信。连接地址默认端口为4226(常量DEFAULT_PORT,见 src/commands.rs#L51),可通过SCCACHE_SERVER_PORT环境变量覆盖;在 Unix 上还可通过SCCACHE_SERVER_UDS指定 Unix 域套接字(src/commands.rs#L57-L69)。当 CLI 尝试连接而端口无服务时,会通过connect_or_start_server(src/commands.rs#L315-L352)自动以SCCACHE_START_SERVER=1重新拉起守护进程,并等待其启动就绪(默认超时 10 秒,SERVER_STARTUP_TIMEOUT,src/commands.rs#L54)。
“谁跑编译、谁访问存储”有两种划分方式,即下文的服务端模式与客户端模式。两种模式共享同一套缓存决策逻辑,差异仅在进程职责。
四、服务端模式(默认)
服务端模式是当前默认的执行方式。CLI 把整个编译任务转发给守护进程:守护进程执行 Direct Mode 的预处理器缓存查找、计算哈希、查询 object 缓存,未命中时运行编译器并把结果写入存储。编译产物文件(如 object 文件)由守护进程直接写盘;回传给 CLI 的只有 stdout / stderr 输出流与退出码。
4.1 源码佐证:Compile 请求链路
在源码层面,CLI 侧通过do_compile(src/commands.rs#L633-L654)把解析后的命令包装成Request::Compile发给守护进程;守护进程端由handle_compile(src/server.rs#L1167-L1178)接收并执行完整的缓存决策流水线。协议层的数据结构定义在 src/protocol.rs:
Compile结构体(src/protocol.rs#L101-L110)承载编译器可执行文件路径、当前工作目录、命令行参数与环境变量;CompileFinished(src/protocol.rs#L85-L97)携带编译进程的返回码(retcode)、终止信号(signal)、stdout、stderr 以及颜色模式(color_mode),这正是上图中“只有输出流与退出码回传 CLI”的实现载体。
值得注意的是,服务端模式下若守护进程在发送CompileStarted后、发送CompileFinished前意外断开,CLI 会回退到本地直接执行编译(见 src/commands.rs#L517-L569 及同文件末尾的test_handle_compile_response_disconnect_falls_back_to_local测试),保证构建不因守护进程崩溃而失败。
五、客户端模式(SCCACHE_CLIENT_SIDE)
客户端模式下,编译流水线跑在 CLI 进程内部,守护进程只作为访问存储后端的“共享网关”(同时也是聚合统计信息的地方)。
5.1 执行流程
启动时 CLI 先做一次性的StorageHandshake,从守护进程拉取缓存元数据(缓存模式、最大尺寸、basedirs、预处理器缓存配置);随后在本地执行与守护进程完全相同的编译流水线(含 Direct Mode),并把每一次独立的缓存操作通过 IPC 逐条转发给守护进程:
5.2 关键机制:StorageGetPath 与原始字节回退
上图中StorageGetPath让 CLI 在存储后端暴露本地路径时直接读取磁盘上的缓存条目,省去一次网络/序列化开销;对于不暴露本地路径的后端(如 S3、Redis 等),CLI 回退为通过StorageGetRaw拉取原始字节。Direct Mode 的条目则通过StorageGetPreprocessorEntry/StoragePutPreprocessorEntry交换。
由于客户端模式下每个 CLI 进程各自累积统计信息,进程退出前会通过RecordStats把增量统计冲刷给守护进程合并。
5.3 源码佐证:IpcStorage 与协议
客户端模式的核心实现是IpcStorage(src/cache/ipc_storage.rs#L36-L68)。它实现了与所有存储后端相同的Storagetrait(src/cache/cache.rs#L75),因此编译流水线的其余部分无需任何改动——这正是该设计的关键巧妙之处:后端无关性通过 trait 抽象天然获得。
IpcStorage::get(src/cache/ipc_storage.rs#L72-L105)体现了完整的回退链:先尝试StorageGetPath(GetPathResult::Found直接打开文件、Miss返回未命中、Unsupported回退),再回退StorageGetRaw拉取字节。而协议层的请求/响应定义在 src/protocol.rs#L10-L38 与 src/protocol.rs#L42-L71,StorageHandshakeInfo(src/protocol.rs#L113-L121)则完整携带了握手所需的缓存元数据。
守护进程端对这些存储 RPC 的分发逻辑在 src/server.rs#L904-L981:StorageHandshake从当前存储实例收集 location、cache_type_name、basedirs、预处理器缓存配置、缓存模式与最大尺寸;StorageGetPath/StorageGetRaw/StoragePutRaw/StorageGetPreprocessorEntry/StoragePutPreprocessorEntry逐一映射到对应存储方法;RecordStats将增量合并进守护进程的统计。
CLI 侧的入口分支位于 src/commands.rs#L916-L939:当config.client_side_mode为真时,创建仅含 2 个工作线程的 runtime(一个用于预处理/编译,一个用于 IPC),调用do_compile_client_side(src/commands.rs#L662-L716)在进程内构造IpcStorage与SccacheService并直接执行compile_direct(src/server.rs#L1184-L1199),最后把take_stats得到的统计增量经RecordStats回传守护进程。
5.4 开启方式与互斥条件
客户端模式通过环境变量SCCACHE_CLIENT_SIDE(或配置文件中的client_side_mode键)启用,docs/Configuration.md#L195 明确其为推荐模式,并预期未来成为唯一受支持的配置,服务端模式最终会被移除。配置解析在 src/config.rs#L1279(环境变量)与 src/config.rs#L802(文件配置FileConfig字段)完成,环境变量优先级高于文件配置(src/config.rs#L1412-L1419)。
该设置目前与以下两项互斥,任一存在时设置会被忽略、sccache 回退到服务端模式:
| 互斥项 | 原因 |
|---|---|
错误日志(SCCACHE_ERROR_LOG) | 客户端模式下日志总是写到 stderr,多个并发的 CLI 进程会争用同一个日志文件产生写竞争(见 src/config.rs#L1413-L1417 注释);触发条件是设置了SCCACHE_LOG等日志环境变量 |
| 分布式编译(配置了 scheduler URL) | 客户端模式与分布式编译不兼容;触发条件是dist.scheduler_url已配置(src/config.rs#L1419) |
5.5 两种执行模式对比
| 维度 | 服务端模式(默认) | 客户端模式(SCCACHE_CLIENT_SIDE) |
|---|---|---|
| 编译执行位置 | 守护进程 | CLI 进程 |
| 存储访问方式 | 守护进程直接访问 | CLI 经IpcStorage逐条 RPC 转发 |
| 预处理器缓存查找 | 守护进程执行 | CLI 本地执行,经StorageGet/PutPreprocessorEntry转发 |
| 统计信息 | 守护进程统一维护 | CLI 各自累积,退出前RecordStats合并 |
与SCCACHE_ERROR_LOG/ 分布式编译 | 兼容 | 互斥(存在则回退服务端模式) |
| 演进方向 | 计划移除 | 未来唯一支持的配置 |
六、总结:一次调用的完整心智模型
把全文串起来,sccache 对sccache cc -c foo.c的处理可以归纳为:
- 进程拆分:短命 CLI + 常驻 daemon,本地 IPC 通信(默认端口 4226),daemon 未启动时自动拉起;
- Direct Mode 优先(C/C++ 且未禁用):以“源文件路径 + 内容 + 预处理参数”查预处理器缓存条目,命中且被包含文件未变则跳过预处理,直接得到 object 缓存键;
- object 缓存决策:以环境变量、编译器二进制、参数、文件内容(预处理产物)等折叠出的哈希查询存储,命中即下载复用,未命中则编译并上传;
- 执行模式:默认由 daemon 承担全部工作(服务端模式);启用
SCCACHE_CLIENT_SIDE后编译流水线移入 CLI,daemon 退化为存储网关,IpcStorage通过统一的Storagetrait 保证流水线代码零改动。
如果希望进一步深入哈希生成细节与 Direct Mode 的本地实现,可继续阅读 docs/Caching.md 与 docs/Local.md;各存储后端(本地、S3、Redis、Memcached、GCS 等)的实现与配置则分别记录在 docs/S3.md、docs/Redis.md、docs/Memcached.md、docs/Gcs.md 等文档中,其后端模块均位于 src/cache 目录之下。
【免费下载链接】sccacheSccache is a ccache-like tool. It is used as a compiler wrapper and avoids compilation when possible. Sccache has the capability to utilize caching in remote storage environments, including various cloud storage options, or alternatively, in local storage.项目地址: https://gitcode.com/GitHub_Trending/sc/sccache
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考