同步篇之临界区与自旋锁,详细介绍临界区与自旋锁相关知识。
写在前面
此系列是本人一个字一个字码出来的,包括示例和实验截图。由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我。
你如果是从中间插过来看的,请仔细阅读 羽夏看Win系统内核——简述 ,方便学习本教程。
看此教程之前,问几个问题,基础知识储备好了吗?保护模式篇学会了吗?练习做完了吗?没有的话就不要继续了。
🔒 华丽的分割线 🔒
并发与同步
并发是指多个线程在同时执行。单核是分时执行,不是真正的同时,而多核是在某一个时刻,会同时有多个线程再执行。同步则是保证在并发执行的环境中各个线程可以有序的执行。但是这个定义是不太准确,我们给几个示例请判断如下代码是否是并发,先看如下代码:
void proc1()
{
int x = 5;
printf("%d",x);
}
void proc2()
{
int x = 6;
printf("%d",x);
}
请问上面这两个函数同时执行,存在不存在并发问题呢?其实并不存在,因为局部变量是在栈中分配的,你用你的,我用我的,互不影响。如果是下面的代码:
int x = 6;
void proc1()
{
printf("%d",x);
}
void proc2()
{
printf("%x",x);
}
这个也是不存在并发问题的,因为这两个函数虽然都是用到的是同一个变量,但是,它们并没有修改此变量,这两个函数怎么执行都不会互相影响,故也不存在并发问题。但是,代码这样一改就不行了:
int x = 6;
void proc1()
{
x--;
}
void proc2()
{
++x;
}
因为这两个函数执行都会修改全局变量,它们的执行会影响结果,故存在并发问题。如果其他操作用到这个值,将会影响判断。
临界区
在学习临界区之前,先看看如下代码:
int x = 6;
void proc1()
{
x++;
}
请问这一行
x++
代码,是不是线程安全的?
答案是不是,尽管我们的C代码是只有一句,但是翻译成汇编,它就不是一句了。我们假设全局变量
x
经过编译器编译后的地址为
0x12345678
,那么汇编就翻译成如下几句汇编:
mov eax,[0x12345678]
add eax,1
mov [0x12345678],eax
如果任何一处汇编执行的时候被时钟中断进行切换线程,大量的线程执行此函数的结果是不一样的。比如线程1执行到
add eax,1
完成被切换走了,线程2执行完此流程,那么,最终这两个线程执行的结果
x
的值为2,这就是典型的线程安全问题。如果我改成用
INC DWORD PTR DS:[0x12345678]
这个汇编来实现此函数功能,这代码安全吗?
对于单核,这个是没问题的。但是对于多核这是有问题的。就和同时执行两个线程来修改同一个变量的原因是一样的,
CPU
实现肯定是读取地址获取数值,然后使用加法器进行加一,然后放回去。但是如何实现多核下的线程安全呢?
如下汇编就解决了这个问题:
LOCK INC DWORD PTR DS:[0x12345678]
对,前面加一个
LOCK
,这个也是一条汇编指令。它是一个锁,锁的是你要执行指令的地址,而不是汇编执行的执行。也就是说
0x12345678
在同一时刻只能由一个核进行访问修改。这就解决了多核下的线程安全,这种操作也被称之为原子操作,虽然原子可以再分,但是意思就是不能再分割的操作。
Windows
为了方便我们应用原子操作,也封装好了几个函数:
InterlockedIncrement
、
InterlockedExchangeAdd
InterlockedDecrement
InterlockedFlushSList
InterlockedExchange
InterlockedPopEntrySList
InterlockedCompareExchange
InterlockedPushEntrySList
。由于怎样用函数不是我们教程讲解的重点,我们研究的是怎样实现,所以这块地方就不赘述了。我们来看看微软是怎么实现原子操作加的:
; int __fastcall InterlockedIncrement(LPLONG lpAddend)
public InterlockedIncrement
InterlockedIncrement proc near
mov eax, 1
lock xadd [ecx], eax
inc eax
retn
InterlockedIncrement endp
这几行就是实现原子操作的。由于是调用约定是快速调用,这个
lpAddend
参数是由
ecx
传递的。
xadd
指令就是实现先交换,再相加放到目的操作数,如下是白皮书描述:
Description
Exchanges the first operand (destination operand) with the second operand (source operand), then loads the sum of the two values into the destination operand. The destination operand can be a register or a memory location; the source operand is a register.
Operation
TEMP ← SRC + DEST;
SRC ← DEST;
DEST ← TEMP;
此原子加法前面加了锁而
ecx
指向的变量是多个线程可能访问的,故线程安全。
如果我写了
balabala
一个代码块,想要解决线程安全问题,我想使用
LOCK
方式解决可以吗?比如下面这样:
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
这样虽然每一行都保证只能一个线程占有资源,但是保证这些代码只能由一个线程运行,还是不能实现的,所以不能实现线程安全。
好了,铺垫了这么多,我们来讲讲临界区是啥。一次只允许一个线程进入直到离开,这样的东西就是临界区,打比方一堆人排队上一个单间厕所,一次只能由一个人进一个人出,如果人是线程,坑是变量等资源,那么这个厕所就是所谓的临界区。用代码演示一下:
DWORD dwFlag = 0; //实现临界区的方式就是加锁
//锁:全局变量 进去加一 出去减一
if(dwFlag == 0) //进入临界区
{
dwFlag = 1;
//.......
//一堆代码
//.......
dwFlag = 0; //离开临界区
}
当然这个代码是有问题的,不能够实现临界区,只是思想展示的示例。如果真正的实现临界区,就必须用汇编,如下是实现进入临界区的代码:
Lab:
mov eax,1
lock xadd [Flag],eax
cmp eax,0
jz endLab
dec [Flag]
//调用线程等待Sleep ……
endLab:
ret
其中
Flag
是上面的全局变量,也就是所谓的锁。我们再看看如何退出临界区:
lock dec [Flag]
为什么加汇编加
lock
我就不赘述了。不过上面的实现,性能比较差,因为一旦两个线程同时执行,一个线程正在跑着,另一个就去睡大觉了。对于临界区,就介绍这么多。
自旋锁
自旋锁也是用来解决同步问题的,为什么这个名字,我们首先看看微软是如何实现自旋锁这个东西的,故先定位到如下函数:
; void __stdcall KeAcquireSpinLockAtDpcLevel(PKSPIN_LOCK SpinLock)
public KeAcquireSpinLockAtDpcLevel
KeAcquireSpinLockAtDpcLevel proc near
SpinLock = dword ptr 4
mov ecx, [esp+SpinLock]
loc_469998: ; CODE XREF: KeAcquireSpinLockAtDpcLevel+14↓j
lock bts dword ptr [ecx], 0
jb short loc_4699A2
retn 4
; ---------------------------------------------------------------------------
loc_4699A2: ; CODE XREF: KeAcquireSpinLockAtDpcLevel+9↑j
; KeAcquireSpinLockAtDpcLevel+18↓j
test dword ptr [ecx], 1
jz short loc_469998
pause
jmp short loc_4699A2
KeAcquireSpinLockAtDpcLevel endp
lock bts dword ptr [ecx], 0
这行代码的作用是将
ECX
指向数据的第0位置1,如果
[ECX]
原来的值为0 那么
CF = 1
,否则
CF = 0
,
lock
保证了只能单核处理。
如果
CF = 1
,也就是说这个已经上锁了,就跳转到
loc_4699A2
这个地方,如果锁上着,就继续往下走,
pause
会让
CPU
暂停一会,然后死循环,转起圈,直至锁被释放,这就是所谓的自旋锁。
自旋锁只对于多核才有意义,如果是单核反而会造成大量的性能损失。自旋锁与临界区、事件、互斥体一样,都是一种同步机制,都可以让当前线程处于等待状态,区别在于自旋锁不用切换线程,有关自旋锁的知识就介绍这么多。
本节练习
本节的答案将会在下一节的正文给出,务必把本节练习做完后看下一个讲解内容。不要偷懒,实验是学习本教程的捷径。
俗话说得好,光说不练假把式,如下是本节相关的练习。如果练习没做好,就不要看下一节教程了,越到后面,不做练习的话容易夹生了,开始还明白,后来就真的一点都不明白了。本节练习不多,请保质保量的完成,答案解析将在正文展示。
1️⃣ 自己实现一个临界区,不得使用本篇章介绍的实现。
2️⃣ 多核情况下,实现在高并发的内核函数内部进行
Hook
,而不能出错。
下一篇
同步篇——事件等待与唤醒
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可
本文来自博客园,作者:寂静的羽夏 ,一个热爱计算机技术的菜鸟
转载请注明原文链接:https://www.cnblogs.com/wingsummer/p/15870119.html