Linux内核QueuedSpinLock原理(2)

admin 2025-12-25 03:11:34 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入解析Linux内核队列自旋锁原理,涵盖数据结构、CPU角色及MCS节点分配。通过状态机分析多场景竞争处理与加解锁流程,重点解读慢速路径代码逻辑,揭示内核如何利用FIFO公平性、渐进式竞争处理优化锁性能,解决高并发缓存行颠簸问题。 综合评分: 90 文章分类: 二进制安全


cover_image

Linux 内核 Queued Spin Lock 原理(2)

原创

独钓孤舟雪

MyStackTrace

2025年12月22日 00:33 北京

在上一篇文章中我们介绍了 Queued Spin Lock 的数据结构 qspinlock,该结构的精妙之处在于它使用一个 union 将一个 32 位的整形变量划分成不同的位域,每个位域都是一个字段,不同的字段是给争抢自旋锁的不同角色用的。这个 qspinlock 结构通过一个紧凑的 4 字节数据结构既兼容了原有的自旋锁 API,又实现了公平的 MCS 等待队列。

下面是 qspinlock 结构的字段划分,在争抢自旋锁的 CPU 只有两个的时候只会用到 locked 和 pending 字段,这种情况并不会用到 MCS 等待队列,在争抢自旋锁的 CPU 超过两个的时候才会用到 MCS 等待队列,这个时候 tail 字段就会指向 MCS 等待队列的队尾节点。

在 Queued Spin Lock 的实现中,MCS 节点采用静态分配的方式,为每个 CPU 提前分配好 4 个 MCS 节点,每个节点都代表一个 CPU 执行上下文(进程,软中断,硬中断,NMI)。结构体 qspinlock 中的 tail 字段并不是一个指向 MCS 节点的指针,它其实是由两部分构成的:tail cpu 是 MCS 队尾节点所属的 CPU 的编号加 1,tail index 是 MCS 队尾节点所属的 CPU 上下文编号。

在 Queued Spin Lock 的实现中,争抢自旋锁的 CPU 被划分为四个角色:

  • Owner:自旋锁当前的持有者,在加锁的时候 Owner 负责将 locked 字段设置为 1,在解锁的时候 Owner 负责将 locked 字段清零。
  • Pender:自旋锁当前的第一顺位等待者,它会把 pending 字段设置为 1,然后在 locked 字段上自旋等待,当 Owner 释放自旋锁之后,Pender 会立刻检测到自旋锁释放了,然后会把 pending 字段清零,把 locked 字段置 1,这样 Pender 就会获取到自旋锁成为 Owner。
  • Successor:自旋锁的 MCS 等待队列中的第一个等待者(排在队列头的等待者),也是自旋锁当前的第二顺位等待者,只有第二顺位等待者及后来的等待者才会进入 MCS 等待队列。Successor 会在 locked_pending 字段上自旋等待,当 Owner 和 Pender 都退出之后,Successor 会检测到 locked_pending 字段清零,然后它把 locked 字段设置为 1,表示获取到该自旋锁, Successor 就升级为 Owner。
  • Queuer:自旋锁的 MCS 等待队列中除了队头(Successor)的其他等待者,Queuer 在各自的 MCS 节点的 locked 字段上自旋等待。

下面我们来分析一下 Linux 内核对 Queued Spin Lock 的实现,加锁函数为 queued_spin_lock,该函数分为快速和慢速两个路径,快速路径就是该自旋锁当前无人持有(lock->val=0),当前来获取自旋锁的 CPU 通过 cmpxchg 原子操作的检查之后立刻就能获取到锁,并且把自旋锁的 locked 字段设置为 1,然后退出。慢速路径就是当前自旋锁已经被其他 CPU 持有,cmpxchg 原子操作的检查失败,然后当前试图获取锁的 CPU 就会进入函数 queued_spin_lock_slowpath,这是 Queued Spin Lock 最重要而且也是最复杂的实现,Queued Spin Lock 的精华部分都在这个函数中。

Queued Spin Lock 的解锁函数为 queued_spin_unlock,这个函数非常简单,仅仅就是把自旋锁的 locked 字段清零。按照之前 MCS Spin Lock 的逻辑,释放自旋锁的时候,会把下一个等待的 CPU 的 MCS 节点的 locked 字段置 1,表示把自旋锁的所有权转移给下一个等待的 CPU,但是这里的 queued_spin_unlock 函数并没有这么做,而是简单的把自旋锁的 locked 字段清零,那么自旋锁所有权的转移是在哪儿做的呢?其实就在前面那个 queued_spin_lock_slowpath 函数中。

下面我们来看看这个 queued_spin_lock_slowpath 函数,Queued Spin Lock 的主要思想都在这个函数中体现的。在这个函数开头有一段注释比较有意思,以状态机的形式解释了这个函数的逻辑,我们弄懂这段注释的含义其实就弄懂了 Linux 内核对 Queued Spin Lock 的实现。

这段注释通过一个状态图描述了 Queued Spin Lock 的几种状态转换,状态由三元组(queue tail, pending bit, lock value)表示,这个状态三元组中的值的说明如下:

  • queue tail:自旋锁 MCS 等待队列的尾部 CPU 的 MCS 节点的编号。

    0:MCS 等待队列为空。

    n:MCS 等待队列尾部对应的 MCS 节点的编号。

    *:任意值,表示该字段不影响当前状态。

  • pending bit:自旋锁的 pending 字段的值。

    0:无 CPU 在等待或等待的 CPU 已进入 MCS 等待队列。

    1:有 CPU 在等待并且该 CPU 没有进入 MCS 等待队列,也就是当前有 Pender。

  • lock value:自旋锁的 locked 字段的值。

    0:锁空闲,当前没有持有者。

    1:锁被持有。

如果状态三元组中的 pending bit 和 lock value 的值为 x/y,则表示这两个字段至少有一个为 1。

这个状态机一共有 4 种状态。

  • uncontended:自旋锁无竞争状态。

    初始状态为 (0,0,0),表示锁未被持有,也没有等待者。在这种状态下,一个 CPU 尝试获取锁,通过原子操作将锁的值从 0 设置为 1,状态变为(0,0,1),这是快速路径。当 Owner 释放自旋锁之后,状态变成 (*,*,0),* 表示释放自旋锁之后可能有 Pender 在等待或者 MCS 等待队列非空,但是与这个 Owner 的状态转换无关了。

(0,0,0) -> (0,0,1) -> (*,*,0)
  • pending:自旋锁只有 Pender 在等待的状态。

    自旋锁已被某个 CPU 持有,并且 MCS 等待队列为空,这时某个 CPU 尝试获取锁,它就会成为 Pender,它会把 pending 字段设置为 1,然后在 locked 字段上自旋等待,这时的状态为 (0,1,1)。当 Owner 释放自旋锁之后,Pender 检测到 locked 字段被 Owner 清零,它会把 pending 字段清零,并且把 locked 字段置 1,Pender 升级为 Owner,这时状态会变成 (0,0,1)。

(0,0,1) -> (0,1,1) -> (0,1,0) -> (0,0,1) -> (*,*,0)
  • uncontended queue:在有 Owner 和 Pender 的情况下又来第三个试图获取自旋锁的 CPU。

    自旋锁当前已被某个 CPU 持有,并且还有一个 Pender 在等待,这时某个 CPU 尝试获取自旋锁,它就会进入 MCS 等待队列,并且这个时候 MCS 等待队列中就只有它一个等待者,它既是队头(Successor)又是队尾(通过 tail 字段可以找到这个 MCS 节点)。这个 CPU (Successor)会在 locked_pending 上自旋等待,只有当 Owner 和 Pender 都把锁释放了,这个 CPU 才能获取到自旋锁。

(0,1,1) -> (n,x,y) -> (n,0,0) -> (0,0,1) -> (*,*,0)
  • contended queue:在有 Owner 和 Successor 的情况下,有 CPU 试图获取自旋锁。

    自旋锁当前已被某个 CPU 持有,并且 MCS 等待队列中有至少一个等待者(queue tail 非零),有没有 Pender 无所谓,这时某个 CPU 尝试获取自旋锁,这个 CPU 只能进入 MCS 队列成为 Queuer 角色,这个 CPU 会在自己的 MCS 节点上的 locked 字段上自旋,等待它前面的 MCS 节点把它唤醒。如果该 CPU 前面的 MCS 节点把它的 MCS 节点的 locked 字段设置为 1,这就意味着当前的 Successor 变成了Owner,该 CPU 也要升级为 Successor,现在它需要在 locked_pending 上自旋等待了。

(n,x,y) -> (*,x,y) -> (*,0,0) -> (*,0,1) -> (*,*,0)

上面的这 4 个状态并不是都会出现的,如果自旋锁在只有 2 个 CPU 竞争,上面的 4 个状态只会出现 uncontended 和 pending,如果自旋锁只有 3 个 CPU 竞争,上面的 4 个状态只会出现 uncontended,pending 和 uncontended queue,如果自旋锁有超过 3 个 CPU 竞争,上面的 4 个状态才会都出现。

了解了函数 queued_spin_lock_slowpath 开头的注释之后,我们来看看它的代码,由于它的代码比较多,我们就分段来看,先看第一段,这一段主要是处理了只有两个 CPU 在竞争自旋锁的情况,也就是只有 Owner 和 Pender 的情况。结合代码本身的注释和我在图上写的红色的说明,应该能够看懂这段是在干啥,这里就不再赘述了。

下面是 queued_spin_lock_slowpath 函数的第二部分,这部分代码主要功能是获取当前 CPU 的 MCS 节点,先让 qspinlock 的 tail 字段指向这个 MCS 节点,再把这个 MCS 节点串进 MCS 等待队列末尾,也就是让原来的队尾的 next 指向它。接下来判断如果原来的 MCS 等待队列不为空,当前 CPU 就需要在自己的 MCS 节点的 locked 字段上自旋了,该 CPU 就成为了一个 Queuer。

如果上面的代码判断出来原来的 MCS 等待队列为空,则当前 CPU 就会成为 MCS 等待队列头,也就是 Successor,它将直接在 locked_pending 字段上自旋,这就是下面第三段代码开始的部分了。在第三段代码中主要处理两个逻辑,第一是把 MCS 等待队列中的 Successor 升级为 Owner 获取到自旋锁,第二是把 MCS 等待队列中位于原来的 Successor(刚升级为 Owner 的 CPU)后面的 CPU 升级为新的 Successor,让它解除在自己的 MCS 节点上的 locked 字段上自旋等待。 这就回答了前面的那个问题,为什么在 queued_spin_unlock 中仅仅是把 qspinlock 的 locked 字段清零,而没有唤醒 MCS 等待队列中的下一个 CPU 的逻辑,这个逻辑不在解锁函数中,而是在 queued_spin_lock_slowpath 函数中。

好了,Queued Spin Lock 的原理就介绍到这里,我们可以从中看出 Linux 内核是如何权衡各方面利弊把优化做到极致的,总结下来主要有以下几点:

  • 保证公平性:使用 FIFO 思想保证自旋锁的公平性。
  • 渐进式竞争处理:从无竞争 → pending 优化 → MCS 等待队列。
  • 最小化开销:两 CPU 竞争时避免队列操作。
  • 避免激烈竞争时的性能问题:超过两个 CPU 竞争时会使用 MCS 队列,避免缓存行颠簸导致的性能问题。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:MyStackTrace 独钓孤舟雪《Linux 内核 Queued Spin Lock 原理(2)》

评论:0   参与:  3