当我们说并发时,是指可能导致共享资源的访问出现竞争状态的若干执行路径,并不是指严格的时间意义上的并发执行。
linux系统下并发的来源主要有:
中断处理路径
调度器的可抢占性
多处理器的并发执行
在单处理器不可抢占系统中,使用local_irq_enable与local_irq_disable是消除异步并发源的有效方式,虽然驱动程序中应该避免使用这两个宏,但是在spinlock等互斥机制中常常用到这两个宏。
local_irq_enable宏用来打开本地处理器的中断,而local_irq_disable则正好相反,用来关闭处理器中断。
local_irq_save会在关闭中断前,将处理器当前的标志位保存在一个unsigned long flags中,在调用local_irq_restore的时候,再将保存的flags恢复到处理器的FLAGS寄存器中。
这样做的目的是,防止在一个中断关闭的环境中因为调用local_irq_enable与local_irq_disable将之前的中断响应状态破坏掉。
在单处理器不可抢占系统中,local_irq_enable与local_irq_disable及其变体来对共享数据保护是种简单而有效的方法。但是local_irq_enable与local_irq_disable时通过关中断的方式进行互斥保护,所以必须确保处于两者之间的代码执行时间不能太长,否则将影响到系统的性能。
问:可抢占系统呢?关闭中断不是会停止系统调用吗?
问:local_irq_enable/local_irq_disable与local_irq_save/local_irq_restore未理解其区别?
设计自旋锁的最初目的是在多处理器系统中提供对共享数据的保护,其背后的核心思想是:设置一个在多处理器之间共享的全局变量锁V,并定义当V=1时为上锁状态,V=0为解锁状态。如果处理器A上的代码要进入临界区,它要先读取V的值,判断其是否为0,如果V≠0表明有其它处理器上的代码正在对共享数据进行访问,此时处理器A进入忙等待即自旋状态,如果V=0表明当前没有其他处理器上的代码进入临界区,此时处理器A可以访问该资源,它先把V置1(自旋锁的上锁状态),然后进入临界区,访问完毕离开临界区时将V置0(自旋锁解锁状态)
spin_lock
不同的处理器上有不同的指令实现其原子操作,所以spin_lock的相关代码在不同的体系架构上有不同的实现。
-----------------------------------
static inline void spin_lock(spinlock_t *lock)
{
raw_spin_lock(&lock->rlock);
}
代码中的数据结构spinlock_t,透过层层的定义,会发现实际上它就是个volatile unsigned int变量:
-----------------------------------
typedef struct raw_spinlock {
volatile unsigned int raw_lock;
}raw_spinlock_t;
typedef struct spinlock {
union {
struct raw_spinlock rlock;
};
}spinlock_t;
spin_lock函数调用的raw_spin_lock是个宏,其实现时处理器相关的,对于ARM处理器而言,最终展开为:
static inline void raw_spin_lock(raw_spinlock_t lock)
{
preempt_disable();
do_raw_spin_lock(lock);
}
函数首先调用preempt_disable宏,后者在定义了 CONFIG_PREEMPT,也即在支持内核可抢占的调度系统中时,将关闭调度器的可抢占特性。在没有定义CONFIG_PREEMPT时,preempt_disable是个空定义,什么也不做。
真正的上锁操作发生在后面的do_raw_spin_lock函数中,不过在讨论该函数的实现细节前,先看看为什么raw_spin_lock要先调用preempt_disable来关闭系统的可抢占性。
一个打开了CONFIG_PREEMPT特性的linux系统中,一个在内核态执行的路径也有可能被切换出处理器,典型的,比如当前进程正在内核态执行某一系统调用,发生了一个外部中断,当中断处理器返回时,因为内核的可抢占性,此时将会出现一个调度点,如果CPU的运行队列中出现了一个比当前被中断进行优先级更高的进行,那么被中断的进程将会被换出处理器,即便此时它正运行在内核态。
单处理器上的这种因为内核的可抢占性所导致的两个不同进程并发执行的情形,非常类似于SMP系统上运行不同处理器上的进程之间的并发,因此为了保护共享资源不会受到破坏,必须在进入临界区前关闭可抢占性。
问:linux内核调度是如何进行的?
因为Linux内核源码试图统一自旋锁的接口代码,即不论是单处理器还是多处理器,不论内核是否配置了可抢占特性,提供给外部模块使用的相关自旋锁代码都只有一份,所以可以看到在上述的raw_spin_lock函数中加入了内核可抢占相关的代码,即便没有配置内核可抢占性的系统上,外部模块也都使用相同的spin_lock和spin_unlock接口函数。
函数接着调用do_raw_spin_lock开始真正的上锁操作:
static inline void do_raw_spin_lock(raw_spinlock_t *lock)
{
unsigned long tmp;
__asm__ __volatile__(
"1: ldrex %0,{%1}\n" //L1
"teq %0,#0\n" //L2
"strexeq %0,%2,{%1}\n" //L3
"teqeq %0,#0\n" //L4
"bne 1b" //L5
:"=&r"(tmp)
:"r"(&lock->raw_lock),"r"(1)
:"cc");
smp_mb();
}
ARM汇编指令LDREX和STREX
1. LDREX 用来读取内存中的值,并标记对该段内存的独占访问:
LDREX Rx, [Ry]
上面的指令意味着,读取寄存器Ry指向的4字节内存值,将其保存到Rx寄存器中,同时标记对Ry指向内存区域的独占访问。
如果执行LDREX指令的时候发现已经被标记为独占访问了,并不会对指令的执行产生影响。2. 而STREX在更新内存数值时,会检查该段内存是否已经被标记为独占访问,并以此来决定是否更新内存中的值:
STREX Rx, Ry, [Rz]
如果执行这条指令的时候发现已经被标记为独占访问了,则将寄存器Ry中的值更新到寄存器Rz指向的内存,并将寄存器Rx设置成0。指令执行成功后,会将独占访问标记位清除。而如果执行这条指令的时候发现没有设置独占标记,则不会更新内存,且将寄存器Rx的值设置成1。
一旦某条STREX指令执行成功后,以后再对同一段内存尝试使用STREX指令更新的时候,会发现独占标记已经被清空了,就不能再更新了,从而实现独占访问的机制。
假设系统中有两个处理器A和B,其上运行的代码现在都通过spin_lock试图进入临界区。刚开始时,自旋锁V=0处于解锁状态,注意这里是真正的并发执行。当处理器A执行完L1处的指令,尚未开始执行L2时,处理器B开始执行L1,等到处理器A执行完L1处指令,尚未开始执行L2时,处理器B开始执行L1,等到处理器A执行完L2准备执行L3时,处理器B执行完L1。这样会发生什么情况呢?初始在处理器A和B看来,V都是0(因为B执行完L1时,处理器A还没有执行L3,因此V还没有被更新),这意味V为1。谁先更新V并不重要,重要的是如果没有L4出的指令,处理器A和B都将跳过L5处的指令而进入临界区,而这意味着spin_lock函数对并发访问时的互斥管理是失败的,将可能在系统中引起非常严重的后果。
但是因为L4处代码的出现情况发生了变化,L4处的代码在这种危机关头所起的作用得益于strex和ldrex指令,相对于ARM中普通的str与ldr指令,strex和ldrex加入了对共享内核互斥访问的支持。针对本例,在处理器A和B都使用L1处的ldrex来访问自旋锁V之后,在执行到L4时将导致只有其中一个处理器可以成功执行L4,也即成功更新V为1,tmp=0。另一个处理器将不会完成对V的更新动作,对它而言tmp=1,意味着更新动作失败,这样它将不得不执行L5进入自旋状态。如此就可以保证对自旋锁V的"读取-检测-更新"操作序列的原子性。
与spin_lock相对的是spin_unlock函数,这是一个应该在离开临界区时调用的函数,用来释放此前获得的自旋锁。其外部接口定义如下:
-----------------------------------
static inline void spin_unlock(spinlock_t *lock)
{
raw_spin_unlock(&lock->rlock);
}
static inline void raw_spin_unlock(raw_spinlock_t lock)
{
do_raw_spin_unlock(lock);
preempt_enable();
}
函数先调用do_raw_spin_unlock做实际解锁操作,然后调用preempt_enable()函数打开内核可抢占性,对于没有定义CONFIG_PREEMPT的系统,该宏是个空定义。
do_raw_spin_unlock函数在ARM处理器上的代码如下:
static inline void do_raw_spin_unlock(raw_spinlock_t* lock)
{
smp_mb();
__asm__ __volatile__(
"str %1,[%0]\n"
:
:"r"(&lock->lock),"r"(0)
:"cc");
}
解锁操作比获得锁的操作要相对简单,只需更新锁变量为0即可,在ARM平台上利用单条指令str就可以完成该任务。
针对spin_lock应用调用spin_unlock而不是其他形式的释放锁函数。驱动程序员必须确保这种获得锁和释放锁函数调用的一致性。
spin_lock的变体
spin_lock对多处理器系统中这种进程间真正的并发执行引起的竞态问题解决得很好。
但需要考虑的是以下情形:
处理器上当前进程A因为要对某一全局性的链表g_list进行操作,所以在操作前通过调用spin_lock来进入临界区,当它正处于临界区时,进程A所在的处理器发生了一个外部中断,此时系统必须暂停当前的进程A的执行转而去处理该中断,假设该中断的处理例程中恰好也要操作g_list,因为这是一个共享全局变量,所以在操作之前需要调用spin_lock函数来对该共享变量进行保护,当中断处理例程中的spin_lock试图去获得自旋锁slock时,因为被它中断的进程A之前已经获得该锁,于是中断处理例程进入自旋状态,导致死锁。
出现这种特定情况的本质原因在于对锁的竞争发生在不能真正并发执行的两条路径上,如果可以并发执行,那么在上面的案例中,被中断的进程依然可以继续执行继而释放锁,对这种问题的解决导致了spin_lock函数其他变体的出现。
因处理外部中断而引发spin_lock缺陷的例子,使得必须在这种情况下对spin_lock予以修正,于是出现了spin_lock_irq和spin_lock_irqsave函数,他们在spin_lock基础上增加了对中断的关闭操作。
通过自旋锁进入的临界区代码必须在尽可能短的时间内执行完毕,因为它执行的时间越长,别的处理器就需要自旋以等待更长的时间(尤其是这种自旋发生在中断处理函数中),最糟糕的情况是进程在临界区中因为某种原因被换出处理器。
所以作为使用自旋锁时一条确定的规则,任何拥有自旋锁的代码必须是原子的,不能休眠。
如此,当知道一个自旋锁在中断处理的上下文中有可能会被使用到时,应该使用spin_lock_irq,而不是spin_lock,后者只有在能确定中断上下文中不会使用到自旋锁的情况下才能使用。
spin_lock_irq对应的释放锁函数为spin_unlock_irq。
自旋锁还设计了一组对应的非阻塞的版本,分别是:
static inline int spin_trylock(spinlock_t *lock);
static inline int spin_trylock_irq(spinlock_t *lock);
spin_trylock_irqsave(lock,flags);
这些非阻塞版本的自旋函数在试图获得一个锁时,如果发现该锁处于上锁状态,会直接返回0而不是自旋,如果成功获得锁则返回1。
单处理器上的spin_lock函数
单处理器系统可分为内核不可抢占及可抢占两种。
对于第一种系统而言,并发主要源自外部中断等异步事件,所以在这种系统中,在进入临界区时只需要关闭处理器的中断即可,在离开临界区时只需打开处理器的中断。
对于第二种系统,并发源除了中断与异常等异步事件外,还包括因为可抢占性导致的进程间并发,所以在这种系统中,在进入临界区除了要关闭处理器中断外,还需要关闭内核调度器的课抢占性。
linux内核为了统一单处理器上这种竞态处理的代码,将spin_lock函数机器变体从多处理器延伸到了单处理器上。
对于单处理器而言,如果是非抢占式系统,那么spin_lock/spin_unlock将等同于空操作;而对于内核可抢占的系统,spin_lock/spin_unlock则分别用来关闭和打开可抢占性,此时它们等同于preempt_disable/preempt_enable。
而spin_lock_irq/spin_lock_irqsave和spin_unlock_irq/spin_unlock_restore在单处理器上则等同于local_irq_disable/local_irq_save和spin_unlock_irq/local_irq_restore。如果是可抢占式系统,那么需要在上述中断控制函数后再加上对内核可抢占性的preempt操作。
问:既然关闭了中断,linux内核还可以实现调度吗?