当前位置: 首页 > Linux

面试必备的进程同步机制——内核自旋锁

时间:2023-04-07 01:23:52 Linux

进程(线程)之间的同步机制是面试中经常遇到的问题,所以打算用一个系列来梳理一下用户态和内核态的各种同步机制。本文从内核空间的一种基本同步机制---自旋锁入手。什么是自旋锁?自旋锁是一个二态原子(atomic)变量:unlockedlocked。临界区(CriticalSection),它首先需要自旋锁当前处于解锁状态,然后它会尝试获取(acquire)自旋锁(改变这个变量的状态为locked),如果有另一个任务B也想访问这个临界区,所以它必须等到任务A释放(release)自旋锁。在此之前,任务B会在这里等待并尝试获取(acquire),也就是我们这里所说的自旋。自旋锁有什么特点如果被问到这个问题,很多人根据上面的定义或许可以总结出:“保护临界区”“等待忙到别人释放锁”“适合等待时间”在一个很短的场景中“说错了吗?当然不是!而这些也确实是自旋锁的特性,所以更多?几个基本概念内核为什么要引入自旋锁?在回答这个问题之前,我想简单介绍一下以下基本概念:UP&SMPUP表示单处理器,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触发调度),高优先级Level任务没有机会运行。之后可以配置内核为抢占式内核(默认),会在某个时机触发重新调度(比如中断处理结束返回内核空间时),此时高优先级的任务可以抢占原来的中央处理器。低优先级任务。需要指出的是,抢占也是需要打开中断的!void__schednotracepreempt_schedule(void){structthread_info*ti=current_thread_info();/**如果有一个非零的preempt_count或中断被禁用,*我们不想抢占当前任务。只是返回..*/if(likely(ti->preempt_count||irqs_disabled()))return;上面代码中的preempt_count表示当前任务是否可以被抢占,0表示可以抢占,大于0表示不可以。而irqs_disabled是用来查看中断是否关闭的。在内核中使用preemt_disbale()关闭抢占,使用preempt_enable()开启抢占。单处理器上的临界区问题对于单处理器来说,由于在任何时候都只有一个执行单元,所以不存在多个执行单元同时访问临界区的情况。但是还有以下几种情况需要保护Case1的任务上下文,抢占低优先级的任务A进入临界区,但此时发生调度(比如发生中断,然后从返回中断),高优先级任务B开始运行并访问临界区。解决方案:在进入临界区之前禁用抢占即可。这样即使发生中断,中断返回也只能返回任务A。Case2中断上下文抢占任务A进入临界区。这时中断发生,中断处理函数也对临界区进行了访问和修改。当中断处理结束时,返回了任务A的上下文,但是此时临界区发生了变化!解决方法:在进入临界区之前禁用中断(顺便说一句,这也禁止抢占)Case3多处理器上的临界区问题除了单处理器上的问题,多处理器还会面临需要保护其他CPU访问的情况任务A运行在CPU_a上,在进入临界区之前关闭了中断(本地),但是此时运行在CPU_b上的任务B仍然可以进入临界区!没有人可以限制解决方法:任务A在进入临界区之前持有一个互斥结构,阻止其他CPU上的任务进入临界区,直到任务A退出临界区释放互斥结构。这个互斥结构就是自旋锁的由来。所以本质上,自旋锁是为了在SMP系统下同时访问临界区而发明的!自旋锁在内核中的实现接下来我们看一下自旋锁在内核中是如何实现的。我的内核版本是4.4.0。定义内核使用自旋锁结构来表示一个自旋锁。如果没有启用调试信息,这个结构就是一个raw_spinlock:typedefstructspinlock{union{structraw_spinlockrlock;//省略代码};}spinlock_t;展开raw_spinlock结构体,可以看到这是一个系统相关的arch_spinlock_t结构体typedefstructraw_spinlock{arch_spinlock_traw_lock;//省略代码}raw_spinlock_t;本文只关心常见的x86_64系统,此时上面的结构体可以展开成typedefstructqspinlock{atomic_tval;}arch_spinlock_t;上面的结构是在SMP上定义的,对于UP来说,arch_spinlock_t是一个空结构typedefstruct{}arch_spinlock_t;啊,自旋锁是一个原子变量(修改这个变量会LOCK总线,所以可以避免多个CPU同时修改它)API核心使用spin_lock_init进行自旋锁初始化#defineraw_spin_lock_init(lock)\do{*(lock)=__RAW_SPIN_LOCK_UNLOCKED(锁);}while(0)#definespin_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_unlockstaticinlinevoidspin_lock(spinlock_t*lock){raw_spin_lock(&lock->rlock);}对于UP,raw_spin_lock最终会展开为_LOCK#define__acquire(x)(void)0#define__LOCK(锁)\做{preempt_disable();__获取(锁定);(无效)(锁定);}while(0)可以看出,它只是禁止抢占。这是上述情况1和SMP的解决方案,raw_spin_lock将扩展为staticinlinevoid__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);}这里也是禁止抢占的,因为在CONFIG_DEBUG_LOCK_ALLOC没有设置的情况下spin_acquire是一个no-op,关键语句是最后一句,就是#defineLOCK_CONTENDED(_lock,try,lock)\lock(_lock)所以,真正生效的是staticinlinevoiddo_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保持自旋。#definearch_spin_lock(l)queued_spin_lock(l)static__always_inlinevoidqueued_spin_lock(structqspinlock*lock){u32val;val=atomic_cmpxchg(&lock->val,0,_Q_LOCKED_VAL);如果(可能(val==0))返回;queued_spin_lock_slowpath(lock,val);}以上就是spin_lock()的实现过程。可以发现,除了众所周知的等待自旋操作,之前还会调用preempt_disable来禁止抢占,但是并没有禁止中断,也就是说可以解决前面提到的Case1和Case3,但是Case2还有问题!在使用这种自旋锁加锁方式时,如果本地CPU中断,在中断上下文中也获取自旋锁,就会造成死锁。因此,使用spin_lock()需要保证锁不会在解锁时成对使用的spin_unlock用于CPU的中断(其他CPU的中断没问题),基本上是加锁的逆操作。再次将val设置为0后,启用抢占。静态内联void__raw_spin_unlock(raw_spinlock_t*lock){spin_release(&lock->dep_map,1,_RET_IP_);do_raw_spin_unlock(锁);preempt_enable();}spin_lock_irq/spin_unlock_irq这里我们只关注SMP的情况,相比之前的spin_lock__raw_spin_lock被调用,这里多了一个操作就是禁止中断。静态内联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上的讨论,LinuxDOC上的回答。对于解锁操作,spin_unlock_irq会调用__raw_spin_unlock_irq。与之前的实现相比,多了一个local_irq_enablestaticinlinevoid__raw_spin_unlock_irq(raw_spinlock_t*lock){spin_release(&lock->dep_map,1,_RET_IP_);do_raw_spin_unlock(锁);local_irq_enable();preempt_enable();}这样也解决了Case2spin_lock_irqsave/spin_unlock_irqsavespin_lock_irq是不是少了什么?它没有错过,但它最终使用local_irq_enable打开了中断。如果说在进入临界区之前中断本来是关闭的,那么通过这次进入和退出,中断就开启了!这显然是不合适的!于是就有了spin_lock_irqsave和对应的spin_unlock_irqsave。它和上一个的区别是中断使能状态保存在flagsstaticinlineunsignedlong__raw_spin_lock_irqsave(raw_spinlock_t*lock){unsignedlongflags;local_irq_save(标志);//保存中断状态到flagspreempt_disable();spin_acquire(&lock->dep_map,0,0,_RET_IP_);do_raw_spin_lock_flags(锁,&flags);returnflags;}当相应的unlock调用时,恢复中断状态,这样可以保证进入和退出临界区前后中断使能状态不变。staticinlinevoid__raw_spin_unlock_irqrestore(raw_spinlock_t*lock,unsignedlongflags){spin_release(&lock->dep_map,1,_RET_IP_);do_raw_spin_unlock(锁);local_irq_restore(标志);//从标志中恢复preempt_enable();锁在SMP系统上主要用于临界区保护,在UP系统上也有简化实现。内核自旋锁与抢占和中断密切相关。内核自旋锁在内核中有多个API,在实际使用中可以灵活使用。.