天天看点

同步篇——临界区与自旋锁

同步篇之临界区与自旋锁,详细介绍临界区与自旋锁相关知识。

写在前面

  此系列是本人一个字一个字码出来的,包括示例和实验截图。由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我。

你如果是从中间插过来看的,请仔细阅读 羽夏看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

同步篇——临界区与自旋锁