LWN: Lockless编程模式 - 最后的一些话题!

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

Lockless patterns: some final topics

March 29, 2021
This article was contributed by Paolo Bonzini
Lockless patterns
DeepL assisted translation
https://lwn.net/Articles/850202/

到目前为止,本系列已经介绍了 Linux 内核中常见的五种 lockless 模式,应该是在 Linux 上工作时最可能遇到的五种模式。在整个系列中,为了清楚起见,有些细节被遗漏了,有些被简化了。在这最后一篇文章中,我会梳理一下其中的一些细节,并尝试回答一个可能是最重要的问题:应该在什么情况下使用这里介绍的 lockless 模式?

Data, address, and control dependencies

迄今为止所有介绍过的代码示例中,在同一个线程中的指令的顺序要么是通过 acquire 和 release 语义来确保的,要么是通过 memory barrier 确保的。然而,有时候处理器和或者编译器完全不可以对指令进行 reorder(重新排序),因为某条指令是跟前面的另一条指令有依赖性的。这种情况下,哪怕是对那些 relaxed load 和 store 操作,也需要保证这两个指令之间的顺序。这里会出现三种依赖情况。

Data depenencies(数据依赖) :某个 store 操作是要将之前的一个 load 操作所读出的数据进行写入时,或者是需要使用这个 load 出来的数据时,就存在数据依赖性:

int x = READ_ONCE(a);
WRITE_ONCE(b, x + 1);

Data dependency 是这三种情况中最简单的一种,在 load 之前,不可能执行 store,否则处理器根本不知道应该写入的值是什么。因此,在这种情况下从 a 中进行的读操作其实就跟 load-acquire 一样,或者说至少对于接下来的这句 WRITE_ONCE() 操作是这样的。与 smp_load_acquire() 或 smp_rmb()不同的是,利用 data dependency 所得到的 order 顺序并不能保证接下来所有的 memory 操作都在这个 read 操作之后进行。在下面的例子中,对 c 进行的 read 可能会在对 a 的 read 之前就执行了,但是 b 的 write 操作不可能在读 a 之前进行:

int x = READ_ONCE(a);
WRITE_ONCE(b, x + 1);
int y = READ_ONCE(c);

然而,我们已经看到,大多数情况下,lockess 代码只关心特定的几个内存操作之间的排序,在某些情况下,load 操作完全可以放心地使用 READ_ONCE() 来进行,因为唯一会用到这个 load 出来的值的地方就是后面的一个 store 操作,并且这也是唯一一个需要确保顺序的操作。不过,在本系列文章中所介绍的各种模式中,都不是这种情况,因为都不是在同一个 thread 中先进行 load 再进行 store 操作的,所以无法用到这里说到的 data dependency。

Address dependency :如果一个读或写操作的 address 是来自于之前的 load 操作,或者是基于之前 load 结果算出来的,那么就存在地址依赖性。

int x = READ_ONCE(a);
int y = READ_ONCE(b[x]);

struct s *x = READ_ONCE(a);
WRITE_ONCE(x->f, 1);

address dependency 的道理也是很容易理解的。在第一个例子中,b[x]的 read 操作不可能在 x 的 read 之前进行;在第二个例子中,在读取完成之前 CPU 也是不能知道后续要写入的地址的。

同样,在下面来自第一部分的这段代码中,datum->x 的地址必须是在从 message 中通过 load 得到 datum 之后才能知道的:

thread 1                            thread 2
--------------------------------    ------------------------------------
a.x = 1;
smp_store_release(&message, &a);    datum = smp_load_acquire(&message);
                                    printk("%x\n", datum->x);

在不知道目标地址的时候,CPU 怎么可能从内存中 load 或者 store 数据呢?因此,人们会希望将 thread 2 的第一行写成下面这个样子,这样就不需要 load-acquire 指令了:

datum = READ_ONCE(message)

这个想法很合理。查看汇编代码的话,其实对于几乎所有 Linux 所能支持的处理器来说也都是成立的,不过,Alpha 架构除外,因为它的 cache 架构很特殊。从 Linux 4.15 开始,Linux 开发者决定在 Alpha 上允许 READ_ONCE() 的开销更高。因此现在 address dependency 的 load 总是会在它们所依赖的 load 之后执行。请注意,address-dependent store 从来不会出问题。

最后,*control dependency* :也就是 read 出来的值可能会导致后续的 read 或 write 操作完全不会执行的话,这就是控制依赖:

int y = 0;
if (READ_ONCE(a))
    y = READ_ONCE(b);        // *no* ordering here

if (READ_ONCE(a))
    WRITE_ONCE(b, 1);        // write is ordered here

当一个 load 与另一个 load 操作之间存在 control dependency 关系时(如上面第一个例子),就绝不会出现顺序错误的情况。CPU 可能会在执行判断逻辑(test)之前先预测性地执行后面的 load 操作,然后如果发现 "then" 这个分支的代码不应该被执行,那么直接忽略这个 load 出来的数据就好。从 load 到 store 的 control dependency 关系则是比较棘手的。这里 CPU 不是问题,因为在知道是不是真的需要进行 store 操作之前,不会把数据写入 cache,但编译器这边可能出现问题。想一想下面这样的代码:

x = READ_ONCE(a);
smp_store_release(&b, 1);
if (x)
    do_something();
else
    do_something_else();

聪明的程序员可能想这样写:

x = READ_ONCE(a);
if (x) {
    WRITE_ONCE(b, 1);
do_something();
} else {
    WRITE_ONCE(b, 1);
do_something_else();
}

这里的 control dependency 确实能让 CPU 保证 read a 和 write b 之间的顺序。然而,编译器可能会在优化时决定提前到判断条件之前就进行 write 操作,然后才会对 x 的值进行判断,这样导致生成的代码其实看起来像这个逻辑:

x = READ_ONCE(a);
WRITE_ONCE(b, 1);
if (x)
    do_something();
else
    do_something_else();

这样一来 control dependency 就不复存在了,CPU 和编译器都可能会在 read a 之前就执行了 store 操作。通常情况下,要想解决这些 control dependency 问题,只要避免这么使用并且将每个 store 操作都改为相应的 acquire 或 release 的 store 就可以了。

在上述所有这些情况中,address-dependent load 可能是你唯一会真正碰到的情况。最常见(并且很容易理解)的情况就是利用 rcu_dereference() 和 srcu_dereference() 来取得一个数据结构 struct,然后读取其中的内容。在 Alpha 架构上,这些 RCU 和 SRCU 的基本函数哪怕是在 Linux 4.15 之前版本上都已经带有了必须的 memory barrier 操作。但是,在有些很少见的情况下如果没有使用 RCU,并且两个 memory access 都是用 READ_ONCE(),那么您需要多加小心。

Optimized memory barriers

第 5 部分文章中介绍了 atomic read-modify-write 操作,比如 atomic_inc()。Linux 对于这些不会返回数值的操作都定义为 relax 语义。并且如果 compare-and-swap 操作失败了,那么也就不能确保带有 memory barrier,而成功的 compare-and-swap 操作从效果上来说就像开发者在这个操作的两边都加了一个 memory barrier 一样。

然而,有些处理器并没有一个 relaxed read-modify-write 操作执行。在这些处理器上,写这样的代码(这是第三篇文章中的 full memory barrier 模式的一个变体)就是个浪费了,这里 set_bit()(这个操作一定会先读取目标地址的数据之后才会改变指定的 bit)和 smp_mb() 已经提供了 back-to-back memory barrier:

set_bit(&flag1, FLAG_DONT_SLEEP);
smp_mb();
if (READ_ONCE(wake_me))
    wake(thread2);

为此,Linux 定义了一些优化版的 memory barrier 操作:smp_mb__before_atomic() 和 smp_mb__after_atomic() 。它们会根据当前体系架构中 atomic read-modify-write 操作的具体硬件实现细节,来决定是编译成 compiler-only barrier 还是 full memory barrier。举例来说,x86 架构上的所有 read-modify-write 操作都会在两边隐含了一个 barrier,因此在 x86 架构上,这些优化版的 memory barrier 操作就会是一个 compiler-only barrier。而在 ARM 架构上有可能会定义有 relaxed read-modify-write 操作,因此,这些优化版的 memory barrier 将生成一个 dmb 指令,从而替代 smp_mb():

set_bit(&flag1, FLAG_DONT_SLEEP);
smp_mb__after_atomic();
if (READ_ONCE(wake_me))
    wake(thread2);

另一个优化版的 memory barrier 是 smp_store_mb(),是用来替代 WRITE_ONCE() 之后紧跟着 smp_mb() 的情况。例如:

thread 1                               thread 2
-------------------                    --------------------------
smp_store_mb(dont_sleep, 1);           smp_store_mb(wake_me, 1);
if (READ_ONCE(wake_me))                if (!READ_ONCE(dont_sleep))
    wake(thread2);                         sleep();

When to use lockless patterns?

尽管本系列的前几篇文章都尽量在举例的时候使用 Linux kernel 中的实际代码,但其实它们只是用来展示原理的。虽然这些文章中的内容应该足够让大家理解现有的代码了,但是一直很少讲解什么时候应该采用这些模式以及为什么采用这些模式。

在系列文章中,我一直试图强调的一件事是,lockless 技术并不是传统的 synchronization primitives(同步接口)的替代品。它们只是达到目的的一种手段,是用来降低同步的开销的(cacheline contention,也就是 cache 竞争,这也是某种意义上的同步操作,只是它发生在处理器内部,而不是你的代码中)。并发代码(concurrent code)中开销很昂贵的同步操作是无法彻底消除的,但可以尽量减少使用这些开销昂贵的指令,或者把将它们移出 hottest parth(也就是最常执行到的代码分支)。为此,下面这些设计要点值得注意:

  • 与现在已经在使用的 lock 和 shared data access(公共数据的访问)的交互。

  • 对公共数据(shared data)的写入频率:每当一个线程向公共位置写入数据时,所引发的 cache coherency traffic(为了保持 cache 一致性而占用总线的带宽)最终可能会导致今后的扩展瓶颈(scalability bottleneck)。

  • 进行同步的频率:运行多个线程运行的最好方式是让它们尽可能地独立执行,因为同步(synchronization)操作总会引入开销的。

比方说,你想在代码运行时收集一些统计数据,比如统计一下通过网络接口发送了多少个数据包,并且希望尽量减少这个统计工作的开销。在贸然决定用 lockless 的方式实现 counter 之前(比如用 atomic increment 指令),应该先调查一下当前发送数据包这个操作是如何确保是序列化进行的。比如说发送数据包的地方已经利用了一个 spinlock 或者 mutex,那么很可能我们做了一个花里胡哨的 lockless 的 counter 功能也无法获得什么性能上的好处:如果你确保 counter 是能放在发送数据包时所需要的一个 cacheline 中的话,那么在已经持有 lock 的情况下,对单个 memory 内容进行修改的操作几乎没有多大开销。

如果发送数据包时并没有一个必定会持有的 lock,那么 lockless 技术可能确实是会带来好处的,但这里不仅仅是一个 atomic read-modify-write 操作就能搞定的。比如说,你可以考虑使用多个 counter,这样你就可以在不用 lock 的情况下(只要这些变量是 per-CPU 的就行)或在一个早已经在用的 lock(比如每个 network queue 的保护锁)保护下来对其进行递增操作。在读取统计信息的时候,可以对这些 counter 进行 sum 计算(这种操作应该很少进行):这种解决方案避免了对公共数据的并发写入,并且不需要在 hot path 上增加任何额外同步操作。

在上面的例子中,一个粗粒度的 lock(例如对 network queue 操作进行保护的 lock)并不一定意味着会在今后的扩展(scalability)时导致问题:在一个设计为保持各个线程大部分时间独立运行的系统中,对于粗粒度 lock 的竞争通常是很罕见的,甚至完全不存在。相反,如果用细粒度锁来保护过量的公共数据访问,则会大幅增加同步成本,从而导致性能下降。

细粒度锁的开销在 read/write lock 中表现得尤为明显,这种情况下,哪怕是 read 这一方,也需要进行 write 操作才能拿到这个 lock。在编写那些高可扩展性的代码(scalable code)时,基本可以将 read/write lock 看做是 "shared/exclusive" lock,这样可以简化思考。你可以使用粗粒度的 read/write lock 来确保只有在 hot path 中才需要针对并发执行的情况进行特殊设计:那些不太频繁执行的代码为了要进行 exclusive(独占)访问而获取了锁,这种情况下所有的 lockless fast path 也完全不用考虑受这些代码的影响。Linux 的 mmap_sem 就是一个例子,Linux 会在获取 mmap_sem 信号量的同时也进行许多 page-table 操作。不过,获取 mmap_sem 进行 read 操作的开销还是比较高的,这成为了一个众所周知的问题,也就是 mmap_sem 的 scalability 问题。

如果需要对特定的数据结构进行细粒度的加锁保护,那么在一个可扩展性好的系统中,通常会只有很少的对这些数据结构进行的写入操作。你可以尝试用 seqlocks 或 RCU 等机制来代替 read/write lock,对 reader 这一边的 critical p 进行保护。当 writer(写入者)不希望耗费 synchronize_rcu() 的时间时,SRCU(可以认为它是一个 subsystem RCU,而不是 sleepable RCU)也可以成为 RCU 的一个有效替代方案。

哪怕 thread 都是独立运行的,也可能在极少数情况下是必须进行交互的。如果某个系统中每秒都需要对一个 flag 进行几千次甚至几百万次的检查,如果每次检查中都用细粒度的锁进行保护,那么无论线程之间的这些争抢指令数有多么少、争抢的发生是多么罕见,带来的开销都会变得明显。在这些情况下,在 fast path 中利用 lockless 技术就非常有价值了。

例如,你可以给每个线程设计一个请求队列来容纳所有来自其他线程的 request,并通过 single-consumer linked list(单一消费者的链表)来进行管理。对这些 request 的处理也许可以采用之前 full memory barrier 文章中所提到的 cross-thread notification(线程间通讯)方式来触发。然而,这些技巧要想真有效果,那么就必须得到整个系统的支持。换句话说,在一个以避免可扩展性瓶颈为目的的系统中,往往所出现的一些细节问题都是比较通用的问题,使用我们文章中介绍的模式通常可以有效解决这些问题。

大家在努力利用 lockless 技术来提高系统的可扩展性(scalability)时,很需要把 lock-free 和 wait-free 算法区分开来。lock-free 算法保证了系统整体能往前执行下去,但并不能保证每个线程都能正常往下执行;lock-free 算法通常都是不公平的,如果每秒的操作次数超过了某个阈值,那么有可能其中一些线程最终可能会频繁失败,从而导致 livelock。wait-free 算法额外保证了每个线程的继续执行。通常复杂度就会大增(也并不一定就会更复杂)。例如消息传递和跨线程通知都是符合 wait-free 的。

从 Linux 的 llist 相关 API 来看,llist_add() 就是 lock-free 的。在消费者这一边来看的话,llist_del_first()是 lock-free 的,而 llist_del_all()是 wait-free 的。因此,如果能预测到会有许多生产者(producer)会调用 llist_add()时发生竞争的话,那么 llist 可能就不是一个合适选择了;而使用 llist_del_all()可能比 llist_del_first()更好,除非要求时间消耗一定要是个固定时长。对于某些架构来说,指令集不允许将 read-modify-write 用在 wait-free 代码里面。如果是这样的话,llist_del_all()就只是 lock-free 的(但仍然是比较好的选择,因为它可以让消费者对共享数据结构进行的访问更加少)。

无论什么情况,最好的检查代码性能方法都是对它进行基准测试(benchmark)。尽管直觉以及一些流行 pattern 可以在设计和实现阶段给你指导,但要准备好最后被测试结果证明做错了的觉悟。

最后,我将引用 Dave Chinner 的一段优秀点评来结束这个系列文章:

这就是并发编程的艺术——仅仅知道什么是 lockless 算法是不够的,你需要了解这些算法导致的数据访问模式,以及这些访问模式何时会对软件产生限制。当然,知道什么时候不使用 lockless 算法(因为有更好的方法来减少公共数据的访问)也是这个艺术的一部分。

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

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

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值