在linuxkernel的实现中,经常会遇到这样的场景:共享数据被中断上下文和进程上下文访问,该如何保护呢?如果只有进程上下文的访问,那么可以考虑使用semaphore或者mutex的锁机制,但是现在中断上下文也参和进来,那些可以导致睡眠的lock就不能使用了,这时候,可以考虑使用spinlock。本文主要介绍了linuxkernel中的spinlock的原理以及代码实现。由于spinlock是architecturedepent代码,因此,我们在第四章讨论了ARM32和ARM64上的实现细节。
二、工作原理
1、spinlock的特点
我们可以总结spinlock的特点如下:
(1)spinlock是一种死等的锁机制。当发生访问资源冲突的时候,可以有两个选择:一个是死等,一个是挂起当前进程,调度其他进程执行。spinlock是一种死等的机制,当前的执行thread会不断的重新尝试直到获取锁进入临界区。
(2)只允许一个thread进入。semaphore可以允许多个thread进入,spinlock不行,一次只能有一个thread获取锁并进入临界区,其他的thread都是在门口不断的尝试。
(3)执行时间短。由于spinlock死等这种特性,因此它使用在那些代码不是非常复杂的临界区(当然也不能太简单,否则使用原子操作或者其他适用简单场景的同步机制就OK了),如果临界区执行时间太长,那么不断在临界区门口“死等”的那些thread是多么的浪费CPU啊(当然,现代CPU的设计都会考虑同步原语的实现,例如ARM提供了WFE和SEV这样的类似指令,避免CPU进入busyloop的悲惨境地)
(4)可以在中断上下文执行。由于不睡眠,因此spinlock可以在中断上下文中适用。
2、场景分析
对于spinlock,其保护的资源可能来自多个CPUCORE上的进程上下文和中断上下文的中的访问,其中,进程上下文包括:用户进程通过系统调用访问,内核线程直接访问,来自workqueue中workfunction的访问(本质上也是内核线程)。中断上下文包括:HWinterruptcontext(中断handler)、软中断上下文(softirq,当然由于各种原因,该softirq被推迟到softirqd的内核线程中执行的时候就不属于这个场景了,属于进程上下文那个分类了)、timer的callback函数(本质上也是softirq)、tasklet(本质上也是softirq)。
先看最简单的单CPU上的进程上下文的访问。如果一个全局的资源被多个进程上下文访问,这时候,内核如何交错执行呢?对于那些没有打开preemptive选项的内核,所有的系统调用都是串行化执行的,因此不存在资源争抢的问题。如果内核线程也访问这个全局资源呢?本质上内核线程也是进程,类似普通进程,只不过普通进程时而在用户态运行、时而通过系统调用陷入内核执行,而内核线程永远都是在内核态运行,但是,结果是一样的,对于non-preemptive的linuxkernel,只要在内核态,就不会发生进程调度,因此,这种场景下,共享数据根本不需要保护(没有并发,谈何保护呢)。如果时间停留在这里该多么好,单纯而美好,在继续前进之前,让我们先享受这一刻。
当打开premptive选项后,事情变得复杂了,我们考虑下面的场景:
(1)进程A在某个系统调用过程中访问了共享资源R
(2)进程B在某个系统调用过程中也访问了共享资源R
会不会造成冲突呢?假设在A访问共享资源R的过程中发生了中断,中断唤醒了沉睡中的,优先级更高的B,在中断返回现场的时候,发生进程切换,B启动执行,并通过系统调用访问了R,如果没有锁保护,则会出现两个thread进入临界区,导致程序执行不正确。OK,我们加上spinlock看看如何:A在进入临界区之前获取了spinlock,同样的,在A访问共享资源R的过程中发生了中断,中断唤醒了沉睡中的,优先级更高的B,B在访问临界区之前仍然会试图获取spinlock,这时候由于A进程持有spinlock而导致B进程进入了永久的spin……怎么破?linux的kernel很简单,在A进程获取spinlock的时候,禁止本CPU上的抢占(上面的永久spin的场合仅仅在本CPU的进程抢占本CPU的当前进程这样的场景中发生)。如果A和B运行在不同的CPU上,那么情况会简单一些:A进程虽然持有spinlock而导致B进程进入spin状态,不过由于运行在不同的CPU上,A进程会持续执行并会很快释放spinlock,解除B进程的spin状态。
多CPUcore的场景和单核CPU打开preemptive选项的效果是一样的,这里不再赘述。
我们继续向前分析,现在要加入中断上下文这个因素。访问共享资源的thread包括:
(1)运行在CPU0上的进程A在某个系统调用过程中访问了共享资源R
(2)运行在CPU1上的进程B在某个系统调用过程中也访问了共享资源R
(3)外设P的中断handler中也会访问共享资源R
在这样的场景下,使用spinlock可以保护访问共享资源R的临界区吗?我们假设CPU0上的进程A持有spinlock进入临界区,这时候,外设P发生了中断事件,并且调度到了CPU1上执行,看起来没有什么问题,执行在CPU1上的handler会稍微等待一会CPU0上的进程A,等它立刻临界区就会释放spinlock的,但是,如果外设P的中断事件被调度到了CPU0上执行会怎么样?CPU0上的进程A在持有spinlock的状态下被中断上下文抢占,而抢占它的CPU0上的handler在进入临界区之前仍然会试图获取spinlock,悲剧发生了,CPU0上的P外设的中断handler永远的进入spin状态,这时候,CPU1上的进程B也不可避免在试图持有spinlock的时候失败而导致进入spin状态。为了解决这样的问题,linuxkernel采用了这样的办法:如果涉及到中断上下文的访问,spinlock需要和禁止本CPU上的中断联合使用。
linuxkernel中提供了丰富的bottomhalf的机制,虽然同属中断上下文,不过还是稍有不同。我们可以把上面的场景简单修改一下:外设P不是中断handler中访问共享资源R,而是在的bottomhalf中访问。使用spinlock+禁止本地中断当然是可以达到保护共享资源的效果,但是使用牛刀来杀鸡似乎有点小题大做,这时候disablebottomhalf就OK了。
最后,我们讨论一下中断上下文之间的竞争。同一种中断handler之间在unicore和multicore上都不会并行执行,这是linuxkernel的特性。如果不同中断handler需要使用spinlock保护共享资源,对于新的内核(不区分fasthandler和slowhandler),所有handler都是关闭中断的,因此使用spinlock不需要关闭中断的配合。bottomhalf又分成softirq和tasklet,同一种softirq会在不同的CPU上并发执行,因此如果某个驱动中的sofirq的handler中会访问某个全局变量,对该全局变量是需要使用spinlock保护的,不用配合disableCPU中断或者bottomhalf。tasklet更简单,因为同一种tasklet不会多个CPU上并发,具体我就不分析了,大家自行思考吧。
三、通用代码实现
1、文件整理
和体系结构无关的代码如下:
(1)include/linux/spinlock_。这个头文件定义了通用spinlock的基本的数据结构(例如spinlock_t)和如何初始化的接口(DEFINE_SPINLOCK)。这里的“通用”是指不论SMP还是UP都通用的那些定义。
(2)include/linux/spinlock_types_。这个头文件不应该直接include,在include/linux/spinlock_文件会根据系统的配置(是否SMP)include相关的头文件,如果UP则会include该头文件。这个头文定义UP系统中和spinlock的基本的数据结构和如何初始化的接口。当然,对于non-debug版本而言,大部分struct都是empty的。
(3)include/linux/。这个头文件定义了通用spinlock的接口函数声明,例如spin_lock、spin_unlock等,使用spinlock模块接口API的驱动模块或者其他内核模块都需要include这个头文件。
(4)include/linux/spinlock_。这个头文件不应该直接include,在include/linux/文件会根据系统的配置(是否SMP)include相关的头文件。这个头文件是debug版本的spinlock需要的。
(5)include/linux/spinlock_api_。同上,只不过这个头文件是non-debug版本的spinlock需要的
(6)linux/spinlock_api_。SMP上的spinlock模块的接口声明
(7)kernel/locking/。SMP上的spinlock实现。
头文件有些凌乱,我们对UP和SMP上spinlock头文件进行整理:
2、数据结构
根据第二章的分析,我们可以基本可以推断出spinlock的实现。首先定义一个spinlock_t的数据类型,其本质上是一个整数值(对该数值的操作需要保证原子性),该数值表示spinlock是否可用。初始化的时候被设定为1。当thread想要持有锁的时候调用spin_lock函数,该函数将spinlock那个整数值减去1,然后进行判断,如果等于0,表示可以获取spinlock,如果是负数,则说明其他thread的持有该锁,本thread需要spin。
内核中的spinlock_t的数据类型定义如下:
typedefstructspinlock{structraw_spinlockrlock;}spinlock_t;typedefstructraw_spinlock{arch_spinlock_traw_lock;}raw_spinlock_t;由于各种原因(各种锁的debug、锁的validate机制,多平台支持什么的),spinlock_t的定义没有那么直观,为了让事情简单一些,我们去掉那些繁琐的成员。structspinlock中定义了一个structraw_spinlock的成员,为何会如此呢?好吧,我们又需要回到kernel历史课本中去了。在旧的内核中(比如我熟悉的内核),spinlock的命令规则是这样:
通用(适用于各种arch)的spinlock使用spinlock_t这样的typename,各种arch定义自己的structraw_spinlock。听起来不错的主意和命名方式,直到linuxrealtimetree(PREEMPT_RT)提出对spinlock的挑战。realtimelinux是一个试图将linuxkernel增加硬实时性能的一个分支(你知道的,linuxkernelmainline只是支持softrealtime),多年来,很多来自realtimebranch的特性被merge到了mainline上,例如:高精度timer、中断线程化等等。realtimetree希望可以对现存的spinlock进行分类:一种是在realtimekernel中可以睡眠的spinlock,另外一种就是在任何情况下都不可以睡眠的spinlock。分类很清楚但是如何起名字?起名字绝对是个技术活,起得好了事半功倍,可维护性好,什么文档啊、注释啊都素那浮云,阅读代码就是享受,如沐春风。起得不好,注定被后人唾弃,或者拖出来吊打(这让我想起给我儿子起名字的那段不堪回首的岁月……)。最终,spinlock的命名规范定义如下:
(1)spinlock,在rtlinux(配置了PREEMPT_RT)的时候可能会被抢占(实际底层可能是使用支持PI(优先级翻转)的mutext)。
(2)raw_spinlock,即便是配置了PREEMPT_RT也要顽强的spin
(3)arch_spinlock,spinlock是和architecture相关的,arch_spinlock是architecture相关的实现
对于UP平台,所有的arch_spinlock_t都是一样的,定义如下:
typedefstruct{}arch_spinlock_t;什么都没有,一切都是空啊。当然,这也符合前面的分析,对于UP,即便是打开的preempt选项,所谓的spinlock也不过就是disablepreempt而已,不需定义什么spinlock的变量。
对于SMP平台,这和arch相关,我们在下一节描述
spin_lock的代码如下:
staticinlinevoidspin_lock(spinlock_t*lock)
{
raw_spin_lock(lock-rlock);
}
当然,在linuxmainline代码中,spin_lock和raw_spin_lock是一样的,在realtimelinuxpatch中,spin_lock应该被换成可以sleep的版本,当然具体如何实现我没有去看(也许直接使用了Mutex,毕竟它提供了优先级继承特性来解决了优先级翻转的问题),有兴趣的读者可以自行阅读,我们这里重点看看(本文也主要focus这个主题)真正的,不睡眠的spinlock,也就是是raw_spin_lock,代码如下:
define_raw_spin_lock(lock)__LOCK(lock)0\n"----------------------------(4)"bne1b":"=r"(lockval),"=r"(newval),"=r"(tmp):"r"(lock-slock),"I"(1TICKET_SHIFT):"cc");while(!=){------------(5)wfe();-------------------------------(6)=ACCESS_ONCE();------(7)}smp_mb();------------------------------(8)}(1)和preloadingcache相关的操作,主要是为了性能考虑
(2)将slock的值保存在lockval这个临时变量中
(3)将spinlock中的next加一
(4)判断是否有其他的thread插入。更具体的细节参考Linux内核同步机制之(一):原子操作中的描述
(5)判断当前spinlock的状态,如果是unlocked,那么直接获取到该锁
(6)如果当前spinlock的状态是locked,那么调用wfe进入等待状态。更具体的细节请参考ARMWFI和WFE指令中的描述。
(7)其他的CPU唤醒了本cpu的执行,说明owner发生了变化,该新的own赋给lockval,然后继续判断spinlock的状态,也就是回到step5。
(8)memorybarrier的操作,具体可以参考memorybarrier中的描述。
arch_spin_lock函数ARM64的代码(来自4.1.10内核)如下:
staticinlinevoidarch_spin_lock(arch_spinlock_t*lock){unsignedinttmp;arch_spinlock_tlockval,newval;asmvolatile(/*Atomicallyincrementthenextticket.*/"prfmpstl1strm,%3\n""1:ldaxr%w0,%3\n"-----(A)-----------lockval=lock"add%w1,%w0,%w5\n"-------------newval=lockval+(116),相当于next++"stxr%w2,%w1,%3\n"--------------lock=newval"cbnz%w2,1b\n"--------------是否有其他PE的执行流插入?有的话,重来。/*Didwegetthelock?*/"eor%w1,%w0,%w0,ror16\n"---------自己的号码牌是否等于owner?"cbnz%w1,2b\n"----------如果等于,持锁进入临界区,否者回到2,即继续spin/**/"3:":"=r"(lockval),"=r"(newval),"=r"(tmp),"+Q"(*lock):"Q"(lock-owner),"I"(1TICKET_SHIFT):"memory");}基本的代码逻辑的描述都已经嵌入代码中,这里需要特别说明的有两个知识点:
(1)Load-Acquire/Store-Release指令的应用。Load-Acquire/Store-Release指令是ARMv8的特性,在执行load和store操作的时候顺便执行了memorybarrier相关的操作,在spinlock这个场景,使用Load-Acquire/Store-Release指令代替dmb指令可以节省一条指令。上面代码中的(A)就标识了使用Load-Acquire指令的位置。Store-Release指令在哪里呢?在arch_spin_unlock中,这里就不贴代码了。Load-Acquire/Store-Release指令的作用如下:
-Load-Acquire可以确保系统中所有的observer看到的都是该指令先执行,然后是该指令之后的指令(programorder)再执行
-Store-Release指令可以确保系统中所有的observer看到的都是该指令之前的指令(programorder)先执行,Store-Release指令随后执行
(2)第二个知识点是关于在arch_spin_unlock代码中为何没有SEV指令?关于这个问题可以参考ARMARM文档中的FigureB2-5,这个图是PE(n)的globalmonitor的状态迁移图。当PE(n)对x地址发起了exclusive操作的时候,PE(n)的globalmonitor从openaccess迁移到exclusiveaccess状态,来自其他PE上针对x(该地址已经被markforPE(n))的store操作会导致PE(n)的globalmonitor从exclusiveaccess迁移到openaccess状态,这时候,PE(n)的Eventregister会被写入event,就好象生成一个event,将该PE唤醒,从而可以省略一个SEV的指令。