关注了就能看到更多这么棒的文章哦~
Lockless patterns: relaxed access and partial memory barriers
February 26, 2021
This article was contributed by Paolo Bonzini
DeepL assisted translation
https://lwn.net/Articles/846700/
本系列中的第一篇文章LWN:介绍lockless算法!对 lockless 算法进行了介绍,以及介绍了"happens before" 关系从而帮助理解 lockless 算法。下一步是看一下 "data race" 的概念,以及为防止 data race 需要使用的 primitives(原语)。我们会继续来了解 relax access、memory barrier,以及如何用它们来实现内核的 seqcount 机制。
对于一些 Linux 内核程序员来说,memory barrier 已经很熟悉了。第一份对内核中数据 concurrent access (并发访问)规范进行初步介绍的文档,其实名字就叫做 memory-barriers.txt。该文档介绍了许多种 memory barrier,以及 Linux 对于 data dependency(数据依赖)和 control dependency(控制依赖)有些什么期望。它还介绍了 "memory-barrier pairing",这可以被看作是 release-acquire 这种配对概念的类似产物,因为它也是用来帮助在跨线程环境中确保 happens before 关系的。
本文将不会像 memory-barriers.txt 中那么详尽地介绍。相反,我们将看看 barrier 比起 acquire and release (获取和释放)模型有些什么差异,以及它们如何简化并确保 seqcount 原语的正确实现。不过,一篇文章哪怕连最常见的 memory barrier 都不可能涵盖完整,所以完整的 memory-barrier 讲解将不得不等待下一期的内容。
Data races, relaxed accesses, and memory barriers
data race 的概念最早是在 C++11 中引入的,此后,它被应用到其他各种语言中,尤其是 C11 和 Rust。这些语言标准在如何处理对数据结构的 lockless access 方面规定得相当严格,它们设计了专门的 atomic load 和 atomic store 原语来实现这个目的。
当两个访问是并发发生的时候(即无法保证它们的 happen before 关系)、其中至少有一个是 store 操作、并且至少有一个没有使用 atomic load 或者 atomic store 原语时,就会发生 data race。每当发生 data race 时,这两个操作的最终结果根据 C11/C++11 定义是不可确定的,这意味着任何结果都有可能。避免了 data race 并不代表说你的算法也是没有 "race condition" 的:data race 是对语言标准(language standard)的违反,而 race condition 则是源于不正确使用锁、不正确的获取/释放语义、或两种错误都存在。
然而,data race 和由此产生的未定义行为(undefined behavior)是很容易避免的。在我们想对一个共享数据进行 store 操作时(这种情形很常见),有两种方法可以做到这一点。第一种是确保这两个操作有 happens before 的顺序关系,也就是使用任意一种可用的获取和释放操作来确保;第二种方法就是使用 atomic load 和 atomic store 操作。
C11、C++11 和 Rust 都提供了各种 memory ordering 机制供程序员在 load 和 store 时使用。我们感兴趣的三种是:acquire(与 load 一起使用)、release(配合 store)和 relaxed(两种操作都适用)。Acquire 和 release 现在应该大家都理解了,Linux 在 smp_load_acquire()和 smp_store_release() 操作中都使用了同样的概念。而 relax 操作则不会对任何跨线程操作保证顺序,也就是不会确保 happen before 的关系。相反,relax 操作只是为了 data race 以及相关的未定义行为,基本上没有用处了。
在实际实现中,Linux 希望编译器和 CPU 在实现的时候都能比语言标准中的定义更加具有可确定性一些。尤其是内核希望普通 load 和 store 都不会因为同时有个 concurrent store 而导致出现 undefined behavior。然而,这种情况下 load 或 store 的最终值仍然是无法确定的,很可能是完全错误的数据。例如可能包括旧的值的一部分和新值的一部分,混合在一起。这意味着,使用从 data race 中 load 出来的指针值的话会出现问题的。
此外,普通的 load 和 store 会受到编译器优化的影响,这可能也会产生意外结果。因此,relaxed-ordering memory operation(放宽内存操作的 ordering 要求)、但同时要保证 atomic 原子操作,这种想法对于 Linux 来说就很有用了。这就是 READ_ONCE()和 WRITE_ONCE() 宏所提供的功能。本系列中的其他文章将会总是显式(explicit)使用 READ_ONCE()和 WRITE_ONCE(),这是如今的 Linux 开发者认为比较好的做法。
这些宏已经出现在上一篇文章的一个例子中了:
它们在使用方式上类似于 smp_load_acquire()和 smp_store_release(),但是它们的第一个参数是赋值的目标(也就是所谓的左值,lvalue),而不是指针。除非有其他机制确保 data race 的结果会被扔掉,否则强烈建议使用 READ_ONCE()和 WRITE_ONCE()来在没有 lock 保护的情况下 load 和 store 公用数据(shared data)。通常情况下,relaxed atomic 操作会与其他一些具有释放和获取语义的原语(或同步机制)一起使用,靠它们来确保 relaxed write 操作和对相同内存位置的 read 操作保证顺序。
举例来说,假设你有多个 work item,它们会用 1 来填充数组中的某些单元,spawn 并完成了这些 work item 之后,必须要先调用 flush_work() ,然后才能对这个数组进行 read 操作。与 pthread_join()类似,flush_work() 也具有获取(acquire)语义,与 work item 的结束是保证同步的(synchronized);flush_work()保证在 work item 完成后才会读取数组内容,并且可以用普通的 load 方式来读取数组了。但是,如果多个、并发的 work item 可以对数组中同一个元素进行 store 操作,就必须使用 WRITE_ONCE(a[x],1),而不是仅仅使用普通的 a[x]=1。
当释放和获取语义是使用 memory barrier 来实现时,就会出现更复杂的情况。为了解释这种情况,我们将讲解 seqcounts 这个实际例子。
Seqcounts
Seqcounts 这种原语是专门用来供消费者(consumer)检测到数据结构在消费者的访问过程中间发生过变化。虽然它们只可以用在特殊情况下(要求被保护的数据量小、在 read 时的 critical p 不会带来副作用、写入速度快并且很少需要写入),但它们有很多有趣的特性,特别是 reader 不会导致 writer 饿死,writer 可以对存放这个 seqcount 的 cacheline 保持所有权。这两个特性使得 seqcounts 在那些需要很强的扩展性(scalability)的场景特别有用。
seqcounts 是一个单生产者、多消费者(single-producer, multiple-consumer)的原语。为了避免有多个并发写入者(concurrent writers),它们通常会与 spinlock 或 mutex 结合使用,形成了我们熟悉的 Linux seqlock_t type。不过,有时在内核之外,你会看到 seqcounts 被称为 seqlocks。
Seqcounts 实际上是一个生成数(generation count),其中如果且仅当 write 操作正在进行时这个生成数才是奇数。如果生成数在 reader 这一边的 critical p 之前的时候看到是奇数,或者在 reader critical p 内部发生了改变,那么说明 reader 就可能已经访问到了一个不一致的状态,必须重新尝试 read 操作。为了使 seqcount 能正确工作,reader 必须要能正确检测到 write 操作的开始和结束。这需要两个 load-acquire 和两个 store-release 操作。假如我们没有已经封装好的 API,通常人们是像下面这样编写 seqcount reader 和 writer 的:
这段代码类似于上一篇文章中所说的 "message passing(消息传递)" 模式。有两对 load-acquire 和 store-release 操作,一组是针对 sc 的,一组是针对 data.x 的,甚至很容易讲清楚为什么需要两对 load-acquire/store-release:
-
为了让 thread 2 退出循环,那么 sc 的第一次读取操作必须要能看到 thread 1 中第二个 store 操作写进 sc 的偶数值。只要确实看到了,那么 smp_store_release()和 smp_load_acquire()就能保证写入 data 中各个字段的 store 操作是会正常让别人看到的。
-
如果 thread 2 读取 data.x 的时候,读到了 store 到 data.x 的值,那么 smp_store_release()和 smp_load_acquire() 就确保 thread 2 至少能看到生成数的第一次改变。因此,thread 2 将会继续循环,或者如果它也看到了生出数第二次改变,就会跟上一条所说的一样拿到确保了一致性的新的 data。
然而,这段代码有一个 bug! 因为 writer 在最开头没有进行获取(acquire)操作,所以对 data.y 的写入操作可能会在将奇数值写到 sc 之前完成。(注:文章在 3 月 2 日的更新中指出了这个问题)。对 data 中所有字段使用 load-acquire/store-release 可以避开这个问题,但这样做可能有点过了。而事实上,可以有更好的做法。
上一篇文章中讲过,旧的 Linux 代码可能会使用 smp_wmb(),后面紧跟着 WRITE_ONCE(),而不是 smp_store_release()。同样,有时 READ_ONCE() 后面会跟着使用 smp_rmb(),而不是 smp_load_acquire()。这些 partial barrier 操作会建立起特定的 happens before 这种先后关系。具体来说(但这里的介绍不是很正式),smp_wmb() 会将后续所有的 relaxed store 都变成释放(release)操作,而 smp_rmb() 会将其之前所有的 relaxed load 操作都变成获取(acquire)操作。
我们尝试来将这种用法替换到对 data.x 的访问操作中:
先不谈 barrier 是如何起作用的,至少这里已经可以更加适合用一个易于使用的 API 来包装了。数据完全是通过 relaxed atomic load 和 store 来操作的(尽管在 Linux kernel memory model 中,non-atomic access 也是可以的),barrier 就被隐藏在 seqcount 的 read_seqcount_retry()和 write_seqcount_begin()中了。
上面加入的 barrier 将读和写操作分成了两组,这就保证了 seqcount access 是安全的。然而,有两点需要注意:
-
首先,barrier 并没有在 relaxed access 之间保证访问顺序。也就是说 thread 2 可能会先看到对 data.y 的改动,然后再看到对 data.x 的改动。这对 seqcount 来说不是问题,因为对 sc 进行检查就可以要求 thread 2 来重新尝试读取,避免它只看到部分 store 结果。
-
其次,barrier 比 load-acquire 和 store-release 操作要弱一些(weaker)。使用 smp_load_acquire() 进行的读取操作会确保比在其之后所有 load 和 store 都要早完成,同样,smp_store_release() 不仅会确保在它之前的 store 操作之后完成,并且也确保一定会在之前的 load 操作之后完成。相反,smp_rmb() 只保证了 load 操作之间的顺序,而 smp_wmb()只保证了 store 操作之间的顺序。然而,load-store 操作的顺序很少需要保证,这就是为什么 Linux 开发者长期以来只使用 smp_rmb()和 smp_wmb()。
在 seqcounts 这个例子中,load-store 的顺序并没有严格要求,因为 reader 不会在它自己的 critical p 之内对这个共享位置进行写入操作,因此在 writer 对生成数进行更新期间并不会有人在并发地修改这个共享位置。这个推理有点粗糙,但只要代码简单并且完全符合这个模式的话,这个结论其实是准确的。如果 reader 需要对这个共享位置进行 write 操作,那么只要对这些写操作再加上一个其他机制(不能是 seqcount 了)来保护就够了。
前一段的解释虽然不太严谨,但是也说明了了解那些常见的 lockless 编程模式(programming pattern)的重要性。简而言之,这些编程模式能够在不损失精确性的情况下,帮我们用更粗的粒度来思考代码。你可以不用单独去分析每一次内存访问,而是做出 "data.x 和 data.y 受 seqcount sc 保护 "这样的论述,或者对本文中第一个消息传递的例子,可以简单得出"a 是被通过消息传递方式发布给其他线程的"。在某种程度上来说,熟练掌握 lockless 编程模式就意味着能够得出类似这样的结论,并利用这些结论来理解代码。
我们对 memory barrier 的初步探讨到此结束。这个话题自然还有很多内容没有讲完,本系列的下一期将深入探讨完整的 memory barrier,包括它们是如何其效果的、以及它们在内核中是如何使用的。
参考此系列上一篇文章:LWN:介绍lockless算法!
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~