Linux Kernel同步机制

转载请注明:【转载自博客xelatex KVM】,并附本文链接。谢谢。


Linux Kernel的同步机制是目前进行Linux Kernel开发所必须了解的一部分。在早期的Kernel版本中,由于Linux不支持SMP和抢占式内核,所以只需要处理中断发生时和内核代码显式reschedule时的锁,其锁机制是非常简单的。但是从2.0版本的内核开始,Linux开始支持SMP,这样多个内核线程同时访问一个数据区的情况就可能发生;到了2.6及以后版本的内核,Linux开始支持抢占式内核,对于一个核在执行内核代码时,其可以被其它内核进程抢占,这种情况下便产生了同一个核执行不同内核线程时访问了同一个数据区的情况。以上两种情况都促使Linux Kernel支持更为复杂的内核同步机制。下面就对这些机制进行简单的介绍。


一、SMP和可抢占式内核

首先需要对上文中提到的SMP和可抢占式内核这两个概念进行一下简单的说明。SMP是Symmetric Multi-Processing的首字母缩写,中文是对称多处理,是指在一个计算机上汇集了一组CPU,每个CPU共享内存和总线子系统。SMP的最大特点是共享资源,内存资源和总线资源是每个CPU共享的。与之相对的是MPP (Massively Parallel Processing),意为大规模并行处理系统,MPP系统中每个CPU都有自己独立的内存和总线资源。

在早起的Linux Kernel中,由每个任务自己放弃对CPU的使用,这种内核叫做非抢占式内核。在非抢占式内核中,各个任务彼此共同使用CPU,由任务的自己选择在何时放弃对CPU的使用。非抢占式内核有重大的缺陷:任务响应时间慢,对于高优先级的任务即使已经准备好了,也需要等待当前任务放弃对CPU的占用,这个缺陷严重影响了多任务系统的实时性;此外非抢占式内核对内核的任务响应时间是不确定的,容易造成饥饿的状态。


二、为什么要使用复杂的内核锁机制

除了上文中提到的SMP和可抢占式内核,使用复杂的内核锁机制的原因还有很多。在这一节我们就列出所有使用复杂的内核锁机制的原因。
  1. SMP系统对数据区的同时访问
  2. 可抢占式内核是的两个进程可以“伪”同时访问一个数据区
  3. 中断:中断可以在任意时间发生,这会打断当前内核代码的执行
  4. 软中断和tasklet:Linux Kernel支持软中断和tasklet技术,同样会随时打断当前内核代码的执行
  5. 内核线程可以自己放弃对CPU的占用

三、原子操作

对于一个操作来说,在CPU指令级别看来可能会有若干个步骤,原子操作的概念是:这些步骤在逻辑上被认为是连续执行的,从而使得这个操作“看起来”只有一个步骤。 原子操作是其它复杂的内核同步机制的基础,Linux Kernel中提供了对整数和对位的原子操作。

1、对整数的原子操作

Linux Kernel中对整数定义了如下原子操作的结构体:

<linux/types.h>
typedef struct {
    volatile int counter;
} atomic_t;

该结构体里只封装了一个int型变量counter。这样封装的原因是:1、使得所有的原子操作必须接受该类型的参数,避免误操作;2、避免用于原子操作的数据被直接作为其他函数的参数;3、避免编译器对该结构的优化;4、用该结构体可以屏蔽不同体系结构对原子操作类型的不同实现方式。

原子操作有如下相关的接口:
  1. ATOMIC_INIT(int i) : 初始化原子结构,并将其值置为i
  2. int atomic_read(atomic_t *v) : 读一个原子结构的值
  3. void atomic_set(atomic_t *v, int i) : 设置一个原子结构的值
  4. void atomic_add(int i, atomic_t *v) : 将原子结构的值加i
  5. void atomic_sub(int i, atomic_t *v) : 将原子结构的值减i
  6. void atomic_inc(atomic_t *v) : 原子结构值加一
  7. void atomic_dec(atomic_t *v) : 原子结构值减一
  8. int atomic_sub_and_test(int i, atomic_t *v) : 原子结构值减i,如果结果为0则返回true,否则返回false
  9. int atomic_add_negative(int i, atomic_t *v) : 原子结构值加i,如果结果为负数则返回true,否则返回false
  10. int atomic_add_return(int i, atomic_t *v) : 原子结构值加i,并返回结果
  11. int atomic_sub_return(int i, atomic_t *v) : 原子结构值减i,并返回结果
  12. int atomic_inc_return(atomic_t *v) : 原子结构值加1,并返回结果
  13. int atomic_dec_return(atomic_t *v) : 原子结构值减1,并返回结果
  14. int atomic_dec_and_test(atomic_t *v) : 原子结构值减1,如果结果为0则返回true,否则返回false
  15. int atomic_inc_and_test(atomic_t *v) : 原子结构值加1,如果结果为0则返回true,否则返回false
使用时首先调用 ATOMIC_INIT(int i)进行初始化,之后便可以调用其他的函数进行对应的操作。

此外,Kernel还支持一种64位数据的原子结构及相关操作,结构体定义如下:
typedef struct {
    volatile long counter;
} atomic64_t;

对应的函数为 ATOMIC64_INIT、atomic64_read、atomic64_set等,即将atomic替换成atomic64。atomic_t即使在64位环境下也是32位的数据,此外并非所有的体系结构都支持64bit原子结构,在编写代码的时候需要注意。

2、位原子操作

相对于整数的原子操作,还有一种原子操作是对一个机器字某一位的原子操作,被称为位原子操作。位原子操作的对象长度是unsigned long型,在kernel中对应一个机器字长度(32bit系统是32位,64bit系统则是64位)。位原子操作有如下接口:
  1. void set_bit(int nr, void *addr) : 将addr处数据的第nr位设置为1
  2. void clear_bit(int nr, void *addr) : 将addr处数据的第nr位设置为0
  3. void change_bit(int nr, void *addr) : 将addr处数据的第nr位取反
  4. int test_and_set_bit(int nr, void *addr) :  将addr处数据的第nr位设置为1,并返回原值
  5. int test_and_clear_bit(int nr, void *addr) : 将addr处数据的第nr位设置为0,并返回原值
  6. int test_and_change_bit(int nr, void *addr) : 将addr处数据的第nr位取反,并返回原值
  7. int test_bit(int nr, void *addr) : 读取addr处数据的第nr位
上述操作都是原子操作,对应的Kernel还提供了一套非原子的未操作,对应的函数名就是在上述函数名前加两个下划线,即__set_bit、__test_bit等等。非原子的操作一般用于数据已经被其它代码保护的情况,非原子操作比原子操作效率更高。

此外,Kernel还提供了找到某地址开始第一个为1(或为0)的函数:
  • int find_first_bit(unsigned long *addr, unsigned int size)
  • int find_first_zero_bit(unsigned long *addr, unsigned int size)
上述函数中size指定了数据的大小,如果数据的大小刚好是一个机器字,则可以对应的调用 __ffs() 和 ffz()函数。


二、自旋锁(Spin Lock)

自旋锁(Spin Lock)是最简单的一种锁,这种锁能够保证其在任何时刻最多只能够被一个拥有者持有。其保护的区域(称为临界区)在同一时间只能够被一个拥有者进入。自旋锁正如其命名,实现的机制非常的简单,就是使用一个循环来询问锁状态,如果锁不可得则持续询问,直到能够获得锁为止。自旋锁最大的优势在于锁的使用代价非常小,相关的结构也非常简单,效率很高;但缺点也非常明显:1、如果锁不可得则会持续的查询,造成CPU时间的浪费;2、先询问锁的进程不能够保证先获得锁;3、自旋锁不可递归使用,即在自旋锁的临界区不可以再次申请该锁。

自旋锁一般被用在临界区代码较短,很快就可以执行结束的地方,或者是在中断处理程序的前端中经常被使用。

自旋锁相关的接口很简单,有如下几个:
  1. DEFINE_SPINLOCK(mr_lock) : 静态初始化自旋锁mr_lock
  2. spin_lock(&mr_lock) : 申请自旋锁
  3. spin_unlock(&mr_lock) : 释放自旋锁
  4. spin_lock_irqsave(&mr_lock, flags) : 申请自旋锁,并将当前的irq状态保存在flags中,关中断
  5. spin_unlock_irqrestore(&mr_lock, flags) : 释放自旋锁,恢复irq状态为flags中的值,开中断
  6. spin_lock_irq(&mr_lock) : 申请自旋锁,关中断
  7. spin_unlock_irq(&mr_lock) : 释放自旋锁,开中断
使用的方式是,首先调用 DEFINE_SPINLOCK初始化自旋锁,之后申请和释放成对的使用2~7中的函数。如下:
DEFINE_SPINLOCK(mr_lock);
spin_lock(&mr_lock);
/* critical region ... */
spin_unlock(&mr_lock);

后两组自旋锁使用的接口比较特别: spin_lock_irqsavespin_unlock_irqrestore组合一般用在临界区需要关中断,但并不是很清楚当前的中断状态,于是将中断状态保存在flags中,在释放锁的时候再恢复; spin_lock_irq spin_unlock_irq则是用于已知当前的中断状态,在获得自旋锁时关中断,释放时打开中断。

一般来说比较推荐使用 spin_lock_irqsavespin_unlock_irqrestore组合。

此外自旋锁还有下面几个可以使用的接口:
  1. spin_lock_init() : 动态初始化自旋锁(如对某个自旋锁你只能够获得其指针时使用)
  2. spin_trylock() : 尝试获得锁,如果失败则返回非0值
  3. spin_is_locked() : 如果锁已经被某拥有者获得,则返回非0值;否则返回0值
自旋锁的接口中还提供了另外两种: spin_lock_bh()spin_unlock_bh()。前者表示获得自旋锁并关闭所有中断的后部(Bottom Halves),后者表示释放锁并打开所有中断的后部。关于终端的前部和后部在这里就不详细的展开了,这一对接口主要用于多个中断的后部共享数据需要自旋锁来保护时。对于中断后部和前部都可能访问的数据进行同步时,则需要在调用这一对接口时,还需要同时关闭中断。


三、信号量(Semaphore)

我们考虑在自旋锁获得锁的过程中,如果无法成功获得锁,则会用“自旋”的方式轮询,这种方式会占用较多的CPU。信号量则采用另外一种模式:即如果一个任务无法获得锁时,将该任务挂在信号量的等待队列上并将该任务进入休眠状态,当其他任务调用释放信号量操作时,调度器会唤醒等待队列上最前端的任务。信号量有一个计数器(count),该计数器初始化为n则意味着最多允许n个任务进入临界区,这也是信号量与自旋锁最大的区别之一。 信号量是Linux Kernel中唯一允许多个任务同时进入临界区的锁。

通过上述描述,我们能够看到信号量与自旋锁的差别:
  1. 信号量在等待锁时任务进入休眠状态,不占用CPU时间
  2. 多个任务在等待信号量时能够保证其申请顺序和进入顺序相同
此外,信号量相比于自旋锁还有一些其他的特性:
  1. 信号量允许多个任务同时进入临界区,而自旋锁最多只允许一个任务进入
  2. 信号量的释放操作可以由其他任务执行,而自旋锁的释放操作只能由持有锁的任务执行
  3. 由于信号量需要使用更为复杂的同步机制,所以信号量的锁同步开销比自旋锁大
  4. 信号量在持有的时候可以被抢占而不会造成死锁,自旋锁在持有时被抢占可能会造成死锁
对于信号量的计数器(count)为1的情况,称该信号量为 二进制信号量(Binary Semaphore),计数器值大于1的情况称之为 计数信号量(CountingSemaphore),前者有时也被称为 互斥量(Mutex)

信号量的使用相关的宏定义在 <asm/semaphore.h>中,结构体为 struct semaphore name。创建信号量的宏为:
sema_init(struct semaphore *, int)
static DECLARE_MUTEX(name)
init_MUTEX(struct semaphore *)
init_MUTEX_LOCKED(struct semaphore *)

第一行为动态创建计数为count的信号量,第二行是静态创建互斥量的宏,第三行是动态创建互斥量的函数,第四行是动态创建一个已经被锁住的信号量,在被某任务释放前不能够被获得。

信号量的使用方法中最重要的是 down_interruptible( struct semaphore * ) 函数,该函数尝试获得信号量,成功则返回0,否则进入休眠状态;如果在休眠状态获得信号量则返回0;如果在休眠状态收到中断,则返回-EINTR。此外获得信号量还可以使用 down(struct semaphore *) down_killable() 函数,后者与 down_interruptible(struct semaphore *) 类似,但只响应KILL信号;前者不响应任何信号,在被唤醒之前会一直处于休眠状态。新的Kernel中建议使用 down_interruptible() down_killable() ,不建议使用down()函数。此外还可以使用 down_trylock(struct semaphore *)函数,该函数尝试获得信号量,如果失败则直接返回非0值。释放信号量则简单的使用 up(struct semaphore *)函数即可。

下面是信号量使用的一个简单的例子:

/* define and declare a semaphore, named mr_sem, with a count of one */
static DECLARE_MUTEX(mr_sem);
/* attempt to acquire the semaphore ... */
if (down_interruptible(&mr_sem)) {
    /* signal received, semaphore not acquired ... */
}
/* critical region ... */
/* release the given semaphore */
up(&mr_sem);


四、读写锁(Read-Write Lock)

Linux Kernel在自旋锁和信号量的基础上,还支持响应的读写锁: 读写自旋锁(Read-Write Spin Lock)读写信号量(Read-Write Semaphore)。所谓的读写锁,就是对读操作和写操作有独立的申请锁操作,当一个锁被读操作申请成功,则进入读状态,被写操作申请成功进入锁状态。当一个锁在读状态,则其它的读申请也会成功;当一个锁在写状态,则任何读写申请都会失败。读写锁一般用在读写路径非常明确的操作中,对于一段代码中读写操作路径不清晰的情况,则直接使用自旋锁或信号量。此外读写锁中不能将一个锁的读状态升级为写状态,此外只有读写信号量能够将写状态降级为读状态。

读写自旋锁锁的使用很简单,主要的接口如下:
DEFINE_RWLOCK(mr_rwlock);
rwlock_init();

read_lock(&mr_rwlock);
/* critical section (read only) ... */
read_unlock(&mr_rwlock);

write_lock(&mr_rwlock);
/* critical section (read and write) ... */
write_unlock(&mr_lock);

读写自旋锁锁申请也有静态和动态两种方式,用法和自旋锁类似。此外,读写自旋锁还支持 read_lock_irq()、read_lock_irqsave()、read_unlock_irq()、read_unlock_irqrestore()、write_lock_irq()、write_lock_irqsave()、write_unlock_irq()、write_unlock_irqrestore()和write_trylock(),其含义与自旋锁类似,在这里就不展开描述了。

读写信号量则是类似的接口,申请方式有静态和动态两种:
static DECLARE_RWSEM(name);
init_rwsem(struct rw_semaphore *sem);

使用方式如下:

static DECLARE_RWSEM(mr_rwsem);

/* attempt to acquire the semaphore for reading ... */
down_read(&mr_rwsem);
    /* critical region (read only) ... */
    /* release the semaphore */
up_read(&mr_rwsem);

/* ... */

/* attempt to acquire the semaphore for writing ... */
down_write(&mr_rwsem);
    /* critical region (read and write) ... */
    /* release the semaphore */
up_write(&mr_sem);

此外与信号量类似,读写信号量也支持down_read_trylock()和own_write_trylock()操作,但是这里需要 特别注意这两个函数在锁申请成功时都会返回非0值,而在失败时返回0!这是与正常的信号量正好相反。 另外注意:所有的读写信号量都是互斥量,即计数为1!

读写信号量还支持 downgrade_write()操作,该操作会原子的将一个已经获得的写锁转化成读锁。


对于读写锁来说,还有一个重要的点需要注意: 读写锁是更倾向于读操作的。这句话的含义是,如果当前锁为读状态且有写请求存在,当一个新的读请求发生时,读写锁依然会允许该读请求进入临界区。这一点意味着 读写锁可能因为频繁的读请求而把写请求饿死。在后文我会介绍一种偏向于写请求的读写锁。


五、互斥量

根据上文的介绍我们已经知道,互斥量其实就是计数值为1的信号量,Linux Kernel提供了对互斥量的接口,该接口更加简单且常用,实现也更加有效率。申请互斥量的接口如下( 前者为静态申请,后者为动态申请)
DEFINE_MUTEX(name);
mutex_init(&mutex);

使用也非常简单:

mutex_lock(&mutex);
/* critical region ... */
mutex_unlock(&mutex);

此外互斥量还支持另外两个接口: mutex_trylock(struct mutex *)mutex_is_locked (struct mutex *)。前者尝试获得互斥量,如果成功则获得互斥量并返回1,否则返回0;后者返回某互斥量的状态,如果被锁住返回1,否则返回0。

互斥量的使用非常简单,但是其与信号量相比限制条件也更加严格:
  1. 同一时间只有一个任务能够获得信号量
  2. 只有申请互斥量的任务才能够释放互斥量,而信号量可以由任务任务释放
  3. 递归的申请互斥量是不允许的,即不可以在临界区申请同一个互斥量,也不允许释放已经释放过的互斥量
  4. 进程在持有互斥量时不可以退出
  5. 互斥量不能够用于中断处理函数和中断处理后部,即使是mutex_trylock()也不可以。
  6. 互斥量只能由上述提供的API进行处理,不可以自行改变其内容


六、自旋锁、信号量和互斥量的对比

我们在之前已经比较过了自旋锁和信号量,在这一节里我们主要比较自旋锁和互斥量,以及信号量和互斥量。

对于自旋锁和互斥量,在大部分情况下我们其实别无选择,比如只有自旋锁可以用于中断的处理,而当一个任务允许休眠时则只能使用互斥量。对于其他情况总的来说:低开销、短时间持有的锁一般使用自旋锁,而长时间持有的锁一般使用互斥量。

对于信号量和互斥量,信号量是互斥量的升级版,信号量比互斥量多的功能只有 允许多个任务进入临界区 这一点 。所以在开发的过程中,建议首先使用互斥量,在发现互斥量无法完成指定功能的时候,在将其改为信号量。由于两者的API非常相似,所以这种改动的代价是非常小的,而使用互斥量则可以避免信号量过于灵活而带来的弊端。


七、完成量(Completion Variants)

Kernel还支持一种锁叫完成量,其用于两个任务间的同步,当某个事件发生需要一个任务通知另一个任务时,可以使用完成量。一般来说,一个任务等待在完成量上,当另一个任务向该完成量发送信号时,等待在完成量上的任务被唤醒并继续执行。完成量在Kernel中一般用于vfork()系统调用,父进程在调用fork时等待在某个完成量上,当自进程完成初始化或退出后向父进程发送完成通知。

完成量的定义在<linux/completion.h>中,API如下:
DECLARE_COMPLETION(mr_comp);
init_completion(struct completion *)
wait_for_completion(struct completion *)
complete(struct completion *)

前两行分别是静态和动态申请完成量,第三行是等待在完成量的API,第四行则是唤醒完成量的API。


八、顺序锁(Sequential Locks)

顺序锁(简称Seq Lock)是2.6内核引入的一种新的读写同步机制,该机制与读写锁类似对读写分别提供获得/释放锁的操作,但是该锁主要用于只有少数写任务和很多读任务的情况,为了避免写操作被饿死,或者是读写操作中写操作具有非常高的优先级时。顺序锁维护了一个版本号,写操作申请时会立即执行并更新版本号,读操作在申请时会记录版本号,完成时检查目前的版本号是否与申请时相同,如果相同则读成功,否则会重启读操作再次尝试。使用方式如下:
seqlock_t mr_seq_lock = DEFINE_SEQLOCK(mr_seq_lock);

write_seqlock(&mr_seq_lock);
/* write lock is obtained... */
write_sequnlock(&mr_seq_lock);

do {
    seq = read_seqbegin(&mr_seq_lock);
    /* read data here ... */
} while (read_seqretry(&mr_seq_lock, seq));

顺序锁的机制能够保证写操作的实时性,一般来说顺序锁用于如下情景:
  • 数据有很多的读操作和较少的写操作
  • 尽管写操作很少,但还是倾向于写操作优先于读操作
  • 数据很简单,但是由于某些原因不能够直接使用原子操作
在Linux内核中使用顺序锁最著名的就是 jiffies,该变量存储着Linux的机器时间,有兴趣的阅读函数 get_jiffies_64()来获得详细的信息。


九、关闭内核抢占

由于新的Linux Kernel是可抢占的,而我们在某些时候不希望当前的代码被抢占,所以我们需要一种机制关闭内核的抢占。Linux Kernel提供了如下接口关闭和打开内核抢占:
  • preempt_disable() : 关闭内核抢占
  • preempt_enable() : 打开内核抢占
  • preempt_enable_no_resched() : 打开内核抢占,但并不立即检查和重新调度挂起的进程
  • preempt_count() : 返回被抢占的次数
使用方法很简单:
preempt_disable();
/* preemption is disabled ... */
preempt_enable();

另外注意:preempt_disable()操作是可以递归调用的,即 preempt_disable和 preempt_enable不必总是成对出现, preempt_disable可以被连续的独立调用。


十、大内核锁(BKL:Big Kernel Lock)

在2.0到2.2版本中,由于Kernel支持了SMP但是还没有支持复杂的锁,所以当时使用的是大内核锁,即同一时刻只有一个任务能够进入内核。该锁已经很少被使用了,所以在这里就不详细说明,只列出其相关的API,有兴趣的可以查询相关资料。在目前的内核开发中, 请不要使用大内核锁
  • lock_kernel () : 锁内核
  • unlock_kernel() : 解锁内核
  • kernel_locked() : 判断内核是否被锁住,被锁住返回1,否则返回0


参考:
[1] 《Linux内核设计与实现》

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值