LWN: Lockless编程模式 - 更多的read-modify-write例子!

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

Lockless patterns: more read-modify-write operations

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

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

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

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

系列文章之四:LWN: Lockless编程模式 - 介绍compare-and-swap!

本系列中上周关于 lockless 的文章(LWN: Lockless编程模式 - 介绍compare-and-swap!)初步介绍了 compare-and-swap(CAS)操作。CAS 是一个强大的工具,可用来实现许多 lockless 原语 API。下一步,我们来看看可以基于 campare-and-swap 实现出来的其他原子性的 read-modify-write 操作。

基于 CAS 的原语通常都是操作 int 值的。Linux 内核使用了 atomic_t,这是一个 struct 类型,其中封装了 int,因此 load 和 store 都是显式标记出来的。例如,如果 x 是一个 atomic_t,就不可以用 x++ 这种写法。相反,必须写 atomic_inc(&x); 。所有对 atomic_t 的操作都是以 "atomic_"开头的。

Linux 针对 atomic_t 大概有 30 个 read-modify-write 的操作,其中 atomic_cmpxchg() 这种形式的 compare-and-swap 操作是最核心的,因为它可以用来实现其他所有操作。例如,下面这是一个 "atomic increment(原子操作自增)":

/* atomic_read() is like READ_ONCE(), but for Linux's atomic_t.  */
old = atomic_read(&x);
do {
    expected = old;
    old = atomic_cmpxchg(&x, expected, expected + 1);
} while (old != expected);

你可能也发现了,它与我们之前介绍过的 "add to list" 操作很类似。此外,在完成时,old 就包含了已经递增过的值,于是上述代码实际上就相当于 Linux 中 atomic_fetch_inc() 这个宏。

不同的指令集中所提供的 read-modify-write 指令数量差异很大。有的指令集中只包括 compare-and-swap;x86 则提供了更多的指令,但其中只有三条(CMPXCHG、XCHG 和 "交换和添加 "指令 XADD)会返回内存位置的原有值。而且,哪怕是最全面的指令集,也必然会出现一些处理器无法处理好的情况。这时,compare-and-swap 就可以派上用场了。例如,我们可以为 "返回两个数值中的最大值" 的这个操作来定义一个使用了 read-modify-write 方式实现的版本:

/* Store max(x, new) into x.  */
old = atomic_read(&x);
do {
    expected = old;
    if (old > new)
        break;
    old = atomic_cmpxchg(&x, expected, new);
} while (old != expected);

或者实现一个判断只有当一个值是非零时才会递增:

old = atomic_read(&x);
do {
    expected = old;
    if (old == 0)
        break;
    old = atomic_cmpxchg(&x, expected, expected + 1);
} while (old != expected);

像后面这个例子代码,对于实现 lock-free fast path 就有用。这种 lockless 编程模式可以让程序只在极少数情况下使用锁,从而大部分时候都可以避免产生锁竞争。一个典型的使用场景就是 reference couting(引用计数)。

Lock-free reference counting

假设有一个很典型的、跟引用计数相关的数据结构,我们称之为 struct gadget。每个 gadget 都有一个父节点,并持有一个对其父节点的 reference(引用),从而确保父节点本身不会被释放。下面是 get_gadget()和 put_gadget()最简单的实现,这两个函数分别用来增加和减小 gadget 的引用计数:

void get_gadget(struct gadget *g) {
    mutex_lock(&gadgets_lock);
    g->refcnt++;
    mutex_unlock(&gadgets_lock);
}

void put_gadget(struct gadget *g) {
    mutex_lock(&gadgets_lock);
    if (g->refcnt-- == 1) {
        mutex_unlock(&gadgets_lock);
        put_gadget(g->parent);
        kfree(g);
        return;
    }
    mutex_unlock(&gadgets_lock);
}

然而,这样的实现效率太低了。在某个 thread 调用 get_gadget() 来再次获取一个对 gadget g 的引用之时,它肯定早就有了一个对 g 的引用。并且在 get_gadget()返回之前,这个引用都不会消失。因此,任何对 put_gadget()的并发调用都不会走到 kfree() 这个分支。我们可以用下面的方法,就可以去除这里的 lock 了:

void get_gadget(struct gadget *g) {
    /*
     * Unlike atomic_fetch_dec(), this increment is atomic but has
     * no acquire or release semantics.  This is true of all Linux
     * atomic operations that do not return a value.
     */
    atomic_inc(&g->refcnt);
}

void put_gadget(struct gadget *g) {
    /*
     * Like atomic_cmpxchg(), this has both acquire and release semantics.
     */
    if (atomic_fetch_dec(&g->refcnt) > 1)
        return;

    put_gadget(g->parent);
    kfree(g);
}

Linux 提供了 kref_get()和 kref_put() 来实现了这个抽象概念。在上面的代码中,有两点值得注意:

  • put_gadget()并没有使用 atomic_fetch_dec_release(),递减引用计数的操作既有 acquire 语义又有 release 语义。就像在 lock-free list example 中一样,这会在所有的对 put_gadget()的调用中都传递了 “happen before” 的关系,因此当通过 g->parent 来访问其他 gadget 时,就可以使用普通的 load 操作了。

  • 唯一的同步点(synchronization point)是在 put_gadget() 里面。get_gadget()确实也要求它的 increment 递增操作是原子操作,但是它不需要有 acquire 的语义。这是因为调用它的线程一定已经具有了对 g 的引用。每当线程最初拿到这个引用的时候(例如在线程创建时),它就已经有了 acquire 和 release 的语义。在这之后,线程就可以独立运行,直到它需要交还这个引用并(可能会)释放这个 gadget 时,才会再次有 acquire 和 release 的语义。

现在,假设每个 gadget 中还存储了一个 sub-gadgets 的 list。那么在销毁自己之前,gadget 需要先将自己从其父节点的 sub-gadgets list 中删除:

void put_gadget(struct gadget *g) {
    if (atomic_fetch_dec(&g->refcnt) > 1)
        return;

    mutex_lock(&g->parent->gadgets_lock);
    list_del(&g->node);
    mutex_unlock(&g->parent->gadgets_lock);
    put_gadget(g->parent);
    kfree(g);
}

然而,跟往常一样,上面的代码只处理了一部分问题。gadget 拥有一个指向它的父节点的引用;如果父节点对它的每一个子节点也有一个引用的话,就会产生一个引用计数循环(reference-count cycle)并产生内存泄漏。因此,这个 list 必须只能包含指向子节点的指针,而不能具备对子节点的引用;也就是有时候人们对这种情况会称为 list 对子节点有一个弱引用(weak reference)。线程可以自由地访问 list 并对其中的 gadget 进行操作,但它们不能调用 get_gadget(),因为它们还没有引用。这意味着,线程执行时可能会碰到子节点出人意料地消失了的情况。

最简单的解决方法就是只在锁保护下才对弱引用进行操作。如果 put_gadget()无法拿到锁,它就无法从 list 中删除 gadget,这样 list 中的 gadget 就不会消失。然而,这样的限制太强了。我们可以改为修改一下 get_gadget() 的调用规则。以下是一个可行的替代方法:

  • 和之前一样,一个已经有强引用(strong reference)的线程可以通过 get_gadget() 来再获得一个引用。

  • 线程可以通过调用 get_gadget() 来将一个弱引用升级为强引用,但是这个过程中需要持有一个保护 list 不被修改的锁。

get_gadget()的实现仍然跟之前一样很简单,但 put_gadget() 的实现必须更加小心:它必须在将引用计数从 1 递减到 0 之前获取锁,直到从 list 中将自己删除后才释放锁。这样一来,list 的访问者就永远不会看到有对于引用计数为 0 的 gadget 的弱引用了。然而,只要引用计数大于 1,put_gadget() 就可以采用 lockless 的方式进行了。下面是用 compare-and-swap 的实现:

void put_gadget(struct gadget *g) {
    for (;;) {
        int old = atomic_read(&g->refcnt);
        if (old > 1) {
            if (atomic_cmpxchg(&g->refcnt, old, old - 1) == old)
                return;
        } else {
            /* old was 1, fence off accesses to weak references!  */
            mutex_lock(&g->parent->gadgets_lock);
            if (atomic_cmpxchg(&g->refcnt, 1, 0) == 1)
                break;

            /*
             * Somebody snuck in and upgraded a weak reference before the
             * mutex_lock().  Try again.
             */
            mutex_unlock(&g->parent->gadgets_lock);
        }
    }

    list_del(&g->node);
    mutex_unlock(&g->parent->gadgets_lock);
    put_gadget(g->parent);
    kfree(g);

建议反复阅读代码,可以看到每次调用 put_gadget() 都会引起一次正确的 compare-and-swap 操作。

这是 Linux 中的常见模式(common pattern),kref_put_mutex()和 kref_put_lock() 这两个函数对开发者的实现会很有帮助。和 llist.h 一样,我们强烈鼓励你不要自己开发自己的版本,而是尽量使用库函数(library functions)。有的读者可能会注意到并没有 kref_put_rwsem() 这个函数,如果有一个 struct rw_semaphore 用来保护子节点的 list,那么这个函数就会很方便。可以作为练习来自己尝试实现这个函数。

有时没有锁在保护数据的访问和销毁(the access and the destruction)。这种情况通常是弱引用由一个单独的子系统(a separate subsystem)所持有,比如 debugfs inode 的 i_private 字段这种情况。在这种情况下可以利用 refcount_inc_not_zero()和 kref_get_unless_zero(),在 kvm_debugfs_open() 中就能看到前一个函数的使用:

/*
 * The debugfs files still hold a reference to the kvm struct at the
 * time kvm_destroy_vm is called.  The files are removed, and the
 * reference disappears, before kvm_destroy_vm frees the kvm struct.
 *
 * To avoid a race between the opening and the removal of the debugfs
 * files, return -ENOENT if kvm_destroy_vm is in progress.
 */
if (!refcount_inc_not_zero(&stat_data->kvm->users_count))
    return -ENOENT;

与文章开头的实现(如果不为零就递增)相比,refcount_inc_not_zero()为了实现溢出保护(overflow protection)而变得更加复杂了,这进一步说明了要尽量使用现有的高级原语(higher-level primitives)重要性。

Conclusions

在下一篇文章中会讲到我们整个系列中的一些没有涵盖到的细节以及简化说明,但基本上本文就结束了我们对无锁编程模式的介绍了。除了 Linux 中非常复杂的部分, 比如 scheduler 或者 read-copy-update(RCU)之外,这些同步原语和模式(these synchronization primitives and patterns)应该涵盖了你会遇到的几乎所有的 lockless 代码。我写这些文章的目的是帮助你理解基本思想以及那些高级 API 是如何封装了这些思想的,这样以后哪怕碰到略有不同的情况,你也能正确使用它们。我希望它们既能作为学习材料,也能作为参考资料。

[作者感谢 Jon Corbet、Laszlo Ersek 和 Stefan Hajnoczi 帮助校对了这些文稿]。

全文完

参考资料:


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

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

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值