宣布 Silk:为 ClickHouse 打造的丝滑纤程运行时
摘要
Silk 是一个为 ClickHouse 设计的新栈式纤程库和调度器,通过 NUMA 感知的工作窃取、io_uring 以及热路径上的零堆分配来提升异步 I/O 性能。它适用于分布式缓存、对象存储和网络 I/O 等 I/O 密集型组件。
<p><a href="https://lobste.rs/s/pd1ftk/announcing_silk_silky_smooth_fiber">评论</a></p>
查看缓存全文
缓存时间: 2026/06/25 23:17
# 宣布 Silk:为 ClickHouse 打造的丝般顺滑的纤程运行时 | ClickHouse
来源:https://clickhouse.com/blog/silk
*Silk 是一个带栈纤程库与调度器,具备 NUMA 感知的工作窃取循环、将 io\_uring 作为 I/O 事实标准,并且在稳态热路径上零堆分配。我们为 ClickHouse 构建了它,首个计划集成的地方是我们的分布式缓存。*
纤程是一种轻量级的用户态执行单元,有点像线程。但与线程不同,纤程参与的是协作式多任务,而非线程使用的抢占式多任务;这使得纤程可以主动让出工作,而不是阻塞等待。这种特性特别适合异步 I/O——随着 CPU 速度提升和集群规模扩大,异步 I/O 正日益成为分布式系统的瓶颈。
与线程不同,纤程缺乏丰富的语言生态系统支持,这就是我们创建 Silk 的原因。Silk 是一个 C++ 库,提供协作式纤程调度器,底层由每个 CPU 的调度器支撑,使用`io_uring`进行异步 I/O,并在本地队列为空时在核心之间窃取工作。它在执行高并发网络 I/O(提示:ClickHouse)以及高并发文件 I/O(意外吗?还是 ClickHouse)方面表现卓越。
其名称向 Cilk 致敬,后者是 1994 年麻省理工学院的工作窃取调度器,其名字本身就是“silk”与 C 的混合体。Silk 旨在延续这一传统。将纤程比作丝线这一隐喻是附带的好处。
我们之所以决定自己编写运行时,而非直接使用现成的方案,是因为我们需要它具备以下组合特性:
1. 在数十纳秒内让出(yield)的纤程
2. 尊重 CPU 拓扑结构的工作窃取
3. 稳态下无堆分配
4. 将`io_uring`视为 I/O 事实标准,而非作为老旧反应器设计上的附加后端
现成的方案没有一个能同时满足这四点。于是我们编写了一个能做到的,并随附了工具集、GDB 扩展和 BPF 分析器,这证明我们打算在 ClickHouse 中依赖它。
ClickHouse 已经拥有自己的并发模型,并且运行良好。对于查询执行这类场景——长时间运行的线程进行真正的 CPU 计算,此时每线程的开销分摊在数百万行的计算中——这是合适的模型。
然而,对于引擎的其他部分,我们需要 silk。如果你在 ClickHouse Cloud 中追踪一个查询,你会发现越来越常见的长尾不是“某个线程做了大量计算”,而是“一万个微小操作以特定顺序完成,其中最慢的那个决定了尾部延迟”。这旨在提升对象存储 I/O、分布式缓存查找、副本协调、HTTP 扇出(fan-out)的性能。所有这些组件都是 I/O 密集、高并发,并由第 99 百分位和第 99.9 百分位决定的。它们正是那种应该用栈指针(而非内核线程)来表示一个在途请求代价的工作负载。
支持带栈纤程而非操作系统线程或无栈 C++20 协程的理由,本质上如下:操作系统线程作为数据库引擎的主要并发单元代价过高。每次上下文切换数微秒、千字节的栈空间,并且数量有限——一旦超过某个阈值,内核就会因上下文切换而崩溃。无栈协程廉价但具有传染性:挂起路径上的每个函数都必须标记为`co_await`可用,而且编译器的堆分配消除优化(HALO)一旦协程句柄逃逸到真实的调度器队列,就会可靠地失效。带栈纤程则提供了廉价的挂起能力,且无需语言层面的标记:任何函数都可以让出,栈就是普通的栈。
历史上对带栈纤程的反对意见(可追溯到阿里巴巴的 Photon 论文)是缓存别名问题:从 slab 分配的纤程栈可能映射到相同的 L1 缓存行,导致病态的缓存驱逐。Photon 论文测量到由此产生的调度器级别开销为 13%。Silk 的回应是:这个问题是 slab 分配栈特有的,并非带栈纤程的普遍性质。每个纤程的栈通过`mmap`从每纤程池中分配,两侧带有防护页。没有 slab,也没有别名。在我们的基准测试中,那 13% 的开销并未出现,因为其前提条件不存在。
根据 Silk 自身与业界对比的基准测试,其大致性能如下:
- 跨 CPU 工作窃取时,每次纤程让出约 3.6 纳秒
- 一次`io_uring`乒乓往返约 7.6 微秒
- 在典型配置下达到 590 万文件 IOPS
- 在单个连接上,吞吐量约为`boost::asio`的 15 倍;高并发下约为 4 倍
- 通过`rseq`,每 CPU 无锁栈性能在 32 线程时比全局无锁栈快 2068 倍
想自己验证这些数字吗?它们来自仓库中的基准测试工具(`./bb`),该工具在可控的 CPU 绑定、固定预热期、百分位追踪和 JSON 输出的条件下,对 silk 和对比工具运行完全相同的工作负载,任何人都可以重跑并验证。这种方法论是 Silk 展现自身的最强方面。
调度器为每个 CPU 运行一个绑定的操作系统线程。每个调度器线程拥有一个每 CPU 的`ProcessorState`,包含一个有界就绪队列(Vyukov MPMC 队列,生产/消费者槽位按缓存行对齐)、一个用于异步 I/O 和定时器过期的`io_uring`环、一个按截止时间排序的睡眠树,以及一个同时充当唤醒门铃的 eventfd。每个涉及纤程的操作(提交 I/O、唤醒等待者、调度新纤程)尽可能在其发起的 CPU 上执行。当某个 CPU 的就绪队列为空时,调度器线程通过 eventfd 上的持久`IORING_OP_POLL_ADD_MULTI`唤醒,并运行一个服务循环,处理其 CQ 环、处理已过期的睡眠、并寻找可窃取的工作。
工作窃取是感知拓扑结构的。启动时,silk 从`/sys`读取系统的 CPU 拓扑,并按估计代价排序为每个 CPU 构建一个窃取候选列表:首先超线程兄弟(约一微秒),然后同插槽核心(约五十微秒),最后跨插槽核心(约五百微秒)。当 CPU 进行窃取时,它按代价顺序遍历候选列表,并在每个代价层级内随机打乱以避免热点。这一技术是“NUMA 感知”调度器的具体实现——不仅仅是“我们拥有独立的队列”,更是“我们知道哪些 CPU 窃取代价低,并优先选择它们”。
除了拓扑感知调度,silk 还有另一个重要的性能特性:**稳态运行时无堆分配。** 纤程栈来自一个在初始化时通过`mmap`分配的池,且从不释放。`FiberFuture`、`IoFuture`、`SleepFuture`和`MultipleWaitState`都位于调用者的栈上;`waitForMultiple`中的`outstandingCount`记账之所以存在,正是因为状态在栈上,且函数必须等待所有在途信号完成后才能返回。每个容器都是侵入式的:队列节点、挂起列表条目、无锁栈钩子、等待者表钩子都是`Fiber`对象本身的字段,而非独立分配。一个纤程可以同时入队三个不同的容器,而堆开销为零字节。`SleepFuture`也是如此,它自带`StackEntry`和`TreeEntry`字段,用于取消队列和按截止时间排序的树。初始化之后,热路径完全不进行任何分配。不是比其他库少,而是零。
我们随 silk 发布的最后一个重要性能特性:`boost::asio`会为每次异步操作分配内存。C++20 无栈协程会在堆上为每个协程帧分配内存,除非 HALO 触发(但实际调度器下通常不会)。在生产级别的通用异步运行时中,零热路径分配的特性几乎只属于为实时用途设计的系统:DPDK、Seastar,或 Linux 内核本身。Silk 作为有意设计的选择,也属于这一类别。其结果是,它可以部署在分配器行为属于 SLA 一部分的地方:内存压力下的查询执行、内核旁路路径,或对延迟敏感的热循环中——在这些场景下,一个因错误页错误触发的`malloc`就意味着错过截止时间。这些都是高性能分布式数据库的关键热点。
**同步原语在形式上相当经典。**`FiberFuture`的压缩状态模式、`FiberSequencer`的扁平组合循环、以及`FiberMutex`的锁与标志竞态处理,都是经典模式的规范实现——这些模式往往以微妙的方式出错,而非被正确实现。每个内存屏障都有对应的配对。每次 CAS 都使用必要的最严格顺序,而不更强。
**HALO 在处理生产工作负载的调度器中并不触发。** C++20 无栈协程的标准宣传是“由于 HALO,零开销”。HALO 要求协程句柄从不逃逸到调度器队列。每个真实调度器都违反这一条件,因此“零开销”的说法在调度器简单的合成基准测试中成立,而在调度器承载实际负载的真实应用中则不成立。
**停车-然后-唤醒的竞态处理是吞吐量的关键。** 每个使纤程挂起的原语都有相同的模式:乐观地尝试操作;失败时设置一个指示等待者存在的标志;通过一个回调(在纤程完全停车后运行)挂起纤程;在回调中,将纤程注册为等待者并重新检查是否有遗漏的唤醒。一旦你在`FiberFuture`中仔细阅读过它,futex、互斥锁和顺序器都会变得易懂。
**整个同步层就是一个模式。** 当你能够用 700 行实现六个同步原语,因为它们都是“压缩状态 + 标志 CAS + 队列或表 + 重新检查的挂起回调”的变体时,你就找到了正确的抽象。该库的构建让每个原语都刻意基于前一个构建。六个原语,两个模式,一个底层的挂起回调契约。
**公共 API 很小。**`FiberScheduler`中共有八个动词:初始化、销毁、运行、调度、让出、挂起、入队/释放等待者,以及 I/O 原语。头文件不到 400 行,读起来像 API 参考文档。带栈状态被视为实现细节,而非用户需要围绕构建的东西。
**基准测试是可重现的。** 每次比较都是公平对比,每次运行都可以通过一条命令重现。silk 与 asio 的对比,是 silk 对阵 asio 的*更好*配置:启用 asio 的 io\_uring 后端反而使其变慢,而非更快。silk 与 fio 的对比,是 silk 对阵 fio 的`--ioengine=io_uring`,而非`psync`。
**运营工具与代码本身一样严肃。** 一个可用的 GDB 扩展,支持 x86\_64 和 aarch64,其帧布局取自 Boost.Context 汇编源文件。一个 BPF 分析器,支持 CPU 上和 CPU 外采样,并通过能力门控支持非特权使用。一个基准测试工具,对每种工作负载运行与参考工具的对比(网络 I/O 用 asio,文件 I/O 用 fio,TCP 延迟用 sockperf,HTTP 用 nginx)。GDB 扩展有自己的 CTest 集成测试。我们希望发布的库能对自身作者之外的其他人也有用。
**这是对 Photon 缓存别名论点的反驳。** Photon 论文多年来一直作为“带栈纤程慢”的标准参考流传。其论点——它测量到的 13% 调度器级缓存未命中率是 slab 分配栈的产物,而非带栈纤程本身的问题,并且通过带防护页的 mmap 池可以完全规避——从未像 Silk 所呈现的那样被公开发表。基准测试证实了这一点:silk 的每次让出代价在纳秒级别,而非 13% 未命中率运行时你可能预期的微秒级别。
虽然我们对自己所创造的感到自豪,但我们也承认其局限与约束。
首先也是最重要的,Silk 仅适用于 Linux。它依赖 io\_uring、eventfd、带防护页的 mmap、rseq 以及现代 Linux 能力模型。没有针对 macOS、Windows 或旧内核的可移植层。这是有意的范围选择,因为目标是服务器级别的 Linux,而支持 kqueue 或 IOCP 将使维护面积翻倍,且团队没有相关用例。
其次,调度器是一个进程级的单例,通过`FiberScheduler`上的静态方法访问。无法在同一个进程中实例化两个隔离的调度器。这使得 API 符合人体工程学,但排除了测试场景以及高级用法,例如“一个调度器用于延迟关键型工作,另一个用于批量处理”。我们有意将多调度器功能排除在当前库范围之外;因为如果将来添加,将是一个 API 破坏性变更。
第三,纤程 API 要求入口点参数适应 64 字节(`FIBER_PARAMETERS_SIZE`)。更大的载荷需要堆分配并通过指针传递。这避免了常见场景下每纤程的分配抖动,但这是一个真实的约束,会在编译时通过`static_assert`暴露出来。
最后但同样重要的是,目前随库提供的分析器是一个通用的 CPU 上和 CPU 外采样分析器。它很有用,但尚不支持纤程身份识别——尽管每纤程归因已列入我们的路线图。架构基础已经到位:silk 知道每个线程上运行的是哪个纤程(通过`threadFiber`TLS),GDB 扩展已经证明可以从外部遍历挂起的纤程栈,BPF 分析器的结构也支持逐步添加探针。目前缺少的是读取 TLS 的 BPF 程序更新,可能还需要在挂起/恢复边界添加一两个 USDT 探针。
虽然我们有很多地方可以通过纤程提升性能,但第一个可能的目标是我们的**分布式缓存**(https://clickhouse.com/blog/building-a-distributed-cache-for-s3)。它是网络密集型和高度扇出的,单个查询可能触及数百个缓存节点。它对尾部延迟敏感,其方式决定了查询延迟。每个缓存请求清晰地映射到一个纤程:扇入、执行 io\_uring 读取、扇出、返回。I/O 已经是 io\_uring 的形状,工作负载以短生命周期请求为主,而非长时间运行的查询工作,因此 silk 的稳态零分配特性在这里最为明显。我们预计最大的可见提升将在尾部:第 99 和第 99.9 百分位——在这些地方,操作系统调度器抖动和数千个并发线程下的分配器暂停是主导因素,而 silk 的每 CPU 绑定和零热路径分配让内核和分配器无从抖动。我们已在内部基准测试中看到了这种形态:在 10000 个并发的 S3 风格请求下,即使中位吞吐量相同且 MinIO 在两边都是瓶颈,纤程执行器的第 99.9 百分位比等效线程池执行器好大约 65%。分布式缓存是 silk 最先运行得最顺畅的地方;引擎其他部分的整合时间线另行安排,我们会在每次整合落地时撰文说明。
Silk 已发布在 github.com/ClickHouse/silk (http://github.com/ClickHouse/silk)。仓库中包含库本身、基准测试工具、GDB 扩展、BPF 分析器,以及四个值得首先打开的文档:`docs/scheduler.md` (https://github.com/ClickHouse/silk/blob/main/docs/scheduler.md)、`docs/sync.md` (https://github.com/ClickHouse/silk/blob/main/docs/sync.md)、`docs/coroutines.md` (https://github.com/ClickHouse/silk/blob/main/docs/coroutines.md)。
相似文章
Silk: 开源协作式纤程调度器
Silk 是一个面向 Linux 的开源协作式纤程调度器,具有每 CPU 调度线程、io_uring 集成和拓扑感知的工作窃取功能,专为低开销下的高并发而设计。
@jhleath: https://x.com/jhleath/status/2065408690992148698
作者解释了如何构建一个能够在恒定时间内每秒启动数百万个沙箱的计算平台,重点介绍了使用Cassandra和S3进行解耦调度和能力聚合。
Gossamer:一种具有真实goroutines和无暂停内存的Rust风格语言
Gossamer是一种受Rust启发的新编程语言,具有真实goroutines、基于引用计数和区域的无暂停确定性内存管理,以及配备LLVM编译的字节码虚拟机。它旨在提供富有表现力的语法,无需借用检查器或垃圾回收暂停。
Zig 0.16 中的异步 I/O:今日视角
Zig 0.16 推出了新的 std.Io 接口,用于跨平台 I/O。zio 库通过栈式协程和操作系统级异步 API 提供了完整的异步实现,无需每个任务一个线程即可实现高效的并发任务。
TokenSpeed:面向智能体工作负载的"光速"LLM推理引擎(5分钟阅读)
Lightseek发布TokenSpeed,一款面向智能体工作负载优化的高性能LLM推理引擎,采用编译器驱动的并行技术和先进的内核优化,相关技术已被vLLM采纳。