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

写在前面

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

你如果是从中间插过来看的,请仔细阅读 羽夏看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为了方便我们应用原子操作,也封装好了几个函数:InterlockedIncrementInterlockedExchangeAddInterlockedDecrementInterlockedFlushSListInterlockedExchangeInterlockedPopEntrySListInterlockedCompareExchangeInterlockedPushEntrySList。由于怎样用函数不是我们教程讲解的重点,我们研究的是怎样实现,所以这块地方就不赘述了。我们来看看微软是怎么实现原子操作加的:

; 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 = 0lock保证了只能单核处理。
  如果CF = 1,也就是说这个已经上锁了,就跳转到loc_4699A2这个地方,如果锁上着,就继续往下走,pause会让CPU暂停一会,然后死循环,转起圈,直至锁被释放,这就是所谓的自旋锁。
  自旋锁只对于多核才有意义,如果是单核反而会造成大量的性能损失。自旋锁与临界区、事件、互斥体一样,都是一种同步机制,都可以让当前线程处于等待状态,区别在于自旋锁不用切换线程,有关自旋锁的知识就介绍这么多。

本节练习

本节的答案将会在下一节的正文给出,务必把本节练习做完后看下一个讲解内容。不要偷懒,实验是学习本教程的捷径。

  俗话说得好,光说不练假把式,如下是本节相关的练习。如果练习没做好,就不要看下一节教程了,越到后面,不做练习的话容易夹生了,开始还明白,后来就真的一点都不明白了。本节练习不多,请保质保量的完成。

1️⃣ 自己实现一个临界区,不得使用本篇章介绍的实现。
2️⃣ 多核情况下,实现在高并发的内核函数内部进行Hook,而不能出错。

下一篇

  

你可能感兴趣的:(同步篇——临界区与自旋锁)