自旋锁的实现是为了保护一段短小的临界区操作代码,保证这个临界区的操作是原子的,从而避免并发的竞争。在 Linux 内核中,自旋锁通常用于包含内核数据结构的操作,你可以看到在许多内核数据结构中都嵌入有 spinlock,这些大部分就是用于保证它自身被操作的原子性,在操作这样的结构体时都经历这样的过程:上锁-操作-解锁。如果内核控制路径发现自旋锁“开着”(可以获取),就获取锁并继续自己的执行。
相反,如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径“锁着”,就在原地“旋转”,反复执行一条紧凑的循环检测指令,直到锁被释放。 自旋锁是循环检测“忙等”,即等待时内核无事可做(除了浪费时间),进程在CPU上保持运行,所以它保护的临界区必须小,且操作过程必须短。不过,自旋锁通常非常方便,因为很多内核资源只锁极短的时间片段,所以等待自旋锁的释放不会消耗太多CPU的时间。
1.自旋锁需要做的工作
从保证临界区访问原子性的目的来考虑,自旋锁应该阻止在代码运行过程中出现的任何并发干扰。这些“干扰”包括:
- 中断,包括硬件中断和软件中断 (仅在中断代码可能访问临界区时需要) 这种干扰存在于任何系统中,一个中断的到来导致了中断例程的执行,如果在中断例程中访问了临界区,原子性就被打破了。所以如果在某种中断例程中存在访问某个临界区的代码,那么就必须用spinlock保护。对于不同的中断类型(硬件中断和软件中断)对应于不同版本的自旋锁实现,其中包含了中断禁用和开启的代码。但是如果你保证没有中断代码会访问临界区,那么使用不带中断禁用的自旋锁API即可。
- 内核抢占(仅存在于可抢占内核中) 在2.6以后的内核中,支持内核抢占,并且是可配置的。这使UP系统和SMP类似,会出现内核态下的并发。这种情况下进入临界区就需要避免因抢占造成的并发,所以解决的方法就是在加锁时禁用抢占(preempt_disable(); ),在开锁时开启抢占(preempt_enable();注意此时会执行一次抢占调度)
- 其他处理器对同一临界区的访问 (仅SMP系统) 在SMP系统中,多个物理处理器同时工作,导致可能有多个进程物理上的并发。这样就需要在内存加一个标志,每个需要进入临界区的代码都必须检查这个标志,看是否有进程已经在这个临界区中。这种情况下检查标志的代码也必须保证原子和快速,这就要求必须精细地实现,正常情况下每个构架都有自己的汇编实现方案,保证检查的原子性。
根据上的介绍,我们很容易知道自旋锁的操作包括:
- 中断控制(仅在中断代码可能访问临界区时需要)
- 抢占控制(仅存在于可抢占内核中需要)
- 自旋锁标志控制 (仅SMP系统需要)
中断控制是按代码访问临界区的不同而在编程时选用不同的变体,有些API中有,有些没有。而抢占控制和自旋锁标志控制依据内核配置(是否支持内核抢占)和硬件平台(是否为SMP)的不同而在编译时确定。如果不需要,相应的控制代码就编译为空函数。 对于非抢占式内核,由自旋锁所保护的每个临界区都有禁止内核抢占的API,但是为空操作。由于UP系统不存在物理上的并行,所以可以阉割掉自旋的部分,剩下抢占和中断操作部分即可。
有些人会以为自旋锁的自旋检测可以用for实现,这种想法“Too young, too simple, sometimes naive”!你可以在理论上用C去解释,但是如果用for,起码会有如下两个问题:
- 你如何保证在SMP下其他处理器不会同时访问同一个的标志呢?(也就是标志的独占访问)
- 必须保证每个处理器都不会去读取高速缓存而是真正的内存中的标志(可以实现,编程上可以用volitale)要根本解决这个问题,需要在芯片底层实现物理上的内存地址独占访问,并且在实现上使用特殊的汇编指令访问。请看参考资料中对于自旋锁的实现分析。以arm为例,从存在SMP的ARM构架指令集开始(V6、V7),采用LDREX和STREX指令实现真正的自旋等待。
2.自旋锁变体的使用规则
不论是抢占式UP、非抢占式UP还是SMP系统,只要在某类中断代码可能访问临界区,就需要控制中断,保证操作的原子性。所以这个和模块代码中临界区的访问还有关系,是否可能在中断中操作临界区,只有程序员才知道。所以自旋锁API中有针对不同中断类型的自旋锁变体:不会在任何中断例程中操作临界区
static inline void spin_lock(spinlock_t *lock)
static inline void spin_unlock(spinlock_t *lock)
如果在软件中断中操作临界区:
static inline void spin_lock_bh(spinlock_t *lock)
static inline void spin_unlock_bh(spinlock_t *lock)
bh代表bottom half,也就是中断中的底半部,因内核中断的底半部一般通过软件中断(tasklet等)来处理而得名。
如果在硬件中断中操作临界区:
static inline void spin_lock_irq(spinlock_t *lock)
static inline void spin_unlock_irq(spinlock_t *lock)
如果在控制硬件中断的时候需要同时保存中断状态:
spin_lock_irqsave(lock, flags)
static inline void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags)
这些情况描诉似乎有点简单,我在网上找到了一篇使用规则((转)自旋锁(spinlock) 解释得经典,透彻),非常详细。我稍作修改,转载如下:
获得自旋锁和释放自旋锁有好几个版本,因此让读者知道在什么样的情况下使用什么版本的获得和释放锁的宏是非常必要的。
如果被保护的共享资源只在进程上下文访问和软中断(包括tasklet、timer)上下文访问,那么当在进程上下文访问共享资源时,可能被软中断打断,从而可能进入软中断上下文来对被保护的共享资源访问,因此对于这种情况,对共享资源的访问必须使用spin_lock_bh和spin_unlock_bh来保护。当然使用spin_lock_irq和spin_unlock_irq以及spin_lock_irqsave和spin_unlock_irqrestore也可以,它们失效了本地硬中断,失效硬中断隐式地也失效了软中断。但是使用spin_lock_bh和spin_unlock_bh是最恰当的,它比其他两个快。
如果被保护的共享资源只在两个或多个tasklet或timer上下文访问,那么对共享资源的访问仅需要用spin_lock和spin_unlock来保护,不必使用_bh版本,因为当tasklet或timer运行时,不可能有其他tasklet或timer在当前CPU上运行。
如果被保护的共享资源只在一个tasklet或timer上下文访问,那么不需要任何自旋锁保护,因为同一个tasklet或timer只能在一个CPU上运行,即使是在SMP环境下也是如此。实际上tasklet在调用tasklet_schedule标记其需要被调度时已经把该tasklet绑定到当前CPU,因此同一个tasklet决不可能同时在其他CPU上运行。timer也是在其被使用add_timer添加到timer队列中时已经被帮定到当前CPU,所以同一个timer绝不可能运行在其他CPU上。当然同一个tasklet有两个实例同时运行在同一个CPU就更不可能了。
如果被保护的共享资源只在一个软中断(tasklet和timer除外)上下文访问,那么这个共享资源需要用spin_lock和spin_unlock来保护,因为同样的软中断可以同时在不同的CPU上运行。
如果被保护的共享资源在两个或多个软中断上下文访问,那么这个共享资源当然更需要用spin_lock和spin_unlock来保护,不同的软中断能够同时在不同的CPU上运行。
如果被保护的共享资源在软中断(包括tasklet和timer)或进程上下文和硬中断上下文访问,那么在软中断或进程上下文访问期间,可能被硬中断打断,从而进入硬中断上下文对共享资源进行访问,因此,在进程或软中断上下文需要使用spin_lock_irq和spin_unlock_irq来保护对共享资源的访问。
而在中断处理句柄中使用什么版本,需依情况而定,如果只有一个中断处理句柄访问该共享资源,那么在中断处理句柄中仅需要spin_lock和spin_unlock来保护对共享资源的访问就可以了。因为在执行中断处理句柄期间,不可能被同一CPU上的软中断或进程打断。
但是如果有不同的中断处理句柄访问该共享资源,那么需要在中断处理句柄中使用spin_lock_irq和spin_unlock_irq来保护对共享资源的访问。
在使用spin_lock_irq和spin_unlock_irq的情况下,完全可以用spin_lock_irqsave和spin_unlock_irqrestore取代,那具体应该使用哪一个也需要依情况而定,如果可以确信在对共享资源访问前中断是使能的,那么使用spin_lock_irq更好一些。因为它比spin_lock_irqsave要快一些,但是如果你不能确定是否中断使能,那么使用spin_lock_irqsave和spin_unlock_irqrestore更好,因为它将恢复访问共享资源前的中断标志而不是直接使能中断。
当然,有些情况下需要在访问共享资源时必须中断失效,而访问完后必须中断使能,这样的情形使用spin_lock_irq和spin_unlock_irq最好。
spin_lock用于阻止在不同CPU上的执行单元对共享资源的同时访问以及不同进程上下文互相抢占导致的对共享资源的非同步访问,而中断失效和软中断失效却是为了阻止在同一CPU上软中断或中断对共享资源的非同步访问。
3.自旋锁使用及注意事项
自旋锁使用如下;
// 1.分配自旋锁
spinlock_t lock;
// 2.初始化自旋锁
spin_lock_init(&lock);
// 3.访问临界区之前获取锁:
spin_lock(&lock); // 获取自旋锁,立即返回,如果没有获取锁,将进行忙等待
或者
spin_trylock(&lock); // 获取锁,返回true,否则返回false,所以这个函数一定要对返回值进行判断!
// 4.访问临界区
// 5.释放自旋锁
spin_unlock(&lock);
自旋锁的注意事项:
- 自旋锁使CPU处于忙等状态,因此临界区执行时间应该尽量短;
- 自旋锁是不可重入的;
- 自旋锁保护的临界区不应该有睡眠操作:
1)对于开中断的自旋锁来说,睡眠操作可能发生如下两种情况:
a. 死锁:任务A获得自旋锁之后睡眠,接着又发生了中断,而中断处理程序内部又打算获取同一个自旋锁,则此时会发生自死锁 —— 自旋锁是不可重入的。
b. CPU浪费:倘若中断处理程序内部没有获取同一个自旋锁的操作,则理论上可以产生调度。假设进程B打算获取CPU的控制权,但由于此时是关抢占的(因为进程A还没有解自旋锁,此时依旧处于自旋锁的临界区中),导致进程B无法运行。也就是说CPU将无法运行任何程序,一直处于无事可做的状态,造成CPU的浪费。
2)对于顺带关中断的自旋锁来说,显而易见在临界区内使不能睡眠的,因为唤醒一个睡眠的进程依赖于调度器,而调度器是通过时钟中断来判断合适唤醒进程的,倘若在关闭中断的时候进程睡眠,则调度器将再也无法收到时钟中断(因为开中断的操作也是由该进程控制的),从而永远都无法唤醒睡眠的进程。也就是说该进程将处于睡死状态。
简单来说,自旋锁的初衷就是:在短期间内进行轻量级的锁定。一个被争用的自旋锁使得请求它的线程在等待锁重新可用的期间进行自旋(特别浪费处理器时间),所以自旋锁不应该被持有时间过长。如果需要长时间锁定的话, 最好使用信号量。