LWN:使用可重启序列来改进C库的大规模应用场景下的性能!

文章讨论了如何通过在glibc中集成可重启序列技术来提高C库的性能,特别是在处理并发和数据竞争问题上。作者MathieuDesnoyers介绍了这一功能的工作原理,并指出它在用户空间代码优化中的潜在价值,尽管实际应用需经验证和基准测试。
摘要由CSDN通过智能技术生成

关注了就能看到更多这么棒的文章哦~

Improving C-library scalability with restartable sequences

By Jonathan Corbet
October 17, 2023
Cauldron
ChatGPT translation
https://lwn.net/Articles/946870/

自 2018 年以来,Linux 内核就支持可重启序列(有时称为“RSEQ”),但它仍然是一个有点小众的功能,主要适用于一些不介意编写汇编代码的非常关注性能的开发人员。根据内核中这个可重启序列方案的开发者 Mathieu Desnoyers 的说法,如果增加合适的库,那么这个特性可以更广泛地适用于对性能敏感的代码。他来到2023 GNU Tools Cauldron,提出了在 GNU C 库(glibc)中使用可重启序列的理由。

他开始时提到,有许多用于改善用户空间代码的可伸缩性(scalability)的方法;其中大多数都是围绕着以某种方式分割工作内容而进行的。使用线程本地存储(thread-local storage)来减少共享数据的竞争冲突就是一个例子。应用程序还可以使用读-复制-更新(RCU)、危险指针(hazard pointers)或引用计数(在不存在频繁更改的情况下效果最好)等。还有一种方法是使用 per-CPU 的数据结构;他说它们在内核中被广泛使用,但也可以在用户空间中工作。内核可以依赖禁用抢占之类的技术来保证对 per-CPU 数据结构的独占访问,但用户空间没有这样的便利。这就是可重启序列会有价值的地方了。

8f49970573d41e39044d2ef2adfd5047.png

任何一个特定情况下的最佳方法各有不同,因为工作负载及其数据访问模式是不同的。这里的选择应该基于一些指标(metrics),包括基准测试(benchmarks)、性能分析(profiles)、跟踪(tracing)等,用这些指标应该能清楚地展示可伸缩性方面的瓶颈在哪里,以及某种技术如何来改善这个情况。他强调,他所提出的想法并没有这样坚实的基础;它们主要基于代码审查,需要在朝着这个方向开展任何具体工作之前证明其价值。

他说,可重启序列是在内核的 4.18 版本中添加的。该功能已在 glibc 内部使用,用来实现sched_getcpu(),因为它会使得每个线程都可以获取当前当前 CPU 编号。使用可重启序列的代码首先要共享一个结构给 kernel,该结构给出了临界区(critical section)的地址范围,以及一个 abort 地址。在正常执行中,临界区中的代码会正常运行,并在最后使用一个原子操作的 store 指令来提交。然而,如果在线程执行临界区时发生了抢占的话,就会失去对其正在使用的 per-CPU 数据结构的独占访问权,不能正常继续了;在这种情况下,它将被迫跳转到 abort 地址,根据其中的动作来重新启动操作。

6.3 内核向在线程和内核之间共享的数据添加了 NUMA ID 字段,允许对getcpu()的 glibc 实现进行优化。此版本还引入了使用虚拟CPU编号的 per-memory-map concurrency 并发的概念,允许更有效地使用 per-CPU 数据结构。还添加了一个新的可扩展 API,但相关支持尚未进入 glibc;他说,由于在旧 API 中几乎没有扩展空间,这个动作需要尽快完成。

librseq库正在开发中,以使开发人员更容易利用可重启序列。它实现了许多常见的数据结构,包括了 per-CPU 的计数器、链表和自旋锁,以及许多底层原子操作。并支持了多个架构。这个库仍处于开发早期阶段;目前还没有正式发布。

per-CPU 计数器的实现遵循通常的模式:在 per-CPU 位置维护一个单独的计数器,从而可以在不担心竞争的情况下递增。通过将 per-CPU 的值相加来获取计数器的总值,这个算法在计数器频繁更新但很少读取的情况下很好,但存在一个问题,即如何安全地访问“远程数据”(也就是当前 CPU 以外的其他 CPU 上的计数器),以获取精确的总和或对计数器数组进行更改。

答案是对加速 membarrier()系统调用进行扩展。MEMBARRIER_CMD_PRIVATE_EXPEDITED_RSEQ 选项实在 5.10 内核中添加的;它会执行内存屏障(memory barrier),但还会中止当前正在运行的 RSEQ 临界区;Desnoyers 将此操作称为“RSEQ fence (栅栏)”。对于像一组 per-CPU 计数器这样的数据结构来说,可以用一个管理线程来替换整个结构,然后发出 RSEQ 栅栏。之后,没有线程会使用旧结构,并且管理线程将独占访问它。

还有一个 per-CPU spinlock,主要用来在仅由本地 CPU 访问时能非常快速高效的情况;它可以在慢速处理路径(slower path)中来进行远程获取。per-CPU 的自旋锁包含了一个描述锁应该如何获取的 bit;大多数情况下,该位是清 0 的,表示没有线程尝试远程访问。这样以来,线程可以通过在 RSEQ 临界区内读取其值来检查其是否空闲,并通过将其值设置为 1 来获得锁。当然,所有这些动作都要在 RSEQ 临界区内。当远程线程需要获取锁时,它首先通过在锁中设置远程访问位,然后执行 RSEQ 栅栏。当该位被设置位 1 时,需要原子方式的比较和交换指令来获取锁;用这种方式的话,本地和远程线程可以采用传统(更慢)的方式来竞争这个锁。

内存分配是可重启序列的最初使用场景之一。这里有一种方法是使用 per-CPU 的空闲列表(free list),以虚拟 CPU 编号为索引。通过在 RSEQ 临界区内进行快速的压栈和出栈(push and pop)操作,可以执行列表的添加和删除。如果某些操作需要走慢速路径(slow path),可以使用 RSEQ 栅栏或 per-CPU 自旋锁来获取访问权。

他说,glibc 中有一些锁可能通过 RSEQ 或 RCU 来进行优化;其中包括动态加载器锁、动态加载器堆栈缓存锁、默认 pthread 属性锁、gettext 锁和时区锁。最后一个经常被获取,因为像 localtime() 这样的函数需要获取这个锁,但时区很少更改(甚至完全不更改)。他认为在这种情况下 RSEQ-based 的更新机制可能会有所帮助。

POSIX 条件变量(condition variables)是一个更难的问题;它们在内部使用互斥锁来串行化操作,在等待和唤醒频繁发生时可能成为竞争点。这是 POSIX 线程设计的固有问题,难以改变。作为替代方案,他创建了一个名为 urcu_wait() 的 API,在liburcu中实现。它实现了若干个可以快速访问的等待线程(a stack of waiting threads),并在需要时退回到使用 futex 的方式。

此时,时间快要用完了。Desnoyers 迅速介绍了他正在与 André Almeida 一起开发的自适应自旋锁实现。通过对 RSEQ API 进行小小的扩展,就能让用户空间代码知道另一个线程当前是否正在运行,并决定是否应主动等待锁被释放。

总的来说,人们对这里提出的想法表现出了兴趣;glibc 已经对可重启序列有一些支持,因此在内部更广泛地使用该功能应该没有真正的障碍。正如他们所说,接下来只需要写出代码并验证它是否真正改善了情况。

全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~

format,png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值