进程(线程)间的同步机制是面试时的常见问题,因此准备用一个系列来好好整理下用户态与内核态的各类同步机制。本文就之内核空间的一种基础同步机制---
自旋锁
开始好了
自旋锁就是一个二状态的原子(atomic
)变量:php
unlocked
locked
当任务A
但愿访问被自旋锁保护的临界区(Critical Section),它首先须要这个自旋锁当前处于unlocked
状态,而后它会去尝试获取(acquire
)这个自旋锁(将这个变量状态修改成locked
),linux
若是在这以后有另外一个任务B
一样但愿去访问这段这段临界区,那么它必需要等到任务A
释放(release
)掉自旋锁才行,在这以前,任务B
会一直等待此处,不段尝试获取(acquire
),也就是咱们说的自旋
在这里。面试
若是被问到这个问题,很多人可能根据上面的定义也能总结出来了:函数
说错了吗?固然没有!而且这些的确都是自旋锁的特色,那么更多呢 ?ui
为何内核须要引入自旋锁?回答这个问题以前我想先简单引入如下几个基本概念:atom
UP
表示单处理器,SMP
表示对称多处理器(多CPU
)。一个处理器就视为一个执行单元,在任何一个时刻,只能运行在一个进程上下文或者中断上下文里。spa
中断能够发生在任务的指令过程当中,若是中断处于使能,会从任务所处的进程上下文切换到中断上下文,在中断上下文中进行所谓的中断处理(ISR
)。.net
内核中使用 local_irq_disable()
或者local_irq_save(&flags)
来去使能中断。二者的区别是后者会将当前的中断使能状态先保存到flags
中。线程
相反,内核使用local_irq_enale()
来无条件的使能中断,而使用local_irq_restore(&flags)
来恢复以前的中断状态。3d
不管是开中断仍是关中断的函数都有local
前缀, 这表示开关中断的只在当前CPU
生效。
抢占
,通俗的理解就是内核调度时,高优先级的任务从低优先的任务中抢到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()
来使能可抢占。
对于单处理器来讲,因为任何一个时刻只会有一个执行单元,所以不存在多个执行单元同时访问临界区的状况。可是依然存在下面的情形须要保护
低优先级任务A
进入临界区,但此时发生了调度(好比发生了中断, 而后从中断中返回),高优先级任务B
开始运行访问临界区。
解决方案:进入临界区前禁止抢占就行了。这样即便发生了中断,中断返回也只能回到任务A
.
任务A
进入临界区,此时发生了中断,中断处理函数中也去访问修改临界区。当中断处理结束时,返回任务A
的上下文,但此时临界区已经变了!
解决方案:进入临界区前禁止中断(顺便说一句,这样也顺便禁止了抢占)
除了单处理器上的问题以外,多处理上还会面临一种须要保护的情形
任务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
同时对其进行修改)
内核使用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
完成对应的解锁。
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)
能够看到,它就是单纯地禁止抢占。这是上面Case 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
禁止抢占,不过它并无禁止中断,也就是说,它能够解决前面说的Case 1
和Case 3
但Case 2
仍是有问题!
使用这种自旋锁加锁方式时,若是本地CPU
发生了中断,在中断上下文中也去获取该自旋锁,这就会致使死锁
所以,使用spin_lock()须要保证知道该锁不会在该CPU
的中断中使用(其余CPU
的中断没问题)
解锁时成对使用的spin_unlock
基本就是加锁的逆向操做,在设置了val
从新为0
以后,使能抢占。
static inline void __raw_spin_unlock(raw_spinlock_t *lock) { spin_release(&lock->dep_map, 1, _RET_IP_); do_raw_spin_unlock(lock); preempt_enable(); }
这里咱们就只关注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
禁止抢占是个有点多余的动做。
关于这个问题,能够看如下几个链接的讨论
CU上的讨论
Stackoverflow上的回答
linux DOC
对于的解锁操做是spin_unlock_irq
会调用__raw_spin_unlock_irq
。相比前一种实现方式,多了一个local_irq_enable
static inline void __raw_spin_unlock_irq(raw_spinlock_t *lock) { spin_release(&lock->dep_map, 1, _RET_IP_); do_raw_spin_unlock(lock); local_irq_enable(); preempt_enable(); }
这种方式也就解决了Case 2
spin_lock_irq
还有什么遗漏吗?它没有遗漏,但它最后使用local_irq_enable
打开了中断,若是进入临界区前中断原本是关闭,那么经过这一进一出,中断居然变成打开的了!这显然不合适!
所以就有了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_release(&lock->dep_map, 1, _RET_IP_); do_raw_spin_unlock(lock); local_irq_restore(flags); // 从 flags 恢复 preempt_enable(); }
SMP
系统上的临界区保护,而且在UP
系统上也有简化的实现抢占
和中断
的关系密切API
,实际使用时能够灵活使用。