linux 内核 --- 自旋锁(spinlock_t)
定义
自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的,自旋锁的效率远高于互斥锁。
信号量和读写信号量适合于保持时间较长的情况,它们会导致调用者睡眠,因此只能在进程上下文使用(_trylock的变种能够在中断上下文使用),而自旋锁适合于保持时间非常短的情况,它可以在任何上下文使用。如果被保护的共享资源只在进程上下文访问,使用信号量保护该共享资源非常合适,如果对共享资源的访问时间非常短,自旋锁也可以。但是如果被保护的共享资源需要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必须使用自旋锁。
实现
传统的自旋锁本质上用一个整数来表示,值为1代表锁未被占用, 为0或者为负数表示被占用,传统自旋锁已不再使用。
Linux 内核 2.6.25 版本中引入了排队自旋锁:谁先等待锁,谁先获得锁。所以 linux 的自旋锁就是排队自旋锁(ticket spinlock),也叫票锁。
票锁原理:票锁在加锁时当前线程会预先原子性的拿到一个逐步递增且唯一的排队服务号,只有当前票锁的服务票号和自己拿到的排队服务号一致时才认为加锁成功。而在解锁时则将当前票锁的服务票号递增,得以让下一个加锁的线程获得锁。
有关结构体定义如下:
/* Non PREEMPT_RT kernels map spinlock to raw_spinlock */ typedef struct spinlock { union { struct raw_spinlock rlock; #ifdef CONFIG_DEBUG_LOCK_ALLOC # define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map)) struct { u8 __padding[LOCK_PADSIZE]; struct lockdep_map dep_map; }; #endif }; } spinlock_t;
typedef struct raw_spinlock { arch_spinlock_t raw_lock; #ifdef CONFIG_DEBUG_SPINLOCK unsigned int magic, owner_cpu; void *owner; #endif #ifdef CONFIG_DEBUG_LOCK_ALLOC struct lockdep_map dep_map; #endif } raw_spinlock_t;
typedef struct { union { u32 slock; struct __raw_tickets { #ifdef __ARMEB__ u16 next; u16 owner; #else u16 owner; u16 next; #endif } tickets; }; } arch_spinlock_t;
自旋锁类型 spinlock_t 和 raw_spinlock_t 的区别
在2.6.33之后的版本,内核加入了raw_spin_lock系列API,使用方法和spin_lock系列一模一样,只是参数由 spinlock_t 变为了 raw_spinlock_t。而且在内核的主线版本中,spin_lock系列只是简单地调用了raw_spin_lock系列的函数,但内核的代码却是有的地方使用spin_lock,有的地方使用raw_spin_lock。是不是很奇怪?要解答这个问题,我们要回到2004年,MontaVista Software, Inc的开发人员在邮件列表中提出来一个Real-Time Linux Kernel的模型,旨在提升Linux的实时性,之后Ingo Molnar很快在他的一个项目中实现了这个模型,并最终产生了一个Real-Time preemption的patch。
该模型允许在临界区中被抢占,而且申请临界区的操作可以导致进程休眠等待,这将导致自旋锁的机制被修改,由原来的整数原子操作变更为信号量操作。当时内核中已经有大约10000处使用了自旋锁的代码,直接修改spin_lock将会导致这个patch过于庞大,于是,他们决定只修改哪些真正不允许抢占和休眠的地方,而这些地方只有100多处,这些地方改为使用raw_spin_lock,但是,因为原来的内核中已经有raw_spin_lock这一名字空间,用于代表体系相关的原子操作的实现,于是linus本人建议:
把原来的raw_spin_lock改为arch_spin_lock;
把原来的spin_lock改为raw_spin_lock;
实现一个新的spin_lock;
写到这里不知大家明白了没?对于2.6.33和之后的版本,我的理解是:
尽可能使用spin_lock;
绝对不允许被抢占和休眠的地方,使用raw_spin_lock,否则使用spin_lock;
如果你的临界区足够小,使用raw_spin_lock;
对于没有打上Linux-RT(实时Linux)的patch的系统,spin_lock只是简单地调用raw_spin_lock,实际上他们是完全一样的,如果打上这个patch之后,spin_lock会使用信号量完成临界区的保护工作,带来的好处是同一个CPU可以有多个临界区同时工作,而原有的体系因为禁止抢占的原因,一旦进入临界区,其他临界区就无法运行,新的体系在允许使用同一个临界区的其他进程进行休眠等待,而不是强占着CPU进行自旋操作。写这篇文章的时候,内核的版本已经是3.3了,主线版本还没有合并Linux-RT的内容,说不定哪天就会合并进来,也为了你的代码可以兼容Linux-RT,最好坚持上面三个原则。
自旋锁,为什么要禁止抢占?
以linux为例,如果允许抢占,线程1正在持有该锁,此时发生了schedule后线程2又去试图拿该锁,线程2就会自旋在那里,浪费CPU资源。
假如只有单核,把自旋锁的preempt_disable注释掉,即允许抢占,使用自旋锁会产生死锁么?
不会。线程1在执行临界区,被schedule出去,线程2试图获取该锁,线程2会自旋在那里(浪费CPU,不主动让出CPU),等到再次被调度到线程1并释放了该锁后,线程2才可以继续往下跑。
自旋锁临界区为什么不允许sleep(使用会schedule类函数)?
线程1在执行临界区,此时该CPU禁止抢占,如果调用sleep主动schedule出去后,该CPU就永远回不来了,此时如果线程2试图获取该锁,就会发生死锁。(实际应该是发生kernel panic)
自旋锁只有在内核可抢占或SMP的情况下才真正需要,在单CPU且不可抢占的内核下,自旋锁的所有操作都是空操作,在单CPU且可抢占的内核下,自旋锁实际上只进行开启和关闭内核抢占的操作,如下:
include\linux\spinlock_api_up.h
#define _raw_spin_lock(lock) __LOCK(lock)
#define __LOCK(lock) \
do { preempt_disable(); ___LOCK(lock); } while (0)
#define ___LOCK(lock) \
do { __acquire(lock); (void)(lock); } while (0)
# define __acquire(x) (void)0
SMP多核情况下,除了关抢占,还需要用到独占的汇编指令操作变量,如下:
kernel\locking\spinlock.c include\linux\spinlock_api_smp.h
#define raw_spin_lock(lock) _raw_spin_lock(lock)
void __lockfunc _raw_spin_lock(raw_spinlock_t *lock) { __raw_spin_lock(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); } #define LOCK_CONTENDED(_lock, try, lock) \ lock(_lock) void do_raw_spin_lock(raw_spinlock_t *lock) { debug_spin_lock_before(lock); arch_spin_lock(&lock->raw_lock); mmiowb_spin_lock(); debug_spin_lock_after(lock); } static inline void arch_spin_lock(arch_spinlock_t *lock) { unsigned long tmp; u32 newval; arch_spinlock_t lockval; prefetchw(&lock->slock); __asm__ __volatile__( "1: ldrex %0, [%3]\n" " add %1, %0, %4\n" " strex %2, %1, [%3]\n" " teq %2, #0\n" " bne 1b" : "=&r" (lockval), "=&r" (newval), "=&r" (tmp) : "r" (&lock->slock), "I" (1 << TICKET_SHIFT) : "cc"); while (lockval.tickets.next != lockval.tickets.owner) { wfe(); lockval.tickets.owner = READ_ONCE(lock->tickets.owner); } smp_mb(); }
如何使用自旋锁函数
获得自旋锁和释放自旋锁有好几个版本,因此让读者知道在什么样的情况下使用什么版本的获得和释放锁的宏是非常必要的。
线程运行过程中是会被中断或软中断打断
被保护的共享资源只在进程上下文访问和软中断上下文访问
当在进程上下文访问共享资源时,可能被软中断打断,从而可能进入软中断上下文来对被保护的共享资源访问,因此对于这种情况,对共享资源的访问必须使用spin_lock_bh和spin_unlock_bh来保护(关闭软中断)。当然使用spin_lock_irq和spin_unlock_irq以及spin_lock_irqsave和spin_unlock_irqrestore也可以,它们失效了本地硬中断(本地指当前CPU中断),失效硬中断隐式地也失效了软中断。但是使用spin_lock_bh和spin_unlock_bh是最恰当的,它比其他两个快。
被保护的共享资源只在进程上下文和tasklet或timer上下文访问
应该使用与上面情况相同的获得和释放锁的宏,因为tasklet和timer是用软中断实现的。
spinlock用在进程上下文和中断
线程A中如果调用了spin_lock(&lock)然后进入临界区,此时来了一个中断(interrupt),该中断也正好运行在和线程A相同的CPU核上,并且在该中断处理程序中恰巧也会spin_lock(&lock)试图获取同一个锁。由于是在同一个CPU核上被中断,线程A无法运行转而运行中断程序,但是中断处理程序无法获得锁,会不停的忙等占用这个CPU核,schedule()线程调度就无法再调度线程A运行(线程A是在这个CPU核上被中断中断,还没来得及保存线程上下文,所以线程A必须回到这个CPU核运行,无法调度到其他核运行),这样就导致了死锁!但是如果该中断处理程序运行在不同的CPU核上就不会触发死锁。因为在不同的CPU核上出现中断不会暂停线程A的执行。所以在使用自旋锁时要明确知道该锁不会在中断处理程序中使用。如果有,那就需要使用spinlock_irq_save,该函数即会关抢占,也会关本地中断(本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来保护,不必使用_bh版本,因为当tasklet或timer运行时,不可能有其他tasklet或timer在当前CPU上运行,即tasklet A不能被tasklet B打断。
不足
随着计算机硬件技术的发展,CPU相对于存储器件的运算速度优势越来越大。在这种背景下,获取基于 counter(需要访问存储器件)的锁(例如spinlock,rwlock)的机制开销越来越明显。因此,那些基于一个multi-processor之间的共享counter的锁机制已经不能满足性能要求,在这种情况下,RCU机制应运而生。