Linux内核源码阅读之自旋锁的作用及其实现

作用:
内核中的自旋锁的作用是保护一段临界区域的操作是独占的,不能因为多个CPU或者多个进程同时访问破坏数据结构。在单核系统和多核系统中自旋锁的实现有所不同。
多核系统:
对于多核系统,主要考虑一个cpu进入临界代码区域之后,其它cpu不能再次进入这个临界代码区域。
单核系统:
对于单核系统,主要的情景是一个进程进入了临界区域后,不能被其它进程抢占,如果被其他进程抢占,会导致进程抢占的cpu会再次进入这个临界代码区域。
单核系统自旋锁的实现:
对于单核系统,不存在多个cpu竞争的情况。内核中一段代码(进程上下文)进入临界区域后能够打断这个进程执行临界区域代码的只有两种情况。第一种是中断,也就是中断中也存在访问临界区域的操作,第二种情况就是系统开启了抢占。对于第一种情况,中断发生后,原来执行的进程被抢占,换入的进程进入了临界区域。对于中断进入临界区域的这种情况,本质上自旋锁是不能保护的。遇到这样的情况,进入临界区域前,系统直接关掉中断,不能使用自旋锁。对于第二种情况,自旋锁在单核系统上的实现实际上是关闭抢占,使得进入临界区域的进程能被中断打断,但是不能被抢占,也就避免了其它进程同时进入到临界区中。
使用方法
内核中提供的普通自旋锁API为spin_lock()和spin_unlock(),使用方法为:

        spin_lock();
        ...临界区...
        spin_unlock();

普通自旋锁的数据类型spinlock_t

typedef struct {
                raw_spinlock_t raw_lock;
        #ifdef  (CONFIG_PREEMPT) && defined(CONFIG_SMP)
                unsigned int break_lock;
        #endif
        #ifdef CONFIG_DEBUG_SPINLOCK
                unsigned int magic, owner_cpu;
                void *owner;
        #endif
        #ifdef CONFIG_DEBUG_LOCK_ALLOC
                struct lockdep_map dep_map;
        #endif
        } spinlock_t;

去掉编译配置选项之后,spinlock_t为空的数据类型,这是因为在单核系统中不需要对数据结构进行处理。
下面是spin_lock在单核系统上的实现,等同于禁止抢占操作:

#define spin_lock(lock)            _spin_lock(lock) 
#define _spin_lock(lock)            __LOCK(lock) 

#define __LOCK(lock) \ 
  do { preempt_disable(); __acquire(lock); (void)(lock); } while (0) 

# define __acquire(x) (void)0 

其中preempt_disable()的工作是关闭内核抢占,__acquire(lock)是空操作,(void)(lock)是简单的数据转型操作,防止编译器对lock未使用报警。看到这里,读者应该就明白了,在单处理器中spin_lock()所做的工作仅仅是关闭内核抢占而已,仅此而已,这就保证了在运行期间不会发生进程抢占,从而也就保证了临界区里的代码只有当前进程才会访问到,在当前进程释放临界区之前都不会有别的进程能够访问。
spin_unlock()的实现:

        #define spin_unlock(lock)   _spin_unlock(lock)
        #define _spin_unlock(lock)  __UNLOCK(lock)       
        #define __UNLOCK(lock) \
              do { preempt_enable(); __release(lock); (void)(lock); } while (0)  
        #define __release(x) (void)0

看懂了上面的spin_lock(),spin_unlock()所做的工作也就很清楚了。spin_unlock()和spin_lock()所做的工作是相反的,spin_lock()关闭了内核抢占,则spin_unlock()开启内核抢占。也就是说在关闭了内核抢占后,进程进入临界区,由于内核抢占已经关闭,因此当前进程不会被其他进程所抢占。完成相应任务后开启内核抢占,释放临界区,此时其他进程就可以抢占CPU从而访问临界区了。
也就是说,普通自旋锁执行过程就是关闭内核抢占->访问临界区代码->开启内核抢占。
普通自旋锁存在的风险
虽然普通自旋锁通过关闭内核抢占并独占CPU资源阻止了其余进程访问临界区资源,但是还有一种特殊的情况。关闭内核抢占只是阻止了其余进程对CPU的抢占,但是并不能阻止中断程序对CPU的抢占,如果中断服务程序想要访问临界区资源的话就有可能造成并发访问,导致中断结束后进程访问的资源被改变了,从而导致错误。为此,Linux内核提供了更加安全的自旋锁API:spin_lock_irqsave()和spin_unlock_irqrestore()。
spin_lock_irqsave()先将处理器状态指令寄存器IF的内容保存起来,然后通过cli指令关闭中断,然后再执行和spin_lock()相同的步骤。spin_unlock_irqrestore()先执行和spin_unlock()相同的步骤,即打开内核抢占,然后通过sti指令开启中断,最后之前保存的值恢复到处理器状态指令寄存器IF中。
运行由自旋锁保护的临界区代码时不允许系统进入睡眠状态,不仅临界区内的代码不允许进入睡眠状态,临界区内代码所调用的代码也不允许进入睡眠状态。首先,自旋锁一般只在需要短时间访问共享资源时才会使用,一般都是马上就能完成的任务,不需要进入睡眠等待资源。其次,进入临界区之后提供的中断和内核抢占都已经关闭,基于系统时钟中断的进程切换也就失效了,也就是说进入睡眠状态之后可能就会永远的睡下去了,因为没有激励信号来把进入睡眠的进程唤醒。但是有一个特例,那就是kmalloc函数,当分配失败时它可以进入睡眠状态。总而言之,运行由自旋锁保护的临界区代码时,不允许程序进入睡眠;同时临界区应该是短时间就可以完成的任务,因为在多处理器架构中自旋锁会进行忙等待,白白占用CPU资源,接下来就讲解多处理器架构中自旋锁的实现。
多处理器(SMP)普通自旋锁
之前讲过,自旋锁要保证任意时刻只有一个CPU运行在临界区内,同时运行在临界区内的CPU不允许进行进程切换。在单处理器中通过关闭内核抢占保证了进程对资源的访问不被打扰,在多处理器中情况就要麻烦一些了,因为还要应付来自其他处理器的干扰。在多处理器中也是通过关闭内核抢占来保证对临界区的访问不受运行在当前CPU上的其余进程的打扰,那么怎么应付来自其他处理器的干扰呢,带着这个问题让我们接着往下看。
数据结构
多处理器中spinlock_t的定义和单处理器中的一样,这里就不再赘述了,不同的是raw_spinlock_t不再是空数据结构了:

       typedef struct {
            unsigned int slock;
        } raw_spinlock_t;

由此可以看出,在多处理其中普通自旋锁其实是使用了一个整数作为计数器,自旋锁初始化的时候会将计数器的值设为1,表示自旋锁当前可用。下面来看自旋锁API的实现。
API的实现

       #define spin_lock(lock)   _spin_lock(lock)    //lock数据类型为*spinlock_t,虽然没有用到-_-。
        void __lockfunc _spin_lock(spinlock_t *lock)
        {
            preempt_disable();//关抢占
            spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);//空操作
            LOCK_CONTENDED(lock, _raw_spin_trylock, _raw_spin_lock);
        }

LOCK_CONTENDED是个宏定义,在当前lock为普通自旋锁时,会以lock为参数运行_raw_spin_lock()函数,_raw_spin_lock()定义如下:

    #define _raw_spin_lock(lock)  __raw_spin_lock(&(lock)->raw_lock);

__raw_spin_lock()的实现是跟体系结构相关的,下面来看看在x86里面的实现:

     static inline void __raw_spin_lock(raw_spinlock_t *lock)
        {
            asm volatile(
            "\n1:\t"
            LOCK_PREFIX " ; decl %0\n\t"
            "jns 2f\n"
            "3:\n"
            "rep;nop\n\t"
            "cmpl $0,%0\n\t"
            "jle 3b\n\t"
            "jmp 1b\n"
            "2:\t" : "=m" (lock->slock) : : "memory");
        }   

指令前缀LOCK_PREFIX表示执行这条指令时将总线锁住,不让其他处理器方位,以此来保证这条指令执行的“原子性”,%0表示lock->slock,第一句话表示将lock->slock减一。第二句话进行判断,如果减一之后大于或等于零则表示加锁成功,则调到标号2处,代码2后面没有继续执行的代码了,因此会返回。如果减一之后小于零,则表示之前已经有进程进行了加锁操作,则跳到标号3处执行,将lock->slock与0进行比较,如果小于零则再次跳到3处执行,即循环执行标号3处的指令。直到加锁者释放锁将lock->slock设为1,此时会跳到标号1处进行加锁操作。
与 __raw_spin_lock()相对应的解锁函数是 __raw_spin_unlock(),他的作用是将lock->slock的值设为1,仅此而已。只有加锁者将lock->slock设为1之后其他在忙等待的CPU才能进行加锁,结合之前的__raw_spin_lock()应该不难理解。

       static inline void __raw_spin_unlock(raw_spinlock_t *lock)
        {
            asm volatile("movl $1,%0" :"=m" (lock->slock) :: "memory");
        }

总结
单处理器自旋锁的工作流程是:关闭内核抢占->运行临界区代码->开启内核抢占。更加安全的单处理器自旋锁工作流程是:保存IF寄存器->关闭当前CPU中断->关闭内核抢占->运行临界区代码->开启内核抢占->开启当前CPU中断->恢复IF寄存器。
多处理器自旋锁的工作流程是:关闭内核抢占->(忙等待->)获取自旋锁->运行临界区代码->释放自旋锁->开启内核抢占。更加安全的多处理器自旋锁工作流程是:保存IF寄存器->关闭当前CPU中断->关闭内核抢占->(忙等待->)获取自旋锁->运行临界区代码->释放自旋锁->开启内核抢占->开启当前CPU中断->恢复IF寄存器。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值