Atomic usage patterns in the kernel

Linux 内核使用大量“原子”操作——在系统中任何地方观察到的不可分割的操作——来在多线程环境中提供安全且高效的行为。最近的一篇文章解释了为什么添加了一组新的原子基元,但正如读者“magnus”观察到的那样,该文章没有提供任何背景来说明这些或任何其他原子操作是如何实际使用的。新操作至今几乎没有使用,所以我们只能猜测它们可能会多么有用。更成熟的操作被广泛使用,虽然编目每个不同的用例都会令人讨厌地乏味,但找到一些常见的模式有助于理解。为此,我搜寻了 Linux 内核代码,以找出如何使用不同的原子操作,并寻找可以阐明新操作的可能用处的示例。

简单的标记
通常,只有当多个线程可能以不同的方式更新同一数据时,才需要原子操作。然而,我早期了解原子性重要性之一涉及未共享的值,或者仅在自旋锁下更新。NFS 服务器的 RPC(远程过程调用)层使用许多状态标记,其中一些当时表示为无符号字符,而其他一些是单比特字段,如:

unsigned int sk_temp : 1, /* 临时套接字 */
C 编译器生成用于更新此类字段的代码将读取,然后写入整个机器字。使用锁来确保多个读-改-写操作不会并行发生是很自然的,但是,如果同一机器字中的不同字段受不同锁保护,这些全字更新很容易相互干扰。此代码最初是安全的,因为只使用一个额外的自旋锁来保护这些标记彼此免受干扰。对该代码进行了重大重写,去掉了额外的自旋锁,而是使用 set_bit() 和 clear_bit() 来在一个单词内以原子方式更新各个比特。

因此,使用原子操作的第一个目的是隔离:确保对一个值进行更新不会干扰对相邻的但无关的值进行的更新。通常可以假设对基本机器字大小的已对齐值的写入(通常为 32 位)不会干扰对相邻值的写入。与小邻域同时更新的较小大小的值,需要某种保护,无论是锁定还是原子操作。

计数器
在许多可能预期对单个值进行并发更新的情况下,计数器可能是最简单的。Linux 使用原子计数器来计算各种内容,包括 IO 错误、丢弃的数据包、使用的 CPU 周期和其他各种简单统计信息。原子操作并不是收集统计信息的最高效方式,所以许多统计信息被单独收集在每个 CPU 的计数器中,这些计数器只在需要时才求和,例如在块层中所做的那样。当然,原子操作更容易,并且当被计数的事件不太频繁时,它们是一个好的选择。

计算当前正在使用的(或当前可用的)某些资源的数量是原子操作的常见用法,这些用例需要仔细注意达到相关限制时会发生什么。ext4 的日记记录层 jbd2 使用原子计数器跟踪已提交到下一个事务(outstanding_credits)的块,并且当提交超过限制时,它只需减去最后一个数字并等待空间可用。这意味着计数器暂时超过了最大值,这大概不是问题。

XFS 文件系统也使用原子计数器来跟踪日志中的空间提交,尽管方式很不同。在这种情况下,计数器实际上是日志中的某个位置,当它达到末尾时,它必须以原子方式环绕到开头。“检查溢出并调整”的方法在 jbd2 中是不可行的,所以一个原子“比较-交换”循环用于确保只有有效值可见。

因此,“可靠计数器”是原子操作的第二个常见用途。它可以收集统计信息或监视资源使用情况,有时还会施加硬限制。一种常见的计数器形式尚未被提及,即引用计数器。虽然这些是“可靠计数器”,但它们在资源所有权中扮演着重要角色,所以稍后在我们的故事中会更合适。

独占所有权
在 Linux 内核中,对某物的独占所有权是一种常见的模式,其中“某物”可能是一个数据结构,也可能是一个数据结构的特定部分或对它的访问,也可能是一个更抽象的资源。通常,自旋锁或互斥体将用于获得独占所有权,但在如果资源不可立即使用时没有等待意愿的情况下,原子操作通常会报告是否获得了所有权。

可能获得独占所有权的最简单方法是使用 test_and_set_bit_lock()。

if (!test_and_set_bit_lock(BIT_NUM, &bitmap)) {
 /* 在此独占所有权 */
 clear_bit_unlock(BIT_NUM, &bitmap);
 } else
 /* 尝试其他方法 */
如果比特是清除的,并且多个线程同时运行此代码,则只有一个线程会看到该比特未被设置,并且已成功设置它。所有其他线程都会看到该比特已经被设置,并且会知道它们没有设置它,因此没有获得所有权。

clear_bit_unlock() 中的 _lock 后缀和 _unlock 后缀有时很重要,而且可能没有得到应有的重视。test_and_set_bit_lock() 和 clear_bit_unlock() 是未加修饰的 test_and_set_bit() 和 clear_bit() 函数的变体;当声明资源所有权时应该使用它们,因为它们带来了社会和技术上的好处。在社会方面,它们为代码的意图提供了有用的文档。并非所有 test_and_set_bit() 调用都声称拥有所有权;有些只需要位操作的隔离属性。同样,并非所有 clear_bit() 调用都会释放所有权。向读者阐明意图可能非常有价值。

技术价值与以下事实有关:只要它们不改变单线程程序的行为,C 编译器和 CPU 硬件就允许对内存访问进行一定程度的重新排序。在没有任何“障碍”来限制这种灵活性的情况下,文本上介于“设置位”和“清除位”之间的内存读取可以在设置位之前执行,对内存的写入可能会延迟到在清除位之后。这种重新排序可能允许一个线程看到另一个线程仍然在处理的数据,这显然是不希望的。

如果没有锁定后缀,test_and_set_bit() 实际上提供了完整双向屏障,这样任何读或写都无法从一侧移动到另一侧。这是一个比需要的更强的保证,因此可以将 test_and_set_bit_lock() 用于仅仅为读取提供单向屏障,以防止在设置位之前执行它们。这被称为“获得”语义,因为它用于获得对某物的独占所有权。相反,clear_bit() 根本不提供屏障,所以为了正确性需要 clear_bit_unlock()。它提供了“释放”语义——对写请求的单向屏障,确保在清除位之前发生的任何写操作都将在清除位本身可见之前可见。

未修饰操作展现出来的不同屏障行为可以通过以下规则来解释:通常,返回值的原子操作(例如 test_and_set_bit())会施加完整的内存屏障,而通常不会返回值的原子操作(例如 clear_bit())不会施加任何屏障。

对内存屏障小心的需求是使用原子操作而不是更安全(但更慢)的自旋锁的代价之一。不过,以前的情况更糟。在 2007 年之前,没有 clear_bit_unlock(),所以必须小心放置诸如 smp_mb__after_clear_bit() 之类的明确屏障来避免竞争。值得庆幸的是,这个名称很可爱的函数早就被弃用了,现在屏障通常与它们保护的操作集成在一起。许多原子操作都可以使用各种后缀,这些后缀表示不同的排序语义,包括 _acquire、_release、_relaxed(根本不提供排序保证)和 _mb(提供完整的内存屏障)。作为一般规则,这些接口应该避免使用,除非你真的知道自己在做什么。即使是知道自己在做什么的人也会就这些问题进行长时间的交谈。

在 cmd_alloc() 函数中使用位操作进行独占访问的一个令人愉快的示例是 cciss 磁盘阵列驱动程序。该驱动程序维护一个“命令”池和一个位图,其中显示了正在使用哪些命令。它使用 find_first_zero_bit() 来查找可用的命令,然后使用 test_and_set_bit() 来声明它。如果失败,它只是返回从池中选择另一个命令。通过使用 test_and_set_bit_lock() 和 clear_bit_unlock(),可以使此代码更容易读取,但没有理由认为当前代码不安全。

我选择那个示例是为了将它与 pasemi_alloc_rx_chan() 进行对比,后者从一个池中执行类似的分配,但有一些不同:位图标识可用资源,find_first_bit() 用于查找一个,test_and_clear_bit() 用于声明它。没有 test_and_clear_bit_lock() 或 set_bit_unlock(),所以这个代码无法用位锁定来自我记录位的使用,我们必须希望在释放锁的 set_bit() 周围没有竞态的空间。

用于独占所有权的计数器和指针
在探索内核时,我发现的一个惊喜是,许多驱动程序只使用原子 t 计数器来获得独占访问。这些驱动程序(例如 dasd,一种用于 IBM s390 系统的存储驱动程序)使用 atomic_cmpxchg() 很像 test_and_set_bit(),例如:

if (atomic_cmpxchg (&device->tasklet_scheduled, 0, 1) != 0)
	return;

仅当获得了对 tasklet_scheduled 保护的任何独占访问权限后,才会继续执行后续代码。可能证明这种异常结构合理的一个原因是,可以与 test_and_set_bit() 及相关函数一起使用的最小位图是单个无符号长整数,在 64 位架构上是 8 个字节。相反,atomic_cmpxchg() 操作的值是一个原子 t,只有 4 个字节。这种微小的空间节省是否证明了该非标准代码是合理的问题,我们必须留给各个开发者考虑。

这种节省空间是新 atomic_fetch*() 操作的可能好处之一。atomic_fetch_or() 可用于测试和设置原子 t 中的任何任意位,这是当前内核中三个调用站点中的两个的用例。当请求独占所有权时,最好使用 atomic_fetch_or_acquire() 来记录该意图。这当前生成相同的代码,但可能会改变。

当计数器用于标识状态机中的状态时,可以看到需要计数器的独占所有权的变体。从一个状态转换到另一个状态可能需要一个线程执行某些特定操作。在许多网络设备驱动程序中可以看到这种模式,尽管我遇到的第一个是在 Firewire 设备的核心代码中。Firewire 设备可以从“初始化”转换到“正在运行”到“已消失”到“已关闭”。其中大多数转换使用 atomic_cmpxchg() 来避免竞争并检测到哪个线程首先进行特定转换。如果测试:

if (atomic_cmpxchg(&device->state,
FW_DEVICE_RUNNING,
FW_DEVICE_INITIALIZING) == FW_DEVICE_RUNNING) {
成功,那么设备首次开始运行时需要的某些额外的工作就可以执行。

使用计数器进行形式上的独占所有权的一个特别常见的案例是各种唯一序列号提供程序。dm_next_uevent_seq() 只是在我失去兴趣之前找到的十几个示例之一。这些会以原子方式增加一个值,并返回该值以供本地使用。调用者肯定唯一可以获得该特定值的调用者,因此可以认为他们拥有该值的独占所有权。在 Davidlohr Bueso 发现使用新引入的 atomic_fetch_inc() 的十个地方中,有七个是用于唯一序列号,这些序列号希望第一个数字为零——atomic_inc_return() 自然从返回一个开始。通过将原子计数器初始化为 -1,可以实现类似的简化。

有时候会使用原子指针更新来获得独占所有权,不过对所有权概念有一些细微不同的理解。IPv6 支持多种子协议,例如 TCP、UDP、ICMP 等等。这些各个协议的处理程序可以使用 inet6_add_protocol() 注册自身,该函数使用 cmpxchg() 原子函数将提供的处理程序安装到协议指针表中。如果已经注册了一个协议,则会失败。如果没有,调用者将获得该特定协议号的所有权,并且可以继续作为注册的处理程序。

在内核中的多个地方都会发生类似的 cmpxchg(),以原子方式将 NULL 替换为指针,例如在 tty_audit 代码和 Btrfs raid56 代码中。这两个都安装了由多个线程访问的新分配和初始化的数据结构。在不太可能的情况下,如果两个线程发现它们需要同时创建它,它们都可能准备结构,但只有一条会成功安装它。另一条必须将其结构作为浪费的努力丢弃。在这里,独占访问被授予“初始化权”,这看起来可能是一种略显扭曲的看待事物的方式,但确实允许形成一个统一的模式。

共享所有权——留待以后讨论
独占所有权的明显续集是由引用计数器提供的共享所有权。这个主题之前已经讨论过了,所以没有什么意义,只能做一些简单的回顾。然而,仔细检查之前发现的模式之一,开辟了一整套新的模式,并为新 atomic_fetch_*() 操作的另一种可能用途提供了一个提示。这些主题将在本文的配套文章中进行介绍。

原文地址:

 Atomic usage patterns in the kernel [LWN.net]

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值