Linux Kernel Synchronization && Mutual Exclusion、Linux Kernel Lock Mechanism Summarize(undone)

目录

1. 同步与互斥
2. 锁定内存总线原子操作
3. 信号量
4. 自旋锁
5. RCU机制
6. PERCPU变量

 

1. 同步与互斥

在多任务操作系统中,多个进程按照不可预测的顺序进行,因为多个进程之间常常存在相互制约或者相互依赖的关系,这些关系可以被划分为同步和互斥的关系

从本质上来说,同步和互斥也可以理解为进程/线程间同步通信的一种机制,只是这里传递的是一种"争用关系",关于Linux进程间通信和同步、以及不同和互斥的相关知识,请参阅另一篇文章

http://www.cnblogs.com/LittleHann/p/3867214.html

0x1: 在内核编程中如果不使用锁可能会导致的问题

假设使用一个全局变量printer表示可用的打印机数量,这个变量被初始设置为1,进程按照如下的方法申请使用打印机

int request_printer()
{
    while (printer == 0)
        wait();
    printer--;
    ...
{

这段代码在单线程(在linux下就是单进程,因为linux没有严格的线程概念,linux只有进程)下运行是没有问题的,但是在多线程情况下,会包含着一个隐含的问题,这个问题需要从"--printer"的汇编代码才能看出,在x86平台上,"--printer"指令可能会被编译成如下的汇编指令

# printer--
movl printer %eax
decl %eax
movl %eax printer

由于CPU在每条指令流水阶段(CPU的指令集中的任意一条指令)的最后会进行"中断检查",因此上面的3条汇编指令之间都有可能发生中断,如果将这3条指令看成一个整体,当执行到其中一半的时候发生了中断,就会导致最后的结果不一致性。从本质上来说,要解决这个问题,就需要建立一个"原子操作"的概念,将需要强制一致性的操作(一条指令、或者多条指令)都"封装"到一个原子操作中。

0x2: 内核编程中涉及到的互斥锁机制

1. 锁定内存总线原子操作
2. 信号量
3. 自旋锁
4. RCU机制
5. PERCPU变量

Relevant Link:

深入linux内核架构(中文版).pdf 2.3章 同步与互斥
http://blog.csdn.net/lucien_cc/article/details/7440225

 

2. 锁定内存总线原子操作

我们继续回到之前提出的那个存在同步问题的case中,这个问题的根源是"printer--"不是原子操作引起的,为了解决这个问题,x86平台提供了dec和inc指令,这两条指令可以直接对内存进行减一或加一的操作而不产生中途的中断

因此上面的指令可以直接编译为decl printer,这样在单CPU系统上,这就是一个原子操作了,但是在多处理器系统上,上述问题依旧存在,因为在CPU内部,decl printer还是由几条微指令组成的,它还是需要首先把printer的值从内存读取到CPU中的寄存器中,然后执行减一操作,最后再写入内存,只不过在这个过程中,CPU不会进行中断检查,因此对单CPU来说,它是原子操作,但是对多CPU系统来说,CPU之间依然存在不同步的问题,脏读、幻读的现象依旧存在。

为此,x86提供了lock前缀,来避免这个问题

# printer--
lock decl printer

lock前缀告诉CPU,在执行当前指令期间锁住内存总线,这样在decl操作的微指令执行期间,如果另外的CPU访问printer,由于得不到总线仲裁的许可,在decl操作完成之前,不会访问到printer内存变量,因此它保证了在多处理器上的原子性

在Linux内核中常用的原子操作有

1. atomic_add
2. atomic_sub
3. atomic_xchg
4. atomic_inc
5. atomic_dec
6. atomic_cmpxchg
7. atomic_min
8. atomic_max
9. atomic_and
10. atomic_or
11. atomic_xor    
12. atomic_read

0x1: 内核实现代码分析

/source/arch/x86/include/asm/atomic.h

static inline void atomic_dec(atomic_t *v)
{
    asm volatile(LOCK_PREFIX "decl %0" : "+m" (v->counter));
}

0x2: 使用方法

1. 在多CPU系统中,使用lock前缀保证CPU间的数据一致性
2. 使用由CPU微指令组成的原子操作进行读取、增、减

Relevant Link:

 

3. 信号量(semaphore)

信号量(semaphore)其实是建立在原子操作的基础上的。从数据结构上角度来看,信号量实际上是一个整数型变量,有两个最基本的原子操作:

1. P(Prolagen)操作:对应于内核中的down()
2. V(Verhogen)操作:对应于内核中的up()
//它们统称PV原语

0x1: 内核实现代码分析

/source/include/linux/semaphore.h

struct semaphore 
{
    //1. 信号量
    raw_spinlock_t          lock;

    //2. 等待该信号量的进程个数
    unsigned int            count;

    //3. 该信号量的等待队列
    struct list_head        wait_list;
};

/source/kernel/locking/semaphore.c

down()

void down(struct semaphore *sem)
{
    unsigned long flags;

    raw_spin_lock_irqsave(&sem->lock, flags);
    if (likely(sem->count > 0))
        sem->count--;
    else
        __down(sem);
    raw_spin_unlock_irqrestore(&sem->lock, flags);
}

up()

void up(struct semaphore *sem)
{
    unsigned long flags;

    raw_spin_lock_irqsave(&sem->lock, flags);
    if (likely(list_empty(&sem->wait_list)))
        sem->count++;
    else
        __up(sem);
    raw_spin_unlock_irqrestore(&sem->lock, flags);
}

0x2: 使用方法

1. 将信号量的值减1
2. 如果信号量的值小于0,则进入等待状态,否则继续执行。访问完资源之后,线程释放信号量,进行如下操作
3. 将信号量的值加1
4. 如果信号量的值小于1,唤醒一个等待中的线程

Relevant Link:

 

4. 自旋锁

当需要对成块的代码进行"原子化串行操作",即保证一个代码块的原子性,最好的办法就是在一个代码块执行前关闭中断,在代码块结束之后再开启中断,在x86中

1. 关闭中断:通过cli指令清除标志寄存器中的中断允许位,即关闭中断
2. 开启中断:通过sti指令开启中断

在关闭中断期间,CPU每执行完一条指令,不会进行中断检查,由于关闭中断后,系统的外部设备可能得不到响应,因此这就要求进程关中断的时间必须是短暂的,并且在关闭中断期间,不能使系统进入睡眠状态(因为睡眠的调度也是通过中断完成的)

通过关中断的方式可以使CPU在执行某些"临界"代码块中免受干扰,但是cli只能关闭当前CPU的中断,但是在多CPU系统中,还是无法避免CPU间脏读、幻读的问题,虽然可以通过信号量的方式来防止其他CPU在同时访问同一个数据,但是由于以下原因,信号量显得不太合适

1. 信号量引起的进程切换消耗相对较大,由于这类"临界"代码的特征是执行时间非常短暂,也就是说CPU执行信号量进程切换的消耗远远大于"临界"代码本身执行的消耗。
所以,在这种情况下,让另一个CPU进入"忙等待"状态,直到临界代码操作完成

2. 由于信号量可能引起进程切换,但是在某些环境下,是不允许进程切换的,例如
    1) 中断环境中是不允许进程切换的,否则会引起panic

因此在这种情况下,当一个进程不能进入信号量包裹的临界区(PV)时,最好的办法是让CPU进入忙等待状态,即使用自旋锁

1. 设置一个锁变量,用来保护临界区代码
2. 当CPU进入临界区之前,检查锁的状态
3. 如果已经上锁,则当前CPU(或者是单CPU模拟出的多线程)执行一个"空循环"反复检测锁的状态,直到其他的CPU(或者是单CPU模拟出的其他线程)
4. 由于在测试锁的期间,CPU处于忙等待状态的"自旋"状态,因此把这种机制称为自旋锁

0x1: 内核实现代码分析

\linux-3.15.5\include\linux\spinlock_types.h

typedef struct raw_spinlock 
{
    arch_spinlock_t raw_lock;
#ifdef CONFIG_GENERIC_LOCKBREAK
    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
} raw_spinlock_t;

在使用自旋锁的时候,首先需要使用spin_lock_init来初始化,spin_lock_init是一个宏

\linux-3.15.5\include\linux\spinlock.h

# define raw_spin_lock_init(lock)                    do { *(lock) = __RAW_SPIN_LOCK_UNLOCKED(lock); } while (0)

申请和释放锁分别由spin_lock_irq()、spin_unlock_irq()完成

/source/include/linux/spinlock_api_smp.h

static inline void __raw_spin_lock_irq(raw_spinlock_t *lock)
{
    //关闭本地CPU的IRQ中断响应
    local_irq_disable();

    /*
    关闭进程抢占,由于中断或系统调用之后,可能会调度其他的进程运行(例如当前进程的时间片用完,或者有一个拥有更高优先级的进程已经进入就绪状态),preempt_disable关闭调度器这个功能,从而保证当前进程在执行临界区代码的过程中不会被其他进程干扰
    */
    preempt_disable();
    spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);

    /*
    关闭中断,这是一个宏,经过层层翻译后最终调用一条汇编指令
    /source/arch/x86/include/asm/irqflags.h
    static inline void native_irq_disable(void)
    {
        asm volatile("cli": : :"memory");
    }
    */
    LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}

我们知道,在单CPU系统中,进入临界区代码只需要关闭中断就可以了,但是在多CPU(或者在单CPU模拟的多线程)的系统中,则需要测试自旋锁的状态

/source/arch/x86/include/asm/spinlock.h

static __always_inline void __ticket_spin_lock(raw_spinlock_t *lock)
{
    short inc = 0x0100;

    asm volatile (
        //锁住内存总线
        LOCK_PREFIX "xaddw %w0, %1\n"
        "1:\t"
        
        //如果lock->lock == 0,则说明成功获取到了这个自旋锁,跳转到标号2,成功退出
        "cmpb %h0, %b0\n\t"
        "je 2f\n\t"

        //否则进入"自旋状态",执行nop指令,并测试lock->slock是否为0
        "rep ; nop\n\t"
        "movb %1, %b0\n\t"
        /* don‘t need lfence here, because loads are in-order */
        "jmp 1b\n"
        "2:"
        : "+Q" (inc), "+m" (lock->slock)
        :
        : "memory", "cc");
}

/*
在单CPU下该函数展开为一个空操作
*/

从内核代码中可以看出,当一个CPU获取自旋锁失败时,这个CPU就在循环中做"无用功",等待其他CPU(或者单CPU模拟的多线程)释放自旋锁,所以,要求持有自旋锁的时间必须尽可能短暂,并且持有自旋锁时不能进入睡眠状态

在单CPU系统中,spin_unlock_irq()只需要打开中断就可以了,在多CPU系统中,spin_unlock_irq()还需要把spinlock设置为开锁状态(即释放自旋锁的持有)

0x2: 使用方法 

在使用自旋锁的时候,spin_lock_irq()和spin_unlock_irq()保护临界区代码不受干扰,即在申请自旋锁的时候需要关闭中断,释放自旋锁的时候又要开启中断,这里存在一个潜在的问题

1. 某段代码在调用spin_lock_irq()之前需要关闭中断,之后获取了某个自旋锁,退出临界区后,再释放自旋锁
2. 在释放自旋锁时,spin_unlock_irq()开启了中断,然而该段代码此时可能根本不允许开启中断
3. 因此我们需要在调用申请自旋锁时保存当时的中断许可状态,在释放自旋锁时恢复之前的状态,而不是盲目地开启中断

spin_lock_irqsave、spin_unlock_irqrestore就是用来完成这个工作的

/source/include/linux/spinlock.h

/*
由于中断许可位位于CPU的标志寄存器中,因此spin_lock_irqsave()在获取自旋锁之后,把标志寄存器的值保存到flags中,而spin_unlock_irqrestore()在释放自旋锁之后,根据flags恢复标志寄存器
*/
#define spin_lock_irqsave(lock, flags)                          do {                                    raw_spin_lock_irqsave(spinlock_check(lock), flags);        } while (0)

static inline void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags)
{
    raw_spin_unlock_irqrestore(&lock->rlock, flags);
}

0x3: 代码示例

spin_lock_irqsave(...);
//some code to do somethings
spin_unlock_irqrestore(...);

Relevant Link:

http://blog.csdn.net/hustyangju/article/details/40391815
http://www.ibm.com/developerworks/cn/linux/l-cn-spinlock/
http://www.searchtb.com/2011/01/pthreads-mutex-vs-pthread-spinlock.html
http://blog.csdn.net/zhanglei4214/article/details/6837697

 

5. RCU机制

在实际应用中,对于某些关键数据结构而言,读取操作的次数远远超过修改操作的次数,典型的例子就是内核中的路由表。在读取操作次数远大于写入操作的情况下,自旋锁带来了不必要的消耗,为了解决这个问题,Linux内核专门设计了RCU机制,它的基本原理就是读操作不加锁,写操作必须加锁

Relevant Link:

深入linux内核架构(中文版).pdf 35 page

 

6. PERCPU变量

Relevant Link:

深入linux内核架构(中文版).pdf 39 page

 

Copyright (c) 2014 LittleHann All rights reserved

 

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。