从 zStorage 性能调优:浅谈多线程的常见性能问题

注:本文内容引用自张洋老师的知乎文章https://zhuanlan.zhihu.com/p/...

我们都知道,多线程能够充分利用硬件资源,从而提升程序的吞吐量。然而,在实际的程序实现中,往往无法达到真正的多线程效果。由于各种因素的影响,线程之间会相互制约,导致性能与线程数量无法呈现线性扩展。本文将结合 zStorage 在性能调优方面的经验,总结线程之间制约的具体因素,以及 zStorage 为解决或缓解这些制约所采取的方法。通过应对这些问题,zStorage 力求实现“真正”的多线程,从而达到极致高性能的目标。

制约多线程性能无法线性扩展的因素主要包括硬件和软件两方面。硬件方面存在诸如 CPU、内存、网卡、硬盘等资源争用问题;软件方面则涉及线程间同步引入的各种同步机制、线程调度及上下文切换、内存分配竞争等问题。下文将针对这些制约因素展开讨论。

CPU

线程上下文切换

如果同一 CPU 核心上运行多个线程,线程调度导致的反复上下文切换,其耗时可能高达毫秒级别。而在高性能存储场景下,这是无法容忍的,因为高性能存储通常要求一个 IO 全流程的耗时小于 1 毫秒。例如,zStorage 在 200 万 IOPS 的情况下,其时延小于 300 微秒。传统的线程模型通常是在一组 CPU 上运行一个线程池,由生产者向队列中添加任务,线程池中的线程负责处理这些任务。由于线程可能会因 IO 操作而阻塞,线程池往往需要使用非常多的线程(成百上千)以达到高并发效果。这种线程池模式在传统机械硬盘环境中一般不会引发问题,因为即使上千线程带来的上下文切换开销严重,也不会成为主要瓶颈,而机械硬盘本身的性能极低(一般只有几百 IOPS),使得硬盘成为主要限制因素。

针对这一问题,zStorage 基于 SPDK 采用了轮询线程模型 。在每个 CPU 核心上仅绑定一个线程,该线程通过轮询不断检查各类任务(如网络 IO、硬盘 IO 等)的完成情况。这样不仅解决了线程上下文切换带来的性能开销问题,还改善了网卡和硬盘在中断模式下性能较差的现象。由于每个 CPU 核心绑定一个线程,所以后文讲到的线程和 CPU 核心表示同一个意思。参考文章:《SPDK 轮询线程模型带来哪些优势?》
https://zhuanlan.zhihu.com/p/...

CPU 缓存

image.png
(表格数据仅供参考)

以上表格为 CPU 访问各级缓存和内存所需耗时的对比,可见每一级的耗时差异非常明显。因此,就性能优化而言,应尽可能在更低级别的缓存中命中数据。另外,寄存器、L1 缓存和 L2 缓存是 CPU 核心独占的,每个 CPU 核心都拥有自己的寄存器、L1 缓存和 L2 缓存;而 L3 缓存则是同一 CPU 内多个 CPU 核心共享的;内存则是所有 CPU 所有核心共享的。对于 zStorage 来说,每个 CPU 核心上仅绑定运行一个线程,独占该 CPU 核心的资源(包括寄存器、L1 缓存、L2 缓存),不过 L3 缓存和内存则与其他 CPU 核心共享。

因此,为了使多线程能够尽量互不干扰地并发运行,在数据结构设计上需要进行分区,将数据归属于某个线程。例如:PG1~10 由线程1管理,PG11~PG20 由线程2管理,等等。这样,每个线程只会访问属于自己的那部分数据,不会与其他线程产生竞争。相反,若多个线程(或 CPU 核心)之间存在数据共享,则会导致 CPU 核心为保证数据一致性而频繁锁定总线、修改和加载内存等操作,从而造成性能不佳。

CPU 缓存未命中

CPU 先从 L1 缓存查找数据,如果没找到,则继续在 L2 缓存查找,如果仍然没找到,则继续在 L3 缓存查找;如果在所有 CPU 缓存中都未命中,最后才会从内存中读取数据。对于像 zStorage 这样的大型分布式存储系统来说,不太可能将程序运行时所需访问的数据全部放在 CPU 缓存中,因此必然会出现缓存未命中的情况。可行性较高的一些优化方法基本都是通过重排指令和数据以减少缓存未命中问题,例如编译器的优化级别、LTO 优化、PGO 优化等。这些优化方式不会改变数据总量的大小,原本需要访问 1GB 数据,即使经过这些优化,从数据量上也不会有明显的降低,1GB 数据仍然无法完全缓存在 CPU 缓存中,这些优化无非是将时空局部性较强的数据放在一起,从而减少缓存未命中问题。

CPU 缓存污染

诸如数据压缩、校验这样大量读取内存数据后进行计算的情况,会将大量数据放入 CPU 缓存中,同时导致原本缓存中的大量有效数据被替换出去。实际上,这些数据是一次性计算的,没有必要经过 CPU 缓存;如果经过 CPU 缓存反而会导致有效缓存数据被替换掉,这种情况被称为 CPU 缓存污染。在 zStorage 中存在压缩、校验等计算场景,不过目前 zStorage 尚未对 CPU 缓存污染情况做处理。据研究,可能的优化方法有绕过 CPU 缓存直接操作内存、L3 缓存分区等等。

从 zStorage 的多线程模型来看,由于 L3 缓存是 CPU 核心间共享的,L3 缓存未命中以及污染问题就成为了线程间的竞争因素。不过,目前尚未针对线程间对 L3 缓存容量竞争做出相应的优化。

内存

多线程访问内存的性能瓶颈主要在于内存带宽。在部署 zStorage 的环境中,都建议充分利用内存通道,每个 NUMA 节点 上建议插入 4 根以上内存条,例如:整个服务器插入 8 根 16GB 的内存条,而不是 2 根 64GB 的内存条。内存的性能特点与硬盘有一定的相似之处,一个硬盘的带宽是有上限的,因此可以多插几块硬盘以提升整体吞吐量。

在多线程、多内存条的环境下,如何规划哪个线程访问哪个内存条呢?默认情况下,BIOS 会开启内存交织模式,一般是同一个 NUMA 节点上的内存条进行交织,类似于硬盘的 RAID0 模式。这样,任何一个线程的内存操作会被条带化到多根内存条上,从而充分发挥多内存通道的性能。

不过也有一些服务器平台对开启内存交织模式有要求,例如要求每个 NUMA 节点上插满 16 根内存条。那么在少于 16 根内存条的情况下,就可能因内存通道流量差异较大而导致性能问题。zStorage 针对这种情况的处理方式是,在 BIOS 中开启多 NUMA 节点模式(例如 8 个 NUMA 节点,主要是 AMD 平台),然后从每个 NUMA 节点上预先分配一些内存,尽量让各个线程访问属于自己 NUMA 节点的内存。

网卡

zStorage 会在不同的 CPU 核心上通过 RDMA 轮询方式收发数据。典型环境配置为每个存储/计算节点配备两张 100G 的 IB 卡,目前网络并非性能瓶颈。不过也有一些涉及网络相关的优化,例如:节点间通信时,尽量直接将数据包发送到目标节点的目标 CPU 核心上,防止目标节点收到数据后再做一次 CPU 核间的迁移。

硬盘(这里主要指全闪存储中的 SSD)

市面上部分开源或商业存储系统中,硬盘与 CPU 核心存在绑定关系,单个硬盘的 I/O 仅在某个(或少数几个)CPU 核心上处理。这种方式在硬盘数量较多时可以充分发挥多核 CPU 的性能;但在硬盘数量较少时,则可能无法充分利用 CPU 的处理能力。此外,对每个硬盘进行绑核处理也较为繁琐。而 zStorage 并未对硬盘做绑核处理,单个硬盘的 I/O 可由任何 CPU 核心下发。zStorage 的 I/O 首先映射到 PG,PG 在 CPU 核心间均匀分布,每个核心负责管理多个来自不同硬盘的 PG。基于这种映射关系,即使硬盘数量较少(如 1 ~ 4 块),I/O 也可由多个 CPU 核心并发下发,从而尽可能利用硬盘的处理能力。

然而,凡事都有两面。这种方式存在一个潜在问题:若系统中有 N 块硬盘,SPDK NVMe 驱动则需在每个 CPU 核心上对这 N 块硬盘进行轮询。假设共有 20 个 CPU 核心,则每次需执行 20N 次轮询。在硬盘数量较少时,该开销尚可接受;但随着硬盘数量增加,轮询开销会线性增长,成为潜在性能问题。反之,若采用硬盘与 CPU 核心绑定的做法,假设每个核心绑定 N/20 块硬盘,则每个核心仅需轮询自身绑定的硬盘,总轮询次数不会超过 N 次。

线程间数据同步

在线程间的同步手段中,除了内存和 CPU 缓存等硬件因素外,主动使用的内存屏障 、锁等同步方法也会相互制约。归纳起来,主要包括:共享变量、原子操作 、编译器屏障、内存屏障、自旋锁 (spinlock)和互斥锁(mutex)。

image.png
下文打算针对以上几种同步方式对性能影响的严重程度来聊一聊。

互斥锁

在 zStorage 的开发过程中,互斥锁是完全不被接受的。因为一旦某个线程陷入等待锁的状态,对应的 CPU 核心便处于“罢工”状态,导致大量待轮询的事件得不到及时处理。此做法与 zStorage 的轮询、无锁设计哲学完全相悖。因此,zStorage 禁止在开发过程中使用互斥锁,尤其是在 IO 路径上更受严格监控。在 zStorage 开发初期,即使用各种 trace 工具查找使用互斥锁的代码,并已全部整改。

自旋锁

自旋锁与互斥锁的区别在于线程在等待自旋锁时不会陷入睡眠,而是忙等待,不断检查锁的状态直到获取锁成功;而互斥锁在无法获取时会使线程进入睡眠,待被唤醒后再尝试获取锁。因此,自旋锁不会出现线程“睡眠 → 唤醒”这样漫长的等待。如上述表格所示:“自旋锁用于保护很小的临界区”,即获取锁后不应执行过长的代码流程,以免其他线程长时间忙等待,浪费大量 CPU 资源。

在 zStorage 代码中,基于轮询、无锁的设计原则,IO 路径上同样禁止使用自旋锁。不过,一些外部库(例如 IB 卡驱动的 libibverbs)无法避免地使用了自旋锁:在轮询完成队列(CQ)时,verbs 库内部会用自旋锁进行调度,这对性能也会造成不小损失。

据资料,可在创建 CQ(ibv_create_cq)时指定该队列仅由单线程使用,以避免 verbs 库中的自旋锁操作,但这一方案有待在 zStorage 环境中验证。

原子操作

原子操作用于解决多线程间的竞争条件、内存可见性和顺序性等问题。原子操作保证其他线程要么看到操作前的状态,要么看到操作后的状态,不会看到中间状态。例如:

intatomic_cmpxchg(atomic_t*ptr,intold,intnew);

该函数原子地将ptr 的值从 old 更新为 new ,前提是ptr 当前的值等于 old 。

这里不详细研究原子操作的用法和使用场景,重点还是聚焦于原子操作对性能的影响。有些原子操作本身隐含的内存屏障,会强制脏数据写入内存,以便其他线程及时可见。我们知道内存操作是昂贵的、耗时的,所以大量的原子操作势必增加内存操作,导致性能损失。

不过相比于互斥锁和自旋锁,原子操作的影响相对来说要小很多。实际上,在 SPDK 的代码中就有不少使用原子变量的地方,例如 rte_ring ,一个无锁的支持多线程的环形队列。在 zStorage 的代码中,对于原子变量是持谨慎态度的,原则上是尽量不用,除非遇到了确实无法避免、没有替代方法的情况。

比如在线程间通信时,spdk_thread_send_msg 便是通过 rte_ring 进行通信。由于原子变量的性能影响,zStorage 做了不少优化,尽量减少了线程间的消息投递。

内存屏障

内存屏障的主要作用是防止指令重排,以及保证多线程情况下的内存可见性。比如前面提到的 spdk_thread_send_msg ,SPDK 中线程间通信的主要方式就是发送消息,即:一个线程将消息放入一个多线程共享的 rte_ring 中,另外一个或者多个线程从 rte_ring 中取出消息,进行处理。在这个消息入队的时候,SPDK 代码便有一个内存屏障的操作,这样做的目的是将当前线程的 CPU 缓存写入内存,以便其他线程可以及时看见最新的数据。

非常明显,这里主要的性能影响是 CPU 需要等待前面的内存操作完成。不过这种线程间需要通信交互的情况,内存屏障操作却是必要的。否则其他线程不能及时地看见最新的数据,可能会导致一些意想不到的问题。所以在 zStorage 中,这种需要线程间交互的情况,也是尽量地避免,尽量地采用其他可在单线程处理的解决方案。

共享变量

最后谈谈共享变量。比较理想的情况是每个线程只访问属于自己的内存,而不去访问其他线程可能读写的内存。这么做内存访问的效率最高,因为 CPU 不用考虑多个 CPU 核心之间的缓存一致性问题。反之,如果 CPU 需要访问多个 CPU 核心之间共享的变量,比如,g_flag 这样的全局变量。如果这个全局变量大部分时间是只读的,那么对性能是没有影响的;反之,若这个全局变量被反复修改和读取,多个 CPU 核心便会由于同步数据对性能产生影响。

还有一种情况,虽然为每个线程申请了不同的内存,但实际上也会产生隐含的内存共享问题,这种情况叫做 False Sharing 。举例来说,假设有 20 个线程,每个线程只读写自己对应的一个数组元素,但由于 CPU 加载缓存时按 cache line(64 字节)为单位,即使只修改了线程对应的元素,也会影响到整个 cache line,导致相关联的 CPU 核心重新加载 cache line。可以通过将数组元素对齐到 cache line 大小,来解决 False Sharing 问题。zStorage 针对 False Sharing 也曾做过专项整改,解决了多处类似的问题。

总结

多线程性能优化的关键在于降低线程间的资源竞争与协作损耗,让各线程尽可能独立高效地利用硬件资源。zStorage 在实践中针对硬件层(CPU 缓存一致性、内存带宽、IO 设备调度)与软件层(同步机制、数据共享)的瓶颈,采取了一系列针对性策略:通过 CPU 核心绑定的轮询模型消除上下文切换,利用数据分区减少缓存竞争,基于 NUMA 节点规划内存访问以提升带宽利用率,以无锁设计和原子操作最小化同步开销,并通过解决 False Sharing 等隐含共享问题避免缓存失效。这些优化的核心逻辑是“数据本地化”与“无锁化”——让线程尽量操作专属资源、减少跨核协作,从而将显性的锁竞争与隐性的缓存同步开销降至最低。未来,随着硬件多核架构与存储需求的演进,zStorage 将持续探索如何在更复杂的资源调度场景中,实现多线程性能与线程数量的线性扩展,真正释放硬件潜能。

END

作者:“云和恩墨 zStorage 分布式存储团队” 张洋
原文:企业存储技术

推荐阅读

欢迎关注企业存储技术极术专栏,欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
5625
内容数
285
关注存储、服务器、图形工作站、AI硬件等方面技术。WeChat:490834312
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息