avatar

Catalog
多核同步之自旋锁

前一篇关于临界区的文章中,提到了临界区的概念,它可以帮助我们在多核的情况下,实现多行代码/指令的线程同步,本篇来探索一下Windows提供的一种实现多核同步的机制,自旋锁

不同版本的内核文件

c
1
2
3
4
5
6
7
//单核
ntkrnlpa.exe 2-9-9-12分页
ntoskrnl.exe 10-10-12分页

//多核:
ntkrnlpa.exe(ntkrpamp.exe) 2-9-9-12分页
ntoskrnl.exe(ntkrnlmp.exe) 10-10-12分页

Intel的CPU,多核情况下系统安装时将ntkrnlmp.exe拷贝为ntoskrnl.exe,系统加载时加载这个(原来是ntkrnlmp.exe的)ntoskrnl.exe。所以Intel多核情况下,内核文件名仍然是ntoskrnl.exe,但是其源文件名是ntkrnlmp.exe,如果加载符号,符号文件名也是ntkrnlmp.pdb。也因此,单核,多核情况下都叫做ntoskrnl.exe。(注:以上内容来自这篇看雪文章评论里KiDebug大佬的解答)

在开始配置xp虚拟机的时候,我们设置的是单核,保证实验时不会发生核的切换,此时我们从C:\Windows\System32目录中复制出来的ntoskrnl.exe文件就是单核情况下的ntoskrnl.exe;想要获取多核情况下的ntkrnlmp.exe需要重新设置虚拟机处理器核的数量,设置成双核的处理器,如图所示

此时再进入操作系统,在C:\Windows\System32复制出来的ntoskrnl.exe实际上就是ntkrnlmp.exe了,我们为其重命名,拖进IDA中,进行下一步的分析。

SwapContext的差异

首先来看SwapContext函数,作为线程切换的核心函数,来看一下多核跟单核情况下SwapContext的差异。

单核ntoskrnl.exe:

多核ntkrnlmp.exe:

我们看到开头部分,单核与多核的SwapContext一大差异是多核情况下SwapContext函数在开头多了一对函数KeAcquireQueuedSpinLockAtDpcLevel与KeReleaseQueuedSpinLockFromDpcLevel。下面我们来看KeAcquireQueuedSpinLockAtDpcLevel做了什么事

KeAcquireQueuedSpinLockAtDpcLevel

这个函数无论在单核还是多核的ntoskkrnl.exe里都有,但是多核的用在了线程切换函数SwapContext上,我们来看看区别。

单核ntoskrnl.exe:

单核的KeAcquireQueuedSpinLockAtDpcLevel函数没有任何作为,函数内部直接返回就结束了,重点是多核的。

多核ntkrnlmp.exe:

由图,多核的KeAcquireQueuedSpinLockAtDpcLevel做了这些工作,代码不多,但远比看着要复杂,也不易理解,还是一步步来看,每行指令含义已通过注释标记在了图中,单纯的看指令会很难理解,所以我们来学习一个新的概念:自旋锁

自旋锁与排队自旋锁

自旋锁

首先,我们要知道的概念,锁的用途是实现互斥访问。锁对象实质是一个二值对象,比如,0值表示锁是空闲的,任何线程都可以获得,一旦获得,则其值赋为1,因而任何其它线程将无法获得该锁,这样就实现了互斥访问。

计算机中有一种软件技术叫做忙等待,指重复地检查一个条件,直至这个条件满足为止。自旋锁的实现就利用了这个技术。

自旋锁是一种特殊的锁,它也有两个操作:Lock和Unlock。一个线程为了获得自旋锁,会占住处理器不放,并且不停地旋转(pause指令),即空耗处理器资源,直到该锁空闲下来,从而成功地获得它。因此,处理器在获得自旋锁以前,一直在检查锁的状态,而不是选择一个就绪线程来执行,也不交付任何APC(后面的文章会讨论)。

那么就会有人疑问了,这样空耗处理器资源岂不是很浪费?为何处理器不选择一个就绪线程执行呢?实际上,在预期等待时间很短的情况下,宁可空转一小段时间,而不是“经过环境切换,将控制权交给其它线程,待锁可用以后再切换回来”,这样可以避免两次环境切换,让当前任务继续执行下去。粗略而言,只要空转时间不超过两次环境切换的时间,则使用自旋锁并不会浪费处理器的计算资源。

这样我们就可以理解自旋锁的作用领域了,在单处理器系统上自旋锁没有任何意义,只会造成资源浪费。而在多处理器系统上,就可以变得有价值,它可以避免在处理器等待获取自旋锁时发生频繁的线程切换;但是这一定要考虑线程调度,换句话说,当一个处理器上的线程在等待自旋锁时,一定存在另一个处理器正在使用该锁保护的对象,并且可以期望该处理器很快会释放该锁(时间少于两次线程切换)

排队自旋锁

当多个处理器抢夺自旋锁时,它们获取自旋锁的顺序是不确定的。Windows内核实现了一种排队的自旋锁,它允许处理器以先进先出(FIFO)的方式获取自旋锁。

在每个处理器的 KPRCB 结构中都有一个 LockQueue 数组成员,数组中的每一项对应于一个全局排队自旋锁,譬如,第一项对应于调度器锁,第三项对应于 PFN 数据库锁。关于这些锁的编号的定义,参见KSPIN_LOCK_QUEUE_NUMBER 枚举类型。因此,排队自旋锁一定是全局自旋锁,在内核设计时指定。处理器要访问排队自旋锁,只需指定锁编号即可。LockQueue 数组中每一项的类型均为 KSPIN_LOCK_QUEUE,其中包含一个 Next 指针和一个自旋锁指针 Lock。由于自旋锁的地址一定是按字对齐的,所以,Lock 成员的最后两位也被用于标志位第 0 位表示是否在等待第 1 位表示当前是否占用该锁,即是否为该锁的所有者。KiInitSpinLocks 函数包含了对所有这些排队自旋锁的初始化。

排队自旋锁的工作方式如下。如果自旋锁是空闲的,则其值(自旋锁指针Lock指向的值为 0;如果它已一个处理器获取,则指向该处理器的 KPRCB 中对应于该锁的 LockQueue 数组项如果有多个处理器正在等待一个排队自旋锁,则以当前已经获取到该自旋锁的处理器的 LockQueue 数组项为链表头,通过 KSPIN_LOCK_QUEUE 的 Next 成员构成一个排队单链表,自旋锁本身的值指向最后一个插入进来的处理器的 LockQueue 数组项。

基于这样的数据结构,当一个处理器要获取一个已被其他处理器占用或多个处理器正在竞争的排队自旋锁时,将把自己的 LockQueue 数组项插入到链表末尾,并将自旋锁的状态更新为指向自己这一项。然后,它自己的 LockQueue 数组项的 Lock 成员的第 0 位(即等待位)上旋转直至该状态位被清除表明已轮到它了。当一个处理器释放排队自旋锁时,它会清除 Lock 成员的第 1 位(即所有者位),然后,若没有处理器在等待该锁,则该自旋锁的状态变为 0否则清除排队链表中下一个处理器的 Lock 成员最低 2 位(所以,该处理器的等待标志位被清除,它可以结束旋转),并将自己的 Next 成员清零。下图显示了两个处理器 P1 和 P2 正在等待一个已被另一个处理器 P0 获取的排队自旋锁的情形。

有了排队自旋锁的概念后,再去看KeAcquireQueuedSpinLockAtDpcLevel函数就能够理解了。如下图所示:

总结

  1. 自旋锁只对多核有意义。
  2. 自旋锁与临界区、事件、互斥体一样,都是一种同步机制,都可以让当前线程处于等待状态,区别在于自旋锁不用切换线程。

参考链接

参考书籍:《Windows内核原理与实现》

参考教程:https://www.bilibili.com/video/av68700135?p=65 (滴水中级预习班65课时)

参考链接:

  1. https://www.xuebuyuan.com/219919.html (一篇关于自旋锁分析的文章)
  2. https://blog.csdn.net/whatday/article/details/13170495 (Windows内核原理与实现书中出现的函数,全局变量等出现的页码,非常有用!)
Author: cataLoc
Link: http://cataloc.gitee.io/blog/2020/05/09/%E5%A4%9A%E6%A0%B8%E5%90%8C%E6%AD%A5%E4%B9%8B%E8%87%AA%E6%97%8B%E9%94%81/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶