LWN: Lockless编程模式 - 介绍compare-and-swap!

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

Lockless patterns: an introduction to compare-and-swap

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

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

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

系列文章之三:LWN: lockless编程模式 - full memory barrier!

在本系列第一篇文章中,我介绍了并发内存模型(concurrent memory models)背后的理论,以及如何用该理论来解释普通的加载和存储操作(load and store)。然而,要想创建更高层的同步原语(比如 spinlock、mutex 和 condition variable),仅靠 load 和 store 分析并不好用。尽管也确实可以利用上次介绍的 full memory-barrier 模式(Dekker 算法)来保持两个线程的同步,但实际上现代处理器提供了一种更简单、更通用、更快速的方式(是的,这三方面都有优势):compare-and-swap 操作。

从 Linux 内核程序员的角度来看,compare-and-swap 原型如下:

T cmpxchg(T *ptr, T old, T new);

其中 T 可以是一个整数类型(不过不能超过指针所占用的空间),也可以是一个指针类型。为了支持这种多态性(polymorphism),cmpxchg()被定义为一个宏,而不是一个函数。但编写这个宏时很小心,以免对其参数进行多次运算。Linux 也有一个 cmpxchg64()宏,采用了 64 位整数作为参数,但它不能保证在所有的 32 位平台上都可用。

cmpxchg()从 *ptr 指向的位置来 load 数值,如果其它等于 old,则在它的位置存储 new 这个值;否则就不会进行 store 操作。然后将 load 的值返回回去,无论其是否与 old 值匹配。compare-and-swap 是原子操作:如果进行了 store,那么可以确定其他线程不会偷偷地把除 old 以外的值写入*ptr。因为它在一个原子操作之内就既读取了 old 值并且 store 了一个 new 值,所以 compare-and-swap 被称为是一个 atomic read-modify-write 操作。

在 Linux 中,cmpxchg()宏对前后代码的执行顺序提出了要求。一个 compare-and-swap 操作包括一个 load 和和一个 store,在本文中,你可以认为它们分别是 load-acquire 和 store-release 操作。这意味着 cmpxchg()可以与其他线程对同一地址进行的 load-acquire 或 store-release 操作保证同步。

Lock-free stacks and queues

"Lockless algorithms for mere mortals" (LWN:凡人如何理解lockless算法?)一文中已经提到了针对 lock-free list 如何使用 compare-and-swap。在本文中,我们将看到如何在 C 语言中实现一个 lockless(无锁的)单向链表,以及如何使用。然而,首先还是先让我们回顾一下单线程的 C 程序是如何在一个单向链表前面插入一项的:

struct thing {
    struct thing *next;
    ...
};
struct thing *first;

node->next = first;
first = node;

有了本系列第一部分的知识,我们就知道了应该把对 first 的赋值操作变成一个 store-release,这样对于其他在做 load-acquire 操作的线程来说就可以看到 node-next 的改变了。这里介绍的模式就是一个应用例子。

但是,该模式只对单个生产者和单个消费者的情况有效。在有多个生产者的情况下,这两条指令必须用一个锁来保护起来。这是因为在这两条指令的间隙,有可能 first 的值会被改变,比如此时另一个线程在链表中插入了另一个节点。如果发生了这种情况,新的节点中的指向后一个节点的指针(node->next) 会指向在赋值操作发生前的 first 的内容。这给我们提了个醒(尽管很明显):acquire 和 release 语义只是设计以及证明 lockless 算法正确性的一部分而已。哪怕用了它们,还是有可能发生逻辑错误(logic mistakes)以及 race condition。

cmpxchg()不使用锁,而是让我们一上来就能发现其他线程正在修改这里。下面的代码,无论有多少个生产者都是安全的:

if (cmpxchg(&first, node->next, node) == node->next)
    /* yay! */
else
    /* now what? */

如你所见,还是有些东西需要明确决定一下。首先,如果 cmpxchg() 观察到 first 发生了变化,该怎么办。在这种情况下,正确的做法是直接将 first 的新值读到 node->next,然后再试一次。这种做法可以奏效,是因为对于其他线程来说,它们仍然看不到我们的 node,没有人会注意到我们刚才撞到了它们正在处理的时机。

第二个更微妙的问题是:我们如何对 first 进行 load?这个 load 操作不需要有 acquire 或 release 的语义,因为代码中并没有做其他内存访问是依赖于 first 值的。另一方面,也许那些进行过度优化的编译器(the big bad optimizing compiler,见 LWN:怕不怕编译器优化让你的代码彻底乱套?(完整版))会认为 first 循环体内都不会发生改变?尽管 Linux 的 cmpxchg() 确实阻止了这种编译器优化,但更好的做法还是使用 READ_ONCE()和 WRITE_ONCE()来标记这些对共享内存进行 relaxed load 和 store。

补上这些内容之后,代码变成了这样:

struct thing *old, *expected;
old = READ_ONCE(first);
do {
    node->next = expected = old;
    old = cmpxchg(&first, expected, node);
} while (old != expected);

一切都很完美,但我们只完成了一半的工作。咱们仍然没有讨论关于消费者这边是如何解读这个链表的。答案是,这取决于生产者和消费者之间的关系、消费者的数量、以及消费者是否有兴趣按照 LIFO(last-in-first-out)或 FIFO(first-in-first-out)的顺序访问各个节点。

首先,有可能所有的 read 操作都发生在生产者完成自己的动作之后。这种情况下,生产者和消费者之间的同步就发生在对列表进行操作的代码之外,消费者可以通过正常的、非原子 load 操作来访问链表。比如说,这里的同步机制可以是 thread-exit 和 thread-join,就像我们在第一篇文章中看到的例子一样。

如果很少进行读操作,或者是可以分批进行的,那么可以使用一个更巧妙的实现方式,从而让生产者可以 lockless 地完成工作,而读操作则是序列化完成的(serialized)。可以使用 reader-writer lock(rwlock)来实现。然而,生产者需要使用的是 shared access 要用的锁(即使用 read_lock()函数),而消费者则要用 exclusive access 的锁(使用 write_lock()函数)! 这样做了之后也能避免读与写同时执行,从而能让消费者也可以使用 non-atomic load 操作。我们希望这个例子能够让大家理解,即使你坚持使用最常见的 lockless 编程模式,也不会有太多的注释或太多的文档。

如果许多消费者会在这些生产者运行时并发执行,并且它们会以任意顺序来使用这些节点,那么消费者可以用一条指令来获取整批节点(这里指从列表中删除):

my_things = xchg_acquire(&first, NULL);

xchg()像 cmpxchg()一样,会将对内存位置的读和写操作采用原子方式组合起来执行。在这种情况下,它会将 list 原来的 head 内容返回,并且将其改成 NULL,从而实现将链表清空的目的。这里我使用的是 xchg_acquire()变体,它对 first 进行 load 操作的时候带有 acquire 语义,但是就像 WRITE_ONCE()一样,在 store NULL 的时候时并不需要使用 release 语义。Acquire 语义在这里就足够了,因为基本上这个模式还是第一部分中讲到的 store-release 和 load-acquire 模式。更准确地说,它是那个模式扩展为多生产者、多消费者(multi-producer, multi-consumer)之后的样子。

我们是否需要在写入这部分逻辑里也做同样的事情,也就是用 cmpxchg_release()代替 cmpxchg()呢?我们确实可以这么做,原则上来说,writer 需要做的只是将对 node-next 的 store 操作公告给其他人就行了。然而,cmpxchg() 在对 list head 进行 load 操作时的 acquire 语义有一个很有好处的副作用:它会确保每个 writer 与写入前一个元素的线程的先后顺序。在下图中,load-acquire 和 store-release 操作都是 cmpxchg()一系列成功调用的组成部分:

thread 1: load-acquire first (returns NULL)
          store-release node1 into first
              \
  thread 2: load-acquire first (returns node1)
            store-release node2 into first
                \
     thread 3: load-acquire first (returns node2)
               store-release node3 into first
                   \
        thread 4: xchg-acquire first (returns node3)

thread 3 的 cmpxchg() 是唯一一个确保与 thread 4 的 xchg_acquire()同步的。然而,由于传递性,所有的 cmpxchg()都会在 xchg_acquire()之前发生。因此,如果在 writer 中使用了 cmpxchg(),那么 reader 就可以用普通的 load 操作来遍历列表了。

反之,如果这些 writer 都使用了 cmpxchg_release(),那么先后关系就会变成这样:

thread 1: load-acquire first (returns NULL)
          store-release node1 into first

  thread 2: load first (returns node1)
            store-release node2 into first

     thread 3: load first (returns node2)
               store-release node3 into first
                   \
        thread 4: xchg-acquire first (returns node3)

thread 4 总是会从 node3->next 中读取到 node2,因为读到的 first 值是 thread 3 写入的。然而,从 thread 1 和 thread 2 到 thread 4 之间并没有先后顺序的保证,因此,thread 4 需要一个 smp_load_acquire()才能确保看到 node2->next 中的值为 node1。

上面介绍的数据结构已经在 Linux 的 linux/llist.h 头文件中实现了。当然,我们强烈建议各位不要自己重新发明轮子,可以直接使用这个实现好的版本。事实上该 API 还包含了两个有趣的函数:llist_del_first()和 llist_reverse_order()。

llist_del_first() 会把 llist 的第一个元素返回回来,并将 header 指针移动到指向第二个元素。它的文档中有警告说,只有在仅有一个 reader 的情况下才可以使用这个函数。相反,如果有两个消费者,那么在经过一系列复杂的添加和删除操作之后,就可能导致所谓的 ABA 问题。由于本文坚定地立足于 "如果有些解释过于复杂了,就不做解释了" 的原则,所以这里就不再进行详细的解释了。不过值得指出,它与之前的 rwlock 例子有相似之处。在这个情况下,多个消费者需要使用 lock 机制来确保对数据结构的并发访问保持顺序执行。而 llist_del_first() 则让 writer 在调用 llist_add()时完全不需要获取 lock,而这些 reader 则可以使用 spinlock 或 mutex。

llist_del_first()为 llist 实现了 LIFO 的语义。然而,如果你的应用程序需要 FIFO 顺序,你可以使用一个很有用的技巧,这就是 llist_reverse_order()发挥价值的地方了。用 xchg()删除一批项目(就像用 llist_del_all()做的那样)确实可以将数据批量按照 FIFO 顺序提供出来,只是每一批数据中的节点是从后到前排序的。于是我们可以使用下面的算法:

struct thing *first, *current_batch;

if (current_batch == NULL) {
    current_batch = xchg_acquire(&first, NULL);
    ... reverse the order of the nodes in current_batch ...
}
node = current_batch;
current_batch = current_batch->next;

每执行一次这组伪代码,就会按照 FIFO 顺序来返回链表中的一个元素。这也是一个单消费者数据结构(single-consumer data structure),因为它假设在任意时刻都只有一个线程在访问 current_batch。如何将伪代码转换为 llist API,就留给读者去练习了。

这一期的内容就到此为止。本系列的下一篇文章将继续探讨 read-modify-write 操作、如何利用 compare-and-swap 来构建这些操作、以及如何利用它们来加速引用计数操作。

全文完

参考资料:

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

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

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值