分享

CPU眼里的竞争锁🔒

 山峰云绕 2024-03-09 发布于贵州

https://m.toutiao.com/is/iFj6x9Pv/?= 


线程竞争,是一个非常普遍的编程现象;竞争到底是谁造成的?会产生什么后果?应该如何解决竞争问题?自旋锁又有什么特殊之处呢?

01

提出问题

线程、进程锁(Mutex,Semaphore)是操作系统的核心、高级功能之一,它因何而来?要解决什么问题?它的实现原理是什么?

首先,我们先回答第一个问题:锁因何而来?因为即使最简单的代码,也会产生竞争问题。例如:这是一个最简单的 +1 函数func,如图所示。

请问,如果把这个函数func调用2次,变量sum的值是多少?答案既可能是2,也可能是:1。你知道这是为什么吗?

02

代码实验

为了重现结果是1的过程,阿布找到了一个透明的单核CPU和内存,如图所示。

上方是CPU;中间是函数代码sum += 1对应的3条CPU指令(汇编指令)。

最下方是内存条。其中,左边的内存颗粒存储着线程1的上下文;右边的内存颗粒存储着线程2的上下文;中间的内存颗粒则存储着变量sum的值;

好了,现在我们可以运行sum += 1对应的3条CPU指令了。首先线程Thread1先运行,如图所示。

第一条指令,读取变量sum的值,并存入寄存器 eax;此时eax的值等于:0。

第二条指令,对eax作加1运算,也就是 0 + 1,结果1,还是存入寄存器eax 。

就在这时,发生了任务切换,操作系统先保存线程Thread 1的上下文,如图所示。

也就是把当前的CPU寄存器,存入到左边用来存放线程Thread 1的内存颗粒上。

随后,决定让线程Thread 2执行这3条指令,如图所示。

第一条指令,还是读取变量sum的值。

第二条指令,还是作+1运算。

第三条指令,把结果1,写入到变量sum里面。

此时,再次发生任务切换,操作系统先保存线程Thread2的上下文,也就是把当前的CPU寄存器,存入到右边的内存颗粒上。

随后,决定让线程Thread 1继续执行,通过设置CPU的寄存器,可以让CPU再次回到刚才被打断的地方,如图所示。

这也就是所谓的:恢复线程Thread 1的上下文。其中eax寄存器,被恢复到+1运算后的值:1,而eip寄存器(值是:0x401144),则让CPU可以继续执行第三条指令,如图所示。

也就是把寄存器eax的值:1,写入到变量sum所在的内存颗粒上。至此,sum += 1已经运行了2次,但sum的值却不是2,而是:1!

03

Mutex

是什么导致这个结果呢?原因有2个,缺一不可!让我们把这3条CPU指令,想象成高速公路;把线程Thread 1、Thread 2想象成两辆在高速公路上行驶的汽车:T1、T2,如图所示。

如你所见:

原因一:sum += 1 不是原子操作,而是由3条汇编指令组成,而这也是我们无法改变的现实

原因二:线程T1和线程T2不是依次的执行这3条指令,而是一拥而上。所以,第1次+1运算的结果,还没完成存储;第2次的+1运算就突然介入。

这导致:第一次+1的计算结果,不能被第二次的+1运算所用。所以,尽管执行了2次+1运算,其结果仍然是:1!

相反,如果让2个线程,依次、排他的执行这3条指令。这个竞争问题,就可以迎刃而解了。让时间倒退,我们来看看正确的做法,如图所示。

在指令的开头和末尾分别加上:一个红绿灯和一台自动闸机。红绿灯上的数字1,表示仅允许1个线程,跑这3条指令。

线程T1先执行,发现是绿灯,所以就正常运行;同时由于占据了一个线程名额后,现在可运行的线程名额就是:1 – 1 = 0,红灯亮起,如图所示。

这时,线程T2过来一看,红灯,于是放弃运行,进入“休眠”状态。

随着线程T1跑完全程,通过自动闸机,返还了一个线程名额。这样,可运行的线程名额就是:0+1=1,绿灯再次亮起。

这样,线程T2就可以像线程T1一样,单独、排他的跑完全部指令。由于线程T1和线程T2是依次执行sum + 1,所以sum最终的结果就是:2

而红绿灯和闸机,就是大家熟悉的:mutex加锁和解锁的API:

pthread_mutex_lock()...//critial sectionpthread_mutex_unlock()

而在红绿灯和闸机之间的代码,也就是我们常说的:临界区:critical section。

04

Semaphore

那如果,红、绿灯的起始数值是2,会怎样呢?那就意味着:当前的公路,最多可以同时提供2个车位,如图所示。

线程T1驶入后,由于占据了一个车位,现在可提供的车位数就是2 – 1 = 1,持续绿灯;线程T2驶入后,也占据一个车位,现在可提供的车位数变成:1 – 1 = 0,红灯亮起。所以,当线程T3看到红灯时,就会就地熄火、休眠。

如果线程1和线程2一直停滞不前、或迷路,没到闸机处打卡,那线程3只能一直休眠,也就是所谓的“死锁”。

如果一切顺利,线程T1顺利驶出闸机,返还了一个车位,所以,现在可提供的车位数就是0 + 1 = 1,绿灯将再次亮起。

这样线程3就可以驶入公路了,因为也会占据一个车位,所以,现在可提供的车位就是:1 – 1 = 0,红灯再次亮起,如图所示。

等到,线程2和线程3都打卡驶出公路时,可提供的车位就是:0 + 2 = 2,绿灯再次亮起。一切恢复如初,如图所示。

而红、绿灯和闸机,就是大家熟悉的:Semaphore的wait API和post API:

sem_wait()sem_post()

05

总结

1. Mutex的本质还是Semaphore,只是可用的共享资源上限是1而已,从而变相的实现了互斥。

2. 加锁操作,会让当前线程消耗1份共享资源;但如果资源已经枯竭,当前线程只能就地休眠,等待资源。而死锁,优先级反转问题,也往往在这个阶段产生,需要十分慎重。

3. 解锁操作,会返还1份资源,并试图唤醒:还在等待资源的线程。

如你所见,锁通过线程主动放弃运行机会的方法,来协调多线程对公路、停车场等共享资源的竞争,实现线程之间的协调,也就是我们常说的同步。

需要注意的是:我们用“锁”保护的是:资源、数据,而不是保护某个操作、或者某个函数。所以,在进行“锁”操作的时候,我们要把影响范围尽量控制在一个小的范围,不要为了保护某一个变量,而锁住整个、或者大片的函数。

最后,附上了Semaphore wait和post在单核CPU下的简易实现代码,以供读者参考,如图所示。

06

热点问题

Q1:由于永久休眠、或忘记“解锁”造成的问题,也叫“死锁”吗?难道不是:两个线程互等对方的占据的资源,又因为双方都没有释放资源,从而导致双方一直处于等待资源的状态,而无法继续运行的问题吗?

A1:是的!作者认为这两种情况都会导致一些线程,因为永久等待永远无法获得的资源,而无法继续运行。所以,阿布因为个人喜好,喜欢把它们都称之为“死锁”,特别的,会把互相等待对方资源的情况,称之为:“互锁”,如图所示。

“互锁”是经典的死锁问题,也是教科书的最爱,但在编程实践中,未必常见。

而因为永久休眠、或其他原因导致没有“解锁”,而导致“死锁”的情况。在形式上更加简单,在实际编程中,也更加常见。

Q2:为什么Semaphore wait和post的实现代码,使用关中断?这是不是太暴力了?

A2:是比较暴力。这可能让CPU错失一些对外部事件(例如:时钟信号、网卡收发信息等)的响应。所以,及时打开中断显得非常必要。

当然,关中断也是最简单的单核CPU实现“原子”操作的办法,代码的重点是让读者理解基本的实现思路。在实现“原子”操作的具体实现上,暂时不用过于深究。因为不同的CPU在处理“原子”操作都会又所不同,需要具体问题,具体分析。

Q3:什么是自旋锁?自旋锁需要什么特殊的硬件支持吗?

A3:自旋锁是锁的一种,它只针对多核CPU。它不像本文描述的常规锁,在等待资源的时候,会释放对CPU的占用,相反会继续占用CPU,并while循环检测资源的可用性(也就是等待其他CPU通过对应的代码,释放资源),避免了任务调度(切换),实现起来,非常简单、高效,适用一些可以快速获得资源的场景。使用场景往往是追求极致效率的底层操作;但如果自旋锁长期得不到资源,一直循环检测,则会占用大量的CPU资源(while循环的代码)

相反,常规锁,既可以用于多核CPU也可以用于单核CPU,它在等待资源的时候,因为会主动放弃对CPU的占用,也就是把当前任务放入等待队列,这往往会触发任务的重新调度,实现起来,会略微复杂。其等待、唤醒过程,相对自旋锁要复杂、缓慢。使用场景多是上层应用程序场景。具体实现,可以参考一个简单OS的信号量实现代码,例如:UCOS。

两种锁,一般情况下,都不需要特别的CPU硬件机制参与。不过为了保证加、减资源数量时的逻辑正确,需要使用CPU特定的原子操作指令(对于单核CPU,可以使用关中断,来达到原子操作的效果),另外为了防止CPU或编译器造成的乱序,代码中也可能会加入“内存屏障”,具体请参看“CPU眼里的:乱序执行 | 内存屏障”

07

更多知识

如果喜欢阿布这种解读方式,希望更加系统学习这些编程知识的话,也可以考虑看看由阿布亲自编写,并由多位微软大佬联袂推荐的新书《CPU眼里的C/C++》

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多