LWN: lockless编程模式 - full memory barrier!

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

Lockless patterns: full memory barriers

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

系列文章之一:LWN:介绍lockless算法!

系列文章之二:LWN: lockless编程模式——relaxed acess和partial memory barrier

本系列前两篇文章中提到过有四种对内存访问确保顺序的方式:第一篇中的 load-acquire 和 store-release 操作,第二篇中的 read 和 write memory barriers。本系列继续探讨 full memory barrier,为什么它们开销太大,以及它们在内核中是如何使用的。

到目前为止所介绍的 4 种不同原语(primitives)都可以对 load 和 store 的顺序进行限制:

  • load-acquire 操作会在其后的 load 和 store 操作之前完成。

  • store-release 操作被排在其前的 load 和 store 之后。

  • read memory barrier 能确保其前的 load 先完成,再进行之后的 load。

  • write memory barrier 确保其前 store 先完成,再进行后续的 store。

可以看出,上面这些操作居然都不能确保之前的 store 和之后的 load 的先后顺序:

其实对处理器来说,要保证 store 能在之后的 load 之前完成,这是一个更加复杂的过程,它应该有一个自己的原语。为了给大家讲清楚原因,我们无法继续使用到目前为止一直在用的高级语言概念,而需要直接深入理解处理器是如何运作的。

How processors really do it

第一篇文章已经提到,从底层来看,处理器之间是利用消息传递架构(message-passing architecture)进行通信的,如QPI或HyperTransport。然而,在汇编语言层面,程序员看到的只是 memory load 和 store 等操作。任何与这些 memory 操作相关的 acquire 和 release 语义都是一种假象,是由处理器的执行流水线提供出来的,也是基于它所运行的代码和此处理器的体系架构的约束的。

例如,在 x86 处理器上,所有的 memory load 都算作 "load-acquire",所有的 memory store 都算作 "store-release";这种行为是(x86 这个)体系架构所要求的。在针对这些处理器进行代码编译时,仍然需要标注 acquire 和 release 操作以及插入 memory barrier,但这些标注只是给编译器看的,用来避免进行无效的优化(invalid optimization)。即使是在这个工作模式下,体系架构也不能保证所有处理器都以相同的顺序看到这些 load 和 store 的结果。比如,假设 a 和 b 已经初始化为零:

CPU 1                    CPU 2
-------------------      --------------------
store 1 into a           store 1 into b
load b into x            load a into y

如果手动排一下,将这四个操作进行各种交叉排列,你会发现至少有一个 store 是在相应的 load 之前完成的。因此,人们会认为在上述流程运行完成后,x 和 y 中至少有一个会是 1。然而,即使在 x86 处理器上,也有可能 x 和 y 读出来都是 0。

为什么会这样呢?原因就在于有 store buffer 这么个东西。store buffer 位于 CPU 与其 L1 cache 之间。到内存的 store 操作通常只会改变 cache line 中的一部分,因此为了完成这些 store,即使只是写到 cache,也可能需要先从内存中读取一个完整的 cache line,这个操作比较耗时。在这个操作过程中,要 store 的数据会先暂存在 store buffer 中,从而允许 CPU 可以继续向后执行。

即使是这些具有 store buffer 的 CPU,也可以比较容易就确保 load-load、load-store、和 store-store 的顺序:

  • 在 out-of-order 的 CPU 上(这种 CPU 可以不按照代码中出现的顺序来执行指令,以提高性能),可以通过预测执行的方式来针对之前的 load 操作来对 memory 操作进行排序。在 cache line 访问和引起这次操作访问的指令走完流水线(instruction retire)之间的这段时间里,CPU 会记录、跟踪这个 cache line 的 eviction 操作。各个指令走完流水线(retire)的顺序是保证按照代码顺序的,所以会一直跟踪直到其之前的 load 全部完成。如果在指令 retire 之前,cache line 发生了 eviction,那么预测执行就会被取消,处理器会重新发起 memory 操作。

  • 确保 store 的顺序就更简单了,只要按照 FIFO 的顺序,将 store buffer 中的数据 flush 到 cache 里去即可。

然而,确保 store-load 顺序就不那么容易了。首先,某个 CPU 所发起的 store 操作可能正停留在它的 store buffer 中,因此当另一个 CPU 从 L1 cache 中 load 数据时,看不到这个 store 进去的值。其次,store buffer 还提供了 store forwarding 的能力,也就是说,memory load 的结果会直接从 store buffer 中获取,而不是从 cache 读取。如果 CPU 1 或 CPU 2 的 store buffer 中有 b 和 a 这一项,那么 load 操作就可能取到这个 store buffer 中的值,也就是 0。

解决这些问题的唯一方法是在 store 和 load 之间确保 store buffer 被 flush 干净了。这个操作听起来很耗时(会消耗几十个时钟周期),但这就是 full memory barrier 也就是 smp_mb()所做的事情了。下面代码和上面的逻辑类似,只是修复过写成 C 语言:

thread 1                 thread 2
-------------------      --------------------
WRITE_ONCE(a, 1);        WRITE_ONCE(b, 1);
smp_mb();                smp_mb();
x = READ_ONCE(b);        y = READ_ONCE(a);

假设 x 为 0。下图中这一条从 read 到 write 的波浪线就表示 WRITE_ONCE(b,1) 覆盖了 thread 1 所 load 的值,那么情况(在内核的 memory-model 文档中有详细描述)如下。

WRITE_ONCE(a, 1);
       |
  -----+----- smp_mb();
       |
       v
x = READ_ONCE(b);   ∿∿∿∿∿∿∿>  WRITE_ONCE(b, 1);
                                     |
                                -----+----- smp_mb();
                                     |
                                     v
                              y = READ_ONCE(a);

因为这些都是 relax 操作,所以不足以在 thread 1 和 thread 2 之间确保两者先后关系。而 barrier 本身也没有 acquire 或 release 的语义,所以两个线程之间根本没有确保先后关系。

然而,这里 barrier 确实确保了两个线程中的操作的先后排序。继续以 x=0 的情况为例具体来说,thread 2 中 full memory barrier 保证在 read_once(a) 这一句执行时,store buffer 已经被 flush 了。会不会在 READ_ONCE(b) 执行之前就已经完成了呢?如果是这样的话,READ_ONCE(b)肯定会看到 thread 2 之前的 WRITE_ONCE(b,1) (毕竟 store buffer 已经被 flush 了),而 x 会是 1。这是一个矛盾,因此 READ_ONCE(b) 一定是先执行的:

WRITE_ONCE(a, 1);              WRITE_ONCE(b, 1);
       |                              |
       |                         -----+----- smp_mb();
       |                              |
       v                              v
x = READ_ONCE(b); -----------> y = READ_ONCE(a);
                    (if x=0)

由于传递性原则,READ_ONCE(a)可以看到 WRITE_ONCE(a,1)的效果,也能看到 y=1。同样,如果 thread 2 从 a 读出来是 0,那么 thread 1 的 full memory barrier 保证了 READ_ONCE(a) 一定在 thread 1 的 READ_ONCE(b)之前执行完:

WRITE_ONCE(a, 1);              WRITE_ONCE(b, 1);
       |                              |
  -----+----- smp_mb();               |
       |                              |
       v                              v
x = READ_ONCE(b); <----------- y = READ_ONCE(a);
                    (if y=0)

这里如果 y=0 就一定意味着 x=1。不同的执行流程可能会走到上述的两种可能性之一,但无论如何,x和 y 不可能都是 0,因为那意味着 read_once(a)在 read_once(b) 之前执行,反之亦然,这都是不可能出现的。

Linux 内核的 memory model并未提及这些read-read之间的"happen-before"关系,因为没有说明哪个是释放操作,哪个是获取操作。但是,实际上它们对于不同线程之间的 memory 操作顺序的确保方面,具有同样的效果。因此,对那些底层使用了 full memory barrier 的高级别 API,我们也可以认为它们具有 acquire 或 release 的语义。这些 API 的使用者就也能利用 happens-before 的方式来思考他们的代码。接下来的例子将展示如何在实践中使用。

Sleep/wake-up synchronization

上一节的详细描述,已经说明 full memory barrier 实际上以一种复杂、不直观的方式来确保了先后顺序。幸运的是,上述 "two thread and two flags" 这种模式(即每个线程向一个 flag 写入、并从另一个 flag 读取)对你来说也许就是你关于全内存壁垒所需要知道的全部了。

这个模式在很多实际的重要场景下都有应用。假设 thread 2 希望 thread 1 在 2 提出请求的时候采取行动,而此时 thread 1 想做一些其他事,甚至可能是一些不确定需要执行多长时间的工作,例如将打算退出调度器的 run queue 并进入 sleep 状态。那么,代码会是这样的:

如果 thread 2 读取 dont_sleep 得到值为 0,thread 1 读取 wake_me 就会得到 1,并唤醒 thread 2。将 thread 1 视为具有 release 语义的话就更容易理解了(可以认为 wake()就是 mutex_unlock 的一部分)。如果 thread 1 读取 wake_me 得到 0,thread 2 读取 dont_sleep 就会得到 1,于是不会进入 sleep 状态。通常,这种情况下应该是使用 acquire 语义的。

这里实际上隐藏了一个假设条件,即 thread 1 的 wake-up 这个请求永远不会丢失,也就是说,哪怕在 thread 2 的 READ_ONCE()之后但在 sleep()之前调用 wake(),这个请求也要是仍然有效的。要避免这个 bug,可以让 wake()和 sleep()的调用都使用同一个锁来保护起来。这里我们再一次看到了 lockless pattern 是如何与传统的 synchronization 合作的,毕竟加锁的这条路平常都不会走到(也就是说这是一个 slow path)。

这种方法确实有效,例如在 Linux 的 prepare_to_wait() 和 wake_up_process() API 中就可以看到效果。这组接口是在 2.5.x 内核中引入的,当时 LWN 就进行了一些介绍。下面是展开了一些相关函数后,这个模式看起来是什么样子:

正如我们在研读 seqcount 时看到的,memory barrier 被隐藏在更高级别的 API 的实现里。实际上人们定义一个带有 acquire 或者 release 语义的 API 的时候,其实就是在其内部加上一些 memory barrier 或者 load-acquire/store-release 操作。在这个案例中,wake_up_process() 就具有 release 语义,而 set_current_state()及调用它的 prepare_to_wait() 就具有 acquire 语义。

为了减少不必要的唤醒,通常会对 sleep condition 进行两次检查,就像这样:

thread 1                               thread 2
-------------------                    --------------------------
WRITE_ONCE(dont_sleep, 1);             if (!READ_ONCE(dont_sleep)) {
smp_mb();                                WRITE_ONCE(wake_me, 1);
if (READ_ONCE(wake_me))                  smp_mb();
    wake(thread2);                       if (!READ_ONCE(dont_sleep))
                                           sleep();
                                       }

在内核里,我们可以在 tcp_data_snd_check() 和调用 tcp_check_space()的地方(thread 1)和 tcp_poll()(thread 2)之间的交互过程中看到两次检查。在这种情况下,代码没能够利用更高级别的抽象,所以可以仔细研究一下。thread 2 希望在 socket 的 send buffer 没有空间的情况下 sleep,因此 tcp_poll()在检查 __sk_stream_is_writeable()之前设置了带有 "wake me" 含义的 SOCK_NOSPACE flag。tcp_poll()中lockless synchronization 的核心代码是:

set_bit(SOCK_NOSPACE, &sk->sk_socket->flags);
smp_mb__after_atomic();
if (__sk_stream_is_writeable(sk, 1))
  mask |= EPOLLOUT | EPOLLWRNORM;

如果 mask 是 0,调用这部分代码的函数就会最终进入 sleep。smp_mb__after_atomic()是 smp_mb()的一个专用版本,但是语义相同。这些用于优化过的 barrier 会在未来的文章中得到进一步解释。

而 thread 1 在处理完 send buffer 中的数据后,必须唤醒 thread 2,tcp_data_snd_check() 首先发送 packet 来从 buffer 中腾出空间("不要睡觉了,现在有空间了!"),然后检查 SOCK_NOSPACE,最后(通过 sk->sk_write_space()函数指针)调用到 sk_stream_write_space(),thread 2 就被唤醒。调用栈不算复杂,所以建议读者自己可以去研究一下代码。不过,我想强调一下 tcp_check_space()中的这段注释:

/* pairs with tcp_poll() */
smp_mb();
if (test_bit(SOCK_NOSPACE, &sk->sk_socket->flags))
  tcp_new_space(sk);

这里利用了 barrier 的 "pair" 含义,来告诉我们这个函数具有 acquire 或 release 的语义。read memory barrier 或者 write memory barrier 相当于直接告诉我们这个函数具有获取或释放的语义。对于一个 full memory barrier 来说,我们需要仔细阅读 barrier 周围的代码。具体这个例子中,我们知道该函数在 "wake-up" 这一侧,因此它就具有 release 语义;而 tcp_poll()则具有 acquire 语义。

几乎内核中所有可以找到 smp_mb()的地方,都是类似的用法。例如:

  • workqueue 使用这个方式来决定是否有更多的工作需要 worker 去做。这种情况下,thread 1 的角色就被换成 insert_work(),而 thread 2 则变成 wq_worker_sleeping()。

  • 在 futex()系统调用中,thread 1 的 write 是在 user space 发生的,而 memory barrier 和 read 操作则是 futex(FUTEX_WAKE)内部实现的。thread 2 的操作就完全是 futex(FUTEX_WAIT)的一部分(因为 wake_me 是 kernel memory 中的一个 flag);FUTEX_WAIT 会使用 futex 的预期值作为系统调用的参数,并用它来决定是否 sleep。关于这里具体是如何生效的,可以参见 kernel/futex.c 文件开头的长长的注释。

  • 在 KVM 中,没有 sleep() 行为了,而是变成了进入处理器的 guest mode 并执行虚拟机内部工作的这个行为了。为了让处理器从 guest mode 退出来,kvm_vcpu_kick() 会向处理器发送一个处理器间中断(inter-processor interrupt)。可以跟着这一串函数调用直到 kvm_vcpu_exiting_guest_mode(),在这里就能找到我们熟悉的关于 memory barrier pairing 的注释了,这个函数也是读取 vcpu->mode 的地方。

  • Virtio 设备中实际上有两处在使用我们介绍的这个模式。其中之一是在 driver 希望停止处理 completed requests,并且发送一个中断来对其进行唤醒操作。另一方面,device 想停止处理 submitted requests,device 就通过写入 "doorbell"内存位置(相当于按门铃)来唤醒它。

我们这就完成了 memory barrier 的介绍。后续,我们将讨论一下 compare-and-swap 操作、它们如何与锁结合起来实现 lock-free fast path、以及它们在实现 lock-free linked list 中起到的作用。

全文完

参考资料:


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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值