本篇来说一下多核同步相关联的知识,原本这是位于滴水教程中驱动部分的内容,但实际上更偏向于同步的知识点,同步相关的内容会在APC更新完后再作更新。这里也算是简单的作个预习。
并发与同步
并发是指多个线程在同时执行:
- 单核(是分时执行,不是真正的同时)
- 多核(在某一个时刻,会同时有多个线程在执行)
同步则是保证在并发执行的环境中各个线程可以有序的执行
单行指令的同步
单行代码安全吗
有了并发与同步的概念,下面我们来看一个代码:
1 | DWORD dwVal = 0; //全局变量 |
dwVal是一个全局变量,在某个线程中,有一个对dwVal的值进行自增的代码。简要思考一下,这行代码安全吗?
实际上是不安全的,我们来看一下这行指令的反汇编:
1 | mov eax, [0x12345678] |
dwVal++这一条自增语句,对应了3条汇编指令,也就是说程序在执行dwVal++时,需要按照顺序执行3条汇编指令,才能实现这条语句的功能。
现在我们来考虑一种情况,线程A和线程B均要进行dwVal++这条指令,理想状态下,这两个线程执行完后,该全局变量的值会增加2。但是如果在线程A执行完第二条指令后,发生了线程切换,情况就会变的不一样了。参考如下代码:
1 | //线程A |
根据推演出的情况,在线程A执行完第二条指令后,发生了线程切换,切换到了线程B,这样CPU执行的过程就会如上述代码所示,当两个线程执行完后,全局变量的值dwVal只会增加1,并不会增加2,结果和预期的并不一样。所以用单行代码对一个全局变量进行修改,是不安全的。
单行指令安全吗
既然单行代码不安全,那单行指令安全吗?参考如下汇编指令:
1 | inc dword ptr ds:[0x12345678] //一行汇编指令,安全吗? |
这条汇编指令仅有一行,看上去不会出现上述的情况。即使线程发生切换了,也不会造成指令的重复执行。的确,在单核的情况下,这条指令是安全的,但是多核的情况下,还是有可能发生,不同线程同时执行这条指令的情况,所以这条指令并不安全。那如何才能在多核的情况下,依旧保证线程间的同步呢?那就需要用到下面介绍的这条指令了。
LOCK指令
我们只需要增加一个lock指令,就能让单条指令在多核的情况下做到同步,参考如下代码:
1 | lock inc dword ptr ds:[0x12345678] |
lock指令有什么用呢?lock指令可以锁定当前指令执行时线程所访问的内存,上述代码执行时,会对0x12345678地址处的值进行修改,有了lock指令的限制,此时其它线程是不能访问或修改0x12345678地址处的值的,只有在这条指令执行完后,其余线程才可以对此地址的值进行访问或者修改。这样就避免了多核情况下,不同线程同时修改此地址处的值的情况。
像上面这样通过Lock指令进行限制,从而不可被中断的操作叫做原子操作。Windows提供了一部分API(主要位于Kernel32.dll和Ntdll.dll)供用户使用从而保证在多核情况下的线程同步。
1 | //原子操作相关的API: |
InterlockedIncrement
下面我们分析Windows提供的原子函数InterlockedIncrement,来理解一下这种同步的实现的本质:
IDA打开Kernel32.dll,搜索InterlockedIncrement便可找到,这是一个导出函数。
1 | _asm { |
这个代码非常简单,一共就5行,我们逐行分析:
将需要修改的变量(往往是个全局变量,它的地址会作为参数传入InterlockedIncrement函数)的地址赋给ecx,此时可以通过[ecx]访问该变量的值
给eax赋值1
这行指令分为两部分看,第一部分是lock,我们已经知道,它用来锁住内存;另一部分核心在于xadd指令,该指令接收2个参数,先交换两个操作数的值,再进行算术加法操作。可以按下述伪代码理解:
c1
2
3
4
5DWORD temp;
temp = [ecx];
[ecx] = eax;
eax = temp;
[ecx] += eax;这一步对eax自增1,此时eax和[ecx]的值相同,eax可以起到返回值的作用(尽管已经完成对[ecx]值的修改)
平衡堆栈并返回
分析完后就很好理解InterlockedIncrement函数了,本质上就是用lock指令完成一个原子操作以保证多核状态下的线程同步。
小节
这下,我们对于单行指令的同步已经理解到位了,只需在指令前加上lock,就可以保证这条指令执行期间,不会有其它线程访问当前指令访问的内存。下面我们来学习一下多行指令的同步概念。
多行指令的同步
多行指令原子操作
如果有多行指令要求原子操作,单独加lock可行吗?观察如下代码:
1 | _asm { |
答案是不可行,lock指令锁住的是内存,线程该切换还是切换,例如线程A在对0x12345678地址上的值进行修改时,发生了线程切换,尽管线程B此时并不能对0x12345678上的值进行修改,但是可以对0x3456789A地址上的值进行修改,如果这时又发生线程切换,切了回去,线程B甚至可以把线程A锁住,不让线程A去修改0x3456789A地址处的值。这样还是没有办法保证,对于这3行指令进行原子操作。这时,就需要引入一个新的概念,临界区。
临界区
可以设置一个临界区,一次只允许一个线程进入直到离开,从而保证线程的同步(这里的临界区指的是广义的临界区,各个操作系统根据临界区的思想都有各自的实现,后面也会学习到Windows提供的临界区实现)参考如下代码:
1 | DWORD dwFlag = 0; //实现临界区的方式就是加锁 |
该代码使用一个全局变量定义了一个锁,这是一个用来进入临界区的锁,当锁为0时,即可进入临界区,进入临界区去,将锁的值修改为1,这样别的线程就无法再进入临界区了。此时临界区中执行的代码可以看作原子操作。理论上可以解决多行指令的多核同步问题。
不过我们再仔细观察一遍这个代码,其实是有问题的,考虑一种情况,在进入临界区后,如果dwFlag = 1这条指令还没有执行,此时发生了线程切换,这时,切换后的新线程也可以进入临界区,这样临界区的作用就失效了。
所以,我们可以换一种方式,先修改锁的值,然后再进入临界区,参考如下伪代码:
1 | //定义全局变量 |
这份伪代码提供了一个新的思路,在进入临界区之前,先判断锁的值,若达到进入临界区的条件,则先修改锁的值,再进入临界区;若条件不符合,则调用sleep()函数,进行线程等待。一段时间后,在跳回进入临界区的地方重新判断。在线程退出临界区后,再通过原子操作还原锁的值。
小结:临界区可以通过添加全局变量的方式实现。
总结
- lock指令可以实现单条指令的多核同步
- 临界区可以实现多行指令的多核同步
参考链接
参考教程:https://www.bilibili.com/video/av68700135?p=64
参考文章:https://blog.csdn.net/qq_41988448/article/details/103585673