smpraw_spinlock_t
⾃旋锁 + dis local cpu preemt + dis local cpu interrupt
在linux/spinlock.h中定义了spinlock操作的API。
spinlock的思想就是在SMP环境中,保护共享的数据结构;也就是CPU-A正在访问(读写)共享数据的期间,其他CPU不能访问同样的共享数据,这样就保证了SMP-safe。每个线程在访问共享数据的之前,都需要获取spin lock,如果锁正被其他线程所占有,那么获取锁的线程
则“空转”CPU以等待其他线程释放锁;spin lock相对于信号量这样的锁机制的好处就是,节约了2次context switch的开销,所以如果线程等待锁的时间⼩于2次context switch的时间,系统性能从spin lock获得的提升就越多。
spin lock除了考虑SMP-safe以外,还要考虑两种伪并发情况,就是中断(interrupt)和抢占(preemption),就是要保证interrupt-safe和preempt-safe。
如果在中断处理程序中,因为要访问共享变量⽽使⽤spin lock,则要避免dead-lock出现。⽐如,CPU0上线程A获取了锁1,在获取和释放锁之间CPU0上发⽣软中断进⼊中断处理程序,中断处理程序也尝试去获取spin lock,但是由于同⼀CPU0上的lock holder线程A在中断处理程序退出之前⽆法被调度⽽释放
锁,所以在CPU0上就出现dead-lock;但是如果软中断是发⽣在其他CPU⽐如CPU1上,则是没有问题的,因为发现在CPU1上的中断不会中断CPU0上lock holder线程A的执⾏。所以要保证interrupt-safe,就要在获取锁之前disable本地CPU中断。
进程(线程)间的同步机制是⾯试时的常见问题,所以准备⽤⼀个系列来好好整理下⽤户态与内核态的各种同步机制。本⽂就以内核空间的⼀种基础同步机制—⾃旋锁开始好了
⾃旋锁是什么
⾃旋锁就是⼀个⼆状态的原⼦(atomic)变量:
unlocked
locked
当任务A希望访问被⾃旋锁保护的临界区(Critical Section),它⾸先需要这个⾃旋锁当前处于unlocked状态,然后它会去尝试获取(acquire)这个⾃旋锁(将这个变量状态修改为locked),
如果在这之后有另⼀个任务B同样希望去访问这段这段临界区,那么它必须要等到任务A释放(relea)掉⾃旋锁才⾏,在这之前,任务B会⼀直等待此处,不段尝试获取(acquire),也就是我们说的⾃旋在这⾥。
⾃旋锁有什么特点
如果被问到这个问题,不少⼈可能根据上⾯的定义也能总结出来了:
“保护临界区”
“⼀直忙等待,直到锁被其他⼈释放”
“适合⽤在等待时间很短的场景中”
说错了吗?当然没有!并且这些的确都是⾃旋锁的特点,那么更多呢?
⼏个基本概念
为什么内核需要引⼊⾃旋锁?回答这个问题之前我想先简单引⼊以下⼏个基本概念:
UP & SMP
UP表⽰单处理器,SMP表⽰对称多处理器(多CPU)。⼀个处理器就视为⼀个执⾏单元,在任何⼀个时刻,只能运⾏在⼀个进程上下⽂或者中断上下⽂⾥。
中断(interrupt)
中断可以发⽣在任务的指令过程中,如果中断处于使能,会从任务所处的进程上下⽂切换到中断上下⽂,在中断上下⽂中进⾏所谓的中断处理(ISR)。
内核中使⽤local_irq_disable()或者local_irq_save(&flags)来去使能中断。两者的区别是后者会将当前的中断使能状态先保存到flags中。
相反,内核使⽤local_irq_enale()来⽆条件的使能中断,⽽使⽤local_irq_restore(&flags)来恢复之前的中断状态。
⽆论是开中断还是关中断的函数都有local前缀, 这表⽰开关中断的只在当前CPU⽣效。
内核态抢占(preempt)
抢占,通俗的理解就是内核调度时,⾼优先级的任务从低优先的任务中抢到CPU的控制权,开始运⾏,其中⼜分为⽤户态抢占和内核态抢占, 本⽂需要关⼼的是内核态抢占。
早期版本(⽐2.6更早的)的内核还是⾮抢占式内核,也就是说当⾼优先级任务就绪时,除⾮低优先级任务主动放弃CPU(⽐如阻塞或者主动调⽤Schedule触发调度),否则⾼优先级任务是没有机会运⾏的。
⽽在此之后,内核可配置为抢占式内核(默认),在⼀些时机(⽐如说中断处理结束,返回内核空间时),会触发重新调度,此时⾼优先级的任务可以抢占原来占⽤CPU的低优先级任务。
需要特别指出的是,抢占同样需要中断处于打开状态!
void __sched notrace preempt_schedule(void)
{
struct thread_info *ti = current_thread_info();
风唐诗李峤/*
* If there is a non-zero preempt_count or interrupts are disabled,
* we do not want to preempt the current task. Just return..
*/
if (likely(ti->preempt_count || irqs_disabled()))
return;
上⾯代码中的preempt_count表⽰当前任务是否可被抢占,0表⽰可以被抢占,⽽⼤于0表⽰不可以。⽽irqs_disabled⽤来看中断是否关闭。
党的组词内核中使⽤preemt_disbale()来禁⽌抢占,使⽤preempt_enable()来使能可抢占。
单处理器上临界区问题
对于单处理器来说,由于任何⼀个时刻只会有⼀个执⾏单元,因此不存在多个执⾏单元同时访问临界区的情况。但是依然存在下⾯的情形需要保护
Ca 1 任务上下⽂抢占
低优先级任务A进⼊临界区,但此时发⽣了调度(⽐如发⽣了中断, 然后从中断中返回),⾼优先级任务B开始运⾏访问临界区。
解决⽅案:进⼊临界区前禁⽌抢占就好了。这样即使发⽣了中断,中断返回也只能回到任务A.
Ca 2 中断上下⽂抢占
任务A进⼊临界区,此时发⽣了中断,中断处理函数中也去访问修改临界区。当中断处理结束时,返回任务A的上下⽂,但此时临界区已经变了!
解决⽅案:进⼊临界区前禁⽌中断(顺便说⼀句,这样也顺便禁⽌了抢占)
Ca 3 多处理器上临界区问题
除了单处理器上的问题之外,多处理上还会⾯临⼀种需要保护的情形
其他CPU访问
任务A运⾏在CPU_a上,进⼊临界区前关闭了中断(本地),⽽此时运⾏在CPU_b上的任务B还是可以进⼊临界区!没有⼈能限制它
解决⽅案:任务A进⼊临界区前持有⼀个互斥结构,阻⽌其他CPU上的任务进⼊临界区,直到任务A退出临界区,释放互斥结构。
这个互斥结构就是⾃旋锁的来历。所以本质上,⾃旋锁就是为了针对SMP体系下的同时访问临界区⽽发明的!
内核中的⾃旋锁实现
接下来,我们来看⼀下内核中的⾃旋锁是如何实现的,我的内核版本是4.4.0
定义
内核使⽤spinlock结构表⽰⼀个⾃旋锁,如果不开调试信息的话,这个结构就是⼀个·raw_spinlock·:
typedef struct spinlock {
union {
环境污染作文struct raw_spinlock rlock;
// code omitted
};
} spinlock_t;
将raw_spinlock这个结构展开, 可以看到这是⼀个体系相关的arch_spinlock_t结构
typedef struct raw_spinlock {
重力的施力物体arch_spinlock_t raw_lock;
// code omitted
} raw_spinlock_t;
本⽂只关⼼常见的x86_64体系来说,这种情况下上述结构可展开为
typedef struct qspinlock {
atomic_t val;
} arch_spinlock_t;
上⾯的结构是SMP上的定义,对于UP,arch_spinlock_t就是⼀个空结构
typedef struct { } arch_spinlock_t;
啊,⾃旋锁就是⼀个原⼦变量(修改这个变量会LOCK总线,因此可以避免多个CPU同时对其进⾏修改)
API
内核使⽤spin_lock_init来进⾏⾃旋锁的初始化
# define raw_spin_lock_init(lock) \
do { *(lock) = __RAW_SPIN_LOCK_UNLOCKED(lock); } while (0)
#define spin_lock_init(_lock) \
do { \
spinlock_check(_lock); \
raw_spin_lock_init(&(_lock)->rlock); \
} while (0)
最终val会设置为0 (对于UP,不存在这个赋值)
内核使⽤spin_lock、spin_lock_irq或者spin_lock_irqsave完成加锁操作;使⽤spin_unlock、spin_unlock_irq或者spin_unlock_irqsave完成对应的解锁。spin_lock / spin_unlock
static inline void spin_lock(spinlock_t *lock)
{
raw_spin_lock(&lock->rlock);
}
对于UP,raw_spin_lock最后会展开为_LOCK
# define __acquire(x) (void)0
#define __LOCK(lock) \
do { preempt_disable(); __acquire(lock); (void)(lock); } while (0)
可以看到,它就是单纯地禁⽌抢占。这是上⾯Ca 1的解决办法
⽽对于SMP, raw_spin_lock会展开为
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
这⾥同样会禁⽌抢占,然后由于spin_acquire在没设置CONFIG_DEBUG_LOCK_ALLOC时是空操作, 所以关键的语句是最后⼀句,将其展开后是#define LOCK_CONTENDED(_lock, try, lock) \
lock(_lock)
所以,真正⽣效的是
static inline void do_raw_spin_lock(raw_spinlock_t *lock) __acquires(lock)
{
__acquire(lock);
arch_spin_lock(&lock->raw_lock);
}
__acquire并不重要。⽽arch_spin_lock定义在include/asm-generic/qspinlock.h.这⾥会检查val,如果当前锁没有被持有(值为0),那么就通过原⼦操作将其修改为1并返回。
否则就调⽤queued_spin_lock_slowpath⼀直⾃旋。
#define arch_spin_lock(l) queued_spin_lock(l)
static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
u32 val;
val = atomic_cmpxchg(&lock->val, 0, _Q_LOCKED_VAL);
if (likely(val == 0))
return;
queued_spin_lock_slowpath(lock, val);
}
以上就是spin_lock()的实现过程,可以发现除了我们熟知的等待⾃旋操作之外,它会在之前先调⽤preempt_disable禁⽌抢占,不过它并没有禁⽌中断,也就是说,它可以解决前⾯说的Ca 1和Ca 3
桂花的气味
但Ca 2还是有问题!
使⽤这种⾃旋锁加锁⽅式时,如果本地CPU发⽣了中断,在中断上下⽂中也去获取该⾃旋锁,这就会导致死锁
六年级写景作文因此,使⽤spin_lock()需要保证知道该锁不会在该CPU的中断中使⽤(其他CPU的中断没问题)
解锁时成对使⽤的spin_unlock基本就是加锁的逆向操作,在设置了val重新为0之后,使能抢占。
static inline void __raw_spin_unlock(raw_spinlock_t *lock)
{
spin_relea(&lock->dep_map, 1, _RET_IP_);
do_raw_spin_unlock(lock);
preempt_enable();
}
spin_lock_irq / spin_unlock_irq
这⾥我们就只关注SMP的情形了,相⽐之前的spin_lock中调⽤__raw_spin_lock, 这⾥多出的⼀个操作的就是禁⽌中断。
static inline void __raw_spin_lock_irq(raw_spinlock_t *lock)
{
local_irq_disable(); // 多了⼀个中断关闭
preempt_disable();
荷兰猪吃什么spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
前⾯说过,实际禁⽌中断的时候也就不会发⽣抢占了,那么这⾥其实使⽤preemt_disable禁⽌抢占是个有点多余的动作。
关于这个问题,可以看以下⼏个连接的讨论
对于的解锁操作是spin_unlock_irq会调⽤__raw_spin_unlock_irq。相⽐前⼀种实现⽅式,多了⼀个local_irq_enable
static inline void __raw_spin_unlock_irq(raw_spinlock_t *lock)
{
spin_relea(&lock->dep_map, 1, _RET_IP_);
do_raw_spin_unlock(lock);
local_irq_enable();
履行工作职责
preempt_enable();
}
这种⽅式也就解决了Ca 2
spin_lock_irqsave / spin_unlock_irqsave
spin_lock_irq还有什么遗漏吗?它没有遗漏,但它最后使⽤local_irq_enable打开了中断,如果进⼊临界区前中断本来是关闭,那么通过这⼀进⼀出,中断竟然变成打开的了!这显然不合适!(因为会出现嵌套调⽤spin_)
因此就有了spin_lock_irqsave和对应的spin_unlock_irqsave.它与上⼀种的区别就在于加锁时将中断使能状态保存在了flags
static inline unsigned long __raw_spin_lock_irqsave(raw_spinlock_t *lock)
{
unsigned long flags;
local_irq_save(flags); // 保存中断状态到flags
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
do_raw_spin_lock_flags(lock, &flags);
return flags;
}
⽽在对应的解锁调⽤时,中断状态进⾏了恢复,这样就保证了在进出临界区前后,中断使能状态是不变的。static inline void __raw_spin_unlock_irqrestore(raw_spinlock_t *lock,
unsigned long flags)
{
spin_relea(&lock->dep_map, 1, _RET_IP_);
do_raw_spin_unlock(lock);
local_irq_restore(flags); // 从 flags 恢复
preempt_enable();
}
总结
内核⾃旋锁的主要⽤于SMP系统上的临界区保护,并且在UP系统上也有简化的实现
内核⾃旋锁与抢占和中断的关系密切
内核⾃旋锁在内核有多个API,实际使⽤时可以灵活使⽤。