来源:https://clickhouse.com/blog/silk

作者: “James Cunningham and Vadim Skipin”

摘要: “Silk 是一个为 ClickHouse 构建的全新开源 C++ 纤程运行时,结合了 NUMA 感知的工作窃取调度器、io_uring I/O 以及在稳定状态下零堆内存分配的特性,可实现纳秒级的纤程切换,并显著降低尾部延迟。”

发布 Silk:为 ClickHouse 打造的丝滑纤程运行时

TL;DR {#tldr}

Silk 是一个有栈纤程库和调度器,具有 NUMA 感知的工作窃取循环、以 io_uring 为 I/O 核心,并在稳态热路径中实现零堆内存分配。我们为 ClickHouse 构建了它,并计划首先将其集成到我们的分布式缓存中。

什么是纤程?什么是 Silk? {#what-are-fibers-what-is-silk}

纤程是一种轻量级的用户态执行单元,有点像线程。与线程不同,纤程参与的是协作式多任务处理,而非线程使用的抢占式多任务处理;这允许纤程主动让出工作,而不是阻塞等待。这种行为特别适合异步 I/O,随着 CPU 速度越来越快、集群规模越来越大,异步 I/O 正日益成为分布式系统中的瓶颈。

与线程不同,纤程并没有丰富的语言生态系统支持,这就是我们创建 Silk 的原因。Silk 是一个 C++ 库,它为你提供了一个协作式纤程调度器,由每个 CPU 的调度器支持,该调度器使用 io_uring 进行异步 I/O,并在本地队列为空时在核心之间窃取工作。它在执行高并发的网络 I/O(提示:ClickHouse)和高并发的文件 I/O(惊喜:也是 ClickHouse)方面表现出色。

其名称是为了向 Cilk 致敬,Cilk 是 1994 年 MIT 的工作窃取调度器,其名称本身是 “silk” 和 C 的合成词。Silk 旨在继承这一谱系。将纤程比作丝线(silk thread)的比喻是一个额外的好处。

促使我们编写一个运行时,而不是使用现成的产品,是因为我们需要它具备以下特性的组合:

  1. 在几十纳秒内完成切换的纤程
  2. 尊重 CPU 拓扑结构的工作窃取
  3. 稳态下无堆内存分配
  4. io_uring 视为 I/O 核心,而非附加在旧反应器设计上的后端。

没有任何现成的选项能同时满足这四点。所以我们编写了一个能够满足的,并且我们附带了工具链、GDB 扩展和 BPF 分析器,以证明我们打算在 ClickHouse 中依赖它。

为什么是纤程,为什么是这些纤程,以及为什么是现在? {#why-fibers-why-these-fibers-and-why-now}

ClickHouse 已经有一个并发模型,并且它工作得很好。对于引擎中类似查询执行的部分,即长时间运行、执行真正 CPU 工作的线程来说,这是正确的模型,其中每线程的开销分摊到数百万行的计算中。

然而,对于引擎的其余部分,我们需要 Silk。如果你在 ClickHouse Cloud 中追踪一个查询,越来越常见的瓶颈不是“一个线程做了大量计算”,而是“以特定顺序完成的成千上万个微小操作,其中最慢的一个决定了尾延迟”。这旨在提高对象存储 I/O、分布式缓存查找、副本协调、HTTP 扇出(fan-out)的性能。所有这些都是 I/O 密集型、高度并发的组件,其性能由 99 分位和 99.9 分位决定。这些工作负载恰恰是每个传输中的请求的成本应该是一个栈指针,而不是一个内核线程。

相对于操作系统线程或无栈的 C++20 协程,支持有栈纤程的论点本质上是:操作系统线程作为数据库引擎中的主要并发单元成本太高。每次上下文切换需几微秒,栈需要几千字节,并且在内核因上下文切换而耗尽资源之前,其数量是有限的。无栈协程成本低廉,但具有传染性:挂起路径上的每个函数都必须标记为可 co_await,并且编译器堆分配消除优化通常在协程句柄逃逸到真正的调度器队列时就停止触发了。有栈纤程无需语言层面的改造即可提供廉价的挂起能力:任何函数都可以 yield,并且栈就是一个普通的栈。

历史上对有栈纤程的反对意见,可以追溯到阿里巴巴的 Photon 论文,即缓存别名:从 slab 分配的纤程可能拥有映射到相同 L1 缓存行的栈,从而产生病态的缓存驱逐。Photon 论文测量到由此产生的约 13% 的调度器级开销。Silk 的回应是,这个问题是 slab 分配栈的特性,而不是有栈纤程本身的普遍问题。每个纤程的栈都从每个纤程的池中通过 mmap 分配,并在两侧设有保护页。不存在 slab,也没有别名。在我们的基准测试中,13% 的开销没有出现,因为其存在的前提条件并不存在。

根据 Silk 自身与业界其他方案的基准测试对比,它大致能提供以下性能:

  • 约 3.6 纳秒每次纤程切换(含跨 CPU 工作窃取)
  • 约 7.6 微秒一次 io_uring ping-pong 往返
  • 在工作配置下达到 590 万文件 IOPS
  • 在单连接时吞吐量约为 boost::asio 的十五倍,在高并发时约为四倍
  • 通过 rseq,每 CPU 无锁栈性能在 32 线程时比全局无锁栈快 2068 倍

想自己测试这些数字吗?它们来自仓库中的一个基准测试工具(./bb),该工具通过 Silk 和对比工具运行完全相同的工作负载,并带有受控的 CPU 亲和性、固定的预热期、百分位数跟踪和 JSON 输出,任何人都可以重新运行和验证。方法论是 Silk 展示自身最有力的一个方面。

Silk 是如何工作的? {#how-does-silk-work}

调度器为每个 CPU 运行一个操作系统线程,并固定(pinned)在该 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 构建一个窃取候选列表,按估计成本排序:超线程兄弟优先(约 1 微秒),同一插槽的其他核心次之(约 50 微秒),跨插槽核心最后(约 500 微秒)。当一个 CPU 进行窃取时,它按成本顺序遍历其候选列表,并在每个成本层级内随机打乱,以避免热点集中。这种技术是对“NUMA 感知”调度器的具体实现,不仅仅是“我们有独立的队列”,而是“我们知道从哪些 CPU 窃取成本低,并优先选择它们”。

除了拓扑感知调度,Silk 还有另一个重要的高性能特性:稳态运行时不会进行堆内存分配。 纤程栈来自一个在初始化时通过 mmap 分配且永不释放的池。FiberFutureIoFutureSleepFutureMultipleWaitState 都位于调用者的栈上;waitForMultiple 中的 outstandingCount 计数正是为了确保状态在栈上,并且函数必须等到所有传输中的信号都完成后才能返回。每个容器都是侵入式的:队列节点、挂起列表条目、无锁栈钩子和等待者表钩子都是 Fiber 对象内部的字段,而不是单独分配的内存。一个纤程可以同时被放入三个不同的容器中,而堆内存的额外开销为零。SleepFuture 也是如此,它携带自己的 StackEntryTreeEntry 字段,用于取消队列和按截止时间排序的树。在初始化之后,热路径完全不进行任何分配。不是比其他库少,而是零。

我们交付 Silk 的最后一个重要性能特性:boost::asio 每次异步操作都会分配内存。C++20 无栈协程除非 HALO 触发,否则会在堆上为每个协程帧分配内存(而使用真实调度器时通常不会触发)。在生产级通用异步运行时中,零热路径分配的特性几乎专属于为实时使用而设计的系统:DPDK、Seastar 或 Linux 内核的部分模块。Silk 作为一项深思熟虑的设计选择,也属于这个类别,其结果是它可以部署在分配器行为属于服务等级协议一部分的环境中:例如在内存压力下的查询执行、内核旁路路径,或者对延迟敏感的热循环中,一个在错误页面上的 malloc 意味着错过截止时间。这些都是高性能分布式数据库的关键热点。

有哪些设计选择? {#what-are-some-of-the-design-choices}

同步原语在形态上是经典的。 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 分析器,支持 on-CPU 和 off-CPU 采样,其功能通过能力门控支持非特权使用。一个基准测试工具,针对每种工作负载运行与参考工具的对比(asio 用于网络 I/O,fio 用于文件 I/O,sockperf 用于 TCP 延迟,nginx 用于 HTTP)。GDB 扩展在 CTest 中有自己的集成测试。我们希望交付一个不仅限于我们自己使用的库。

这是对 Photon 缓存别名问题的反驳。 Photon 论文多年来一直作为“有栈纤程慢”的标准参考文献流传。关于其测量的 13% 调度器级缓存未命中率是 slab 分配栈的产物,而非有栈纤程本身的问题,并且使用带保护页的 mmap 池可以完全绕开这一问题的论点,尚未以 Silk 呈现的形式发表过。基准测试支持了这一点:Silk 的每次切换成本在纳秒级别,而不是你从 13% 未命中率运行时可能预期的微秒级别。

好,但它并非完美无缺,对吧? {#ok-but-its-not-perfect-right}

虽然我们为我们所编写的内容感到自豪,但我们也能承认其局限性和约束。

首先,也是最重要的,Silk 仅支持 Linux。它依赖于 io_uring、eventfd、带保护页的 mmap、rseq 以及现代 Linux 能力模型。没有支持 macOS、Windows 或旧内核的可移植层。这是一个有意的范围选择,因为目标是服务器级 Linux,并且支持 kqueue 或 IOCP 会使工作量翻倍,而团队并没有这个用例。

其次,调度器是一个进程范围内的单例,通过 FiberScheduler 上的静态方法访问。无法在同一个进程中实例化两个隔离的调度器。这使得 API 符合人体工程学,但排除了测试场景和高级用法,例如“一个调度器用于延迟关键型工作,一个用于批处理”。我们有意将多调度器功能排除在库的当前范围之外;因为以后添加它将会是一个破坏性的 API 变更。

第三,纤程 API 要求入口点参数大小不超过 64 字节(FIBER_PARAMETERS_SIZE)。更大的有效载荷需要堆分配并通过指针传递。这避免了常见情况下的每纤程分配开销,但这是一个真正的约束,会在编译时通过 static_assert 显现出来。

最后但并非最不重要的是,随库提供的分析器,在其当前形式下,是一个通用的 on-CPU 和 off-CPU 采样分析器。它很有用,但尚未感知纤程身份,不过每纤程的属性归因在我们的路线图上。架构基础已经就位:Silk 知道每个线程上正在运行哪个纤程(通过 threadFiber TLS),GDB 扩展已经展示了可以从外部遍历挂起的纤程栈,并且 BPF 分析器的结构支持增量添加探针。缺少的是 BPF 程序更新以读取 TLS,以及可能在挂起/恢复边界处添加一两个 USDT 探针。

ClickHouse 将首先在哪里使用它? {#where-will-clickhouse-utilize-this-first}

虽然我们有很多地方可以通过纤程来提高性能,但第一个可能的目标是我们的分布式缓存。它是网络密集型和高扇出的,一个查询可能触及数百个缓存节点。它以决定查询延迟的方式对尾延迟敏感。每个缓存请求都可以清晰地映射到一个单独的纤程:扇入,执行 io_uring 读取,扇出,返回。I/O 已经是 io_uring 形态的,并且工作集由短生命周期的请求主导,而非长时间运行的查询工作,因此 Silk 的稳态零分配属性在此处最为明显。我们预计最显著的收益将在尾部:99 分位和 99.9 分位,在这些分位点上,操作系统调度器抖动和数千个并发线程下的分配器暂停是主要贡献者,而 Silk 的每 CPU 固定和零热路径分配使得内核和分配器没有什么可犹豫的。我们已经在内部基准测试中看到了这种形态:在 10,000 个并发 S3 风格请求下,纤程执行器的 99.9 分位延迟大约比等效的线程池执行器好 65%,即使中位吞吐量相同并且 MinIO 在两者中都是瓶颈。分布式缓存是 Silk 首先运行得最顺畅的地方;引擎的其余部分将在不同的时间线上进行,我们会在每次集成落地时进行介绍。

在哪里可以了解更多? {#where-can-i-see-more}

Silk 发布在 github.com/ClickHouse/silk。该仓库包含库、基准测试工具、GDB 扩展、BPF 分析器以及四个值得首先打开的文档:docs/scheduler.mddocs/sync.mddocs/coroutines.mddocs/perf.md。本文中的每个基准测试都可以从干净检出的代码库中重现。如果你正在一个 Linux 服务器级 C++ 系统上工作,并且工作负载看起来像高并发 I/O 并具有严格的尾延迟要求(分布式缓存、对象存储客户端、RPC 框架、HTTP 扇出),那么 Silk 处于一个值得被检验的状态。阅读文档,运行基准测试,提交问题。ClickHouse 之所以发展迅速,是因为其底层是精确的。Silk 是其下一个底层,也是我们所需要的层。

最后,随着我们将 Silk 融入 ClickHouse,我们将撰写更多关于它如何提高性能的文章。敬请期待!

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐