AUTOSAR多核OS为实现核间资源互斥,保证数据一致性,设计了自旋锁机制,该机制适用于核间资源互斥。对于多核概念,需要一种新的机制来支持不同内核上任务的互斥。这种新机制不应在同一内核上的 TASK 之间使用,因为它没有意义。在这种情况下,AUTOSAR 操作系统将返回错误。
自旋锁的特点就是当一个线程获取了锁之后,其他试图获取这个锁的线程一直在循环等待获取这个锁,直至锁重新可用。由于线程实在一直循环的获取这个锁,所以会造成CPU处理时间的浪费,因此最好将自旋锁用于能很快处理完的临界区。
一旦一个锁变量被 TASK/ISR2 占用,其他内核上的其他 TASK/ISR2 将无法占用该锁变量。自旋锁机制不会在轮询锁定变量时取消调度这些其他 TASK。但是,在轮询锁定变量时,可能会发生具有更高优先级的 TASK/ISR 准备就绪的情况。在这种情况下,旋转任务将受到干扰。
1. 干扰引起的死锁情况,在同一个核Core0内,高优先级 TASK 无限旋转(循环的获取这个锁),因为低优先级 TASK 已经占用了自旋锁。在这种情况下,第二个 GetSpinlock 调用将返回错误。【当高优先级向低优先级的task一直申请自旋锁的情况】
2. 用户可以保护 TASK 免受【当高优先级向低优先级的task一直申请自旋锁的时,因为低优先级 TASK 已经占用了自旋锁,第二个 GetSpinlock 调用将返回错误】的影响,例如,使用 SuspendAllInterrupts 敲击旋转锁,这样它就不会受到其他 TASKS 的干扰。操作系统可以为调用方自动执行此操作,请参阅配置参数 OsSpinlockLockMethod。
下图显示了由两个不同内核上的 TASKS 以不同顺序获取的两个旋转锁导致的典型死锁
3. 为避免死锁,不允许嵌套不同的旋转锁。如果旋转锁必须要嵌套,则必须定义唯一的顺序。旋转锁只能按此顺序进行,在定义的顺序内不允许循环,允许跳过单个旋转锁。
此图显示了一个示例,其中两个 TASKS 可以访问一组旋转锁 S1 -- S6。允许按预定义的顺序占用旋转锁,并允许跳过旋转锁。如果同时占用多个旋转锁,则必须按严格的后进先出顺序进行锁定和解锁。
4. 小结
AUTOSAR OS为了避免同一个内核上自旋锁造成的死锁,在任何任务或者中断占用自旋锁时,OS会自动挂起所有的中断,不会被同一内核上的任务或者中断抢占。但是,如果核间任务嵌套请求占用自旋锁,就有可能导致任务的相互锁死。比如,核0上的某任务请求先占用自旋锁A,再占用自旋锁B;而核1上的另一个任务请求先占用自旋锁B,再占用自旋锁A。如果这两个任务开始运行的时间间隔很近,就有可能造成任务的互相锁死,即自旋锁的回环嵌套调用导致的死锁。所以在系统设计时,禁止回环嵌套使用自旋锁。如果实在需要嵌套使用自旋锁,那么需要严格按照顺序请求。比如核0上的某任务请求先占用自旋锁A,再占用自旋锁B;核1上的另一个任务也先请求占用自旋锁A,再占用自旋锁B,这样顺序地访问自旋锁就不会造成死锁。
自旋锁使用时有2点需要注意:
-
自旋锁是不可递归的,递归的请求同一个自旋锁会自己锁死自己。(递归)
-
线程获取自旋锁之前,要禁止当前处理器上的中断。(防止获取锁的线程和中断形成竞争条件)
比如:当前线程获取自旋锁后,在临界区中被中断处理程序打断,中断处理程序正好也要获取这个锁,
于是中断处理程序会等待当前线程释放锁,而当前线程也在等待中断执行完后再执行临界区和释放锁的代码。
中断处理下半部的操作中使用自旋锁尤其需要小心:
- 下半部处理和进程上下文共享数据时,由于下半部的处理可以抢占进程上下文的代码,
所以进程上下文在对共享数据加锁前要禁止下半部的执行,解锁时再允许下半部的执行。
- 中断处理程序(上半部)和下半部处理共享数据时,由于中断处理(上半部)可以抢占下半部的执行,
所以下半部在对共享数据加锁前要禁止中断处理(上半部),解锁时再允许中断的执行。
-
同一种tasklet不能同时运行,所以同类tasklet中的共享数据不需要保护。
-
不同类tasklet中共享数据时,其中一个tasklet获得锁后,不用禁止其他tasklet的执行,因为同一个处理器上不会有tasklet相互抢占的情况
- 同类型或者非同类型的软中断在共享数据时,也不用禁止下半部,因为同一个处理器上不会有软中断互相抢占的情况
自旋锁(spinlock)与互斥锁的区别:
加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
那这个开销成本是什么呢?会有两次线程上下文切换的成本:
- 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
- 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
线程的上下文切换的是什么?
当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。
所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
一般加锁的过程,包含两个步骤:
- 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
- 第二步,将锁设置为当前线程持有;
CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。
自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。