Linux驱动之并发控制

Linux设备驱动中必须解决的一个问题是多个进程对共享资源的并发访问,并发的访问会导致竞态,即使是经验丰富的驱动工程师也常常设计出包含并发问题的bug的驱动程序。

Linux提供了多种解决竞态问题的方法,本章主要讲解了中断屏蔽、原子操作、自旋锁和信号量等并发控制机制。中断屏蔽很少单独被使用,原子操作只能针对整数进行,因此自旋锁和信号量应用最为广泛。

自旋锁会导致死循环,锁定期间不允许阻塞,因此要求锁定的临界区小。信号量允许临界区阻塞,可以适用于临界区大的情况。

读写自旋锁和读写信号量分别是放宽了条件的自旋锁和信号量,他们允许多个执行单元对共享资源的并发读。

一、并发的引入:并发与竞态

并发(concurrency)指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、竞态变量等)的访问则很容易导致竞态(race conditions)。例如,对于同一个设备,假设一个执行单元A对其写入3000个字符‘a’,而另一个执行单元B对其写入4000个字符‘b’,第三个执行单元C读取该设备的所有字符。如果执行单元A、B的写操作如图1.1所示顺序发生,执行单元C的读操作当然不会有什么问题。但是如果执行单元A、B如图1.2所示顺序发生,而执行单元C又“不合时宜”地读,则会读出3000个‘b’。

图1.1 并发执行单元的顺序执行


Linux驱动之并发控制_第1张图片
image

图1.2 并发执行单元的交错执行


Linux驱动之并发控制_第2张图片
image

比图1.2更复杂、更混乱的并发大量地存在于设备驱动中,只要并发的多个执行单元存在对共享资源的访问,竞态就可能发生。在Linux内核中,主要的竞态发生于以下几种情况:

1.对称多处理器(SMP)的多个CPU

SMP是一种紧耦合、共享存储的系统模型,其体系结构如图1.3所示,它的特点是多个CPU使用共同的系统总线,因此可访问共同的外设和存储器。

图1.3 SMP体系结构


Linux驱动之并发控制_第3张图片
image

2.单CPU内进程与抢占它的进程

Linux 2.6内核支持抢占调度,一个进程在内核执行的时候可能被另一高优先级进程打断,进程与抢占它的进程访问共享资源的情况类似于SMP的多个CPU。

3.中断(硬中断、软中断、Tasklet、底半部)与进程之间

中断可以打断正在执行的进程,如果中断处理程序访问进程正在访问的资源,则竞态也会发生。此外,中断也有可能被新的更高优先级的中断打断,因此,多个中断之间本身也可能引起并发而导致竞态。

上述并发的发生情况除了SMP是真正的并行以外,其他的都是“宏观并行,微观串行”的,但实际引发的实质问题和SMP相似。

解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享的时候,其他的执行单元被禁止访问。访问共享资源的代码区域称为==临界区(critical sections)==,临界区需要被以某种互斥机制加以保护。中断屏蔽、原子操作、自旋锁和信号量等是Linux设备驱动中可采用的互斥途径,接下来逐一进行讲解。

二、中断屏蔽:单CPU的方式

在单CPU范围内避免竞态的一种而省事的方法是在进入临界区之前屏蔽系统的中断。CPU一般都具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生。具体而言,中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于Linux内核的进程调度等操作都依赖于中断来实现,内核抢占进程间的并发也得以避免了。

中断屏蔽的方法为:

local_irq_disable()  /* 屏蔽中断 */
. . .
critical section /* 临界区 */
. . .
local_irq_enable()  /* 开中断 */

由于Linux的异步I/O、进程调度等很多重要操作都依赖于中断,中断对于内核的运行非常重要,在屏蔽中断期间所有的中断都无法得到处理,因此长时间屏蔽中断是很危险的,有可能造成数据丢失乃至系统崩溃等后果。这就要求在屏蔽了中断之后,当前的内核执行路径应当尽快地执行完临界区的代码。

local_irq_disable()和local_irq_enable()都只能禁止和使能本CPU内的中断,因此,并不能解决SMP多CPU引发的竞态。因此,单独使用中断屏蔽通常不是一种值得推荐的避免竞态的方法,它适宜与下文将要介绍的自旋锁联合使用。

与local_irp_disable()不同的是,local_irq_save(flags)除了进行禁止中断的操作以外,还保存目前的CPU的中断位信息,local_irq_restore(flags)进行的是与local_irq_save(flags)相反的操作。

如果只是想禁止中断的底半部,应使用local_bh_disable(),使能被local_bh_disable()禁止的底半部应该调用local_bh_enable()。

三、原子操作:与CPU架构密切相关

原子操作指的是在执行过程中不会被别的代码路径所中断的操作。

Linux内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对位和整型变量进行原子操作。他们的共同点是在任何情况下操作都是原子的,内核代码可以安全地调用它们而不被打断。位和整型变量原子操作都依赖底层CPU的原子操作来实现,因此所有这些函数都与CPU架构密切相关。

3.1 整型原子操作

1.设置原子变量的值

void atomic_set(atomic_t *v, int i);  /* 设置原子变量的值为i */
atomic_t v = ATOMIC_INIT(0);  /* 定义原子变量v并初始化为0 */

2.获取原子变量的值

atomic_read(atomic_t *v);  /* 返回原子变量的值 */

3.原子变量加/减

void atomic_add(int i, atomic_t *v);  /* 原子变量增加i */
void atomic_sub(int i, atomic_t *v);  /* 原子变量自减i */

4.原子变量自增/自减

void atomic_inc(atomic_t *v);  /* 原子变量增加1 */
void atomic_dec(atomic_t *v);  /* 原子变量减少1 */

5.操作并测试

int atomic_inc_and_test(atomic_t *v);
int atomic_dec_and_test(atomic_t *v);
int atomic_sub_and_test(int i, atomic_t *v);

上述操作对原子变量执行自加、自减和减操作后(注意没有加)测试其是否为0,为0返回true,否则返回false。

6.操作并返回

int atomic_add_return(int i, atomic_t *v);
int atomic_sub_return(int i, atomic_t *v);
int atomic_inc_return(atomic_t *v);
int atomic_dec_return(atomic_t *v);

上述操作对原子变量进行加/减和自增/自减操作,并返回新的值。

3.2 位原子操作

1.设置位

void set_bit(nr, void *addr);

上述操作设置addr地址的第nr位,所谓设置位即是将位写为1。

2.清除位

void clear_bit(nr, void *addr);

上述操作清除addr地址的第nr位,所谓清除位即是将位写为0。

3.改变位

void change_bit(nr, void *addr);

上述操作对addr地址的第nr位进行反置。

4.测试位

test_bit(nr, void *addr);

上述操作返回addr地址的第nr位。

5.测试并操作位

int test_and_set_bit(nr, void *addr);
int test_and_clear_bit(nr, void *addr);
int test_and_change_bit(nr, void *addr);

上述test_and_xxx_bit(nr, void *addr)操作等同于执行test_bit(nr, void *addr)后再执行xxx_bit(nr, void *addr)。

代码清单2.1给出了原子变量的使用例子,它用于使得设备最多只能被一个进程打开。

static atomic_t xxx_available = ATOMIC_INIT(1);  /* 定义原子变量并初始化为1 */
static int xxx_open(struct inode *inode, struct file *filp)
{
    . . .
    if(!atomic_dec_and_test(&xxx_available)) {
        atomic_inc(&xxx_available);
        return -EBUSY;  /* 已经被占用 */
    }
    . . .
    return 0;  /* 成功 */
}
static int xxx_release(struct inode *inode, struct file *filp);
{
    atomic_inc(&xxx_available);  /* 释放设备 */
    return 0;
}

四、自旋锁:单CPU抢占式或SMP系统

4.1 自旋锁的使用

自旋锁(spin lock)是一种典型的对临界资源进行互斥访问的手段,其名称来源于它的工作方式。为了获得一个自旋锁,在某CPU上运行的代码需要先执行一个原子操作,该操作测试并设置(test-and-set)某个内存变量,由于它是原子操作,所以在该操作完成之前其他执行单元不可能访问这个内存变量。如果测试结果表明锁已经空闲,则程序获得这个自旋锁并继续执行;如果测试结果表明锁仍被占用,程序将在一个小的循环内重复这个“测试并设置”操作,即进行所谓的“自旋”,通俗地说就是“在原地打转”。当自旋锁的持有者通过重置该变量释放这个自旋锁后,某个等待的“测试并设置”操作向其调用者报告锁已释放。

理解自旋锁最简单的方法是把它作为一个变量看待,该变量把一个临界区或者标记为“我当前在运行,请稍等一会”后者标记为“我当前不在运行,可以被使用”。如果A执行单元首先进入例程,它将持有自旋锁;当B执行单元试图进入同一个例程时,将获得自旋锁已被持有,需等到A执行单元释放后才能进入。

4.2 普通自旋锁

Linux中与自旋锁相关的操作主要有以下4种:

1.定义自旋锁

spinlock_t lock;

2.初始化自旋锁

spin_lock_init(&lock);

该宏用于动态初始化自旋锁lock。

3.获得自旋锁

spin_lock(&lock);

该宏用于获得自旋锁lock,如果能够立即获得锁,它马上返回;否则,它将自旋在那里,直到该自旋锁的保持者释放。

spin_trylock(&lock);

该宏尝试获得自旋锁lock,如果能够立即获得锁,它获得锁并返回真,否则立即返回假,实际上不再“在原地打转”。

4.释放自旋锁

spin_unlock(&lock);

该宏释放自旋锁lock,它与spin_trylock或spin_lock配对使用。自旋锁一般这样被使用:

/* 定义一个自旋锁 */
spinlock_t lock;
spin_lock_init(&lock);
. . .
spin_lock(&lock);  /* 获取自旋锁,保护临界区 */
. . .   /* 临界区 */
spin_unlock(&lock);  /* 解锁 */

++自旋锁主要针对SMP或但CPU但内核可抢占的情况,对于单CPU和内核不支持抢占的系统,自旋锁退化为空操作++。在单CPU和内核可抢占的系统中,自旋锁持有期间的抢占将被禁止。由于内核可抢占的单CPU系统的行为实际很类似于SMP系统,因此,在这样的单CPU系统中使用自旋锁仍十分必要。

尽管用了自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候,还可能受到中断和底半部(BH,稍后章节会介绍)的映像。为了防止这种影响,就需要用到自旋锁的衍生。spin_lock()/spin_unlock()是自旋锁机制的基础,他们和关中断local_irq_disable()/开中断local_irq_enable()、关底半部local_bh_disable()开底半部local_bh_enable()、关中断并保存状态字local_irq_save()/开中断并恢复状态local_irq_restore()结合就形成了整套自旋锁机制,关系如下:

spin_lock_irq() = local_irq_disable() + spin_lock()
spin_unlock_irq() = spin_unlock() + local_irq_enable()
spin_lock_irqsave() = local_irq_save() + spin_lock()
spin_unlock_irqrestore() = spin_unlock() + local_irq_restore()
spin_lock_bh() = local_bh_disable() + spin_lock()
spin_unlock_bh() = spin_unlock() + local_bh_enable()

spin_lock_irq()、spin_lock_irqsave()、spin_lock_bh()类似函数会为自旋锁的使用系好“安全带”,以避免突如其来的中断驶入对系统造成的伤害。

驱动工程师应谨慎使用自旋锁,而且在使用中还要特别注意如下几个问题:

  • 自旋锁实际上是忙等锁,当锁不可用时,CPU一直循环执行“测试并设置”该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。当临界区很大,或有共享设备的时候,需要较长时间占用锁,使用自旋锁会降低系统的性能。
  • 自旋锁可能导致系统死锁。引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,则该CPU将死锁。
  • 自旋锁锁定期间不能调用可能引起进程调度的函数。如果进程获得自旋锁之后再阻塞,如调用copy_from_user()、copy_to_user()、kmalloc()和msleep()等函数,则可能导致内核的崩溃。

代码清单2.2给出了自旋锁的使用例子,它被用于实现使得设备只能被最多1个进程打开,等同于代码清单2.1。

int xxx_count = 0;  /* 定义文件打开次数计数 */
static int xxx_open(struct inode *inode, struct file *filp);
{
    . . .
    spin_lock(&xxx_lock);
    if(xxx_count) {  /* 已经打开 */
        spin_unlock(&xxx_lock);
        return -EBUSY;
    }
    xxx_count++;  /* 增加使用计数 */
    spin_unlock(&xxx_lock);
    . . .
    return 0;  /* 成功 */
}
static int xxx_release(struct inode *inode, struct file *filp)
{
    . . .
    spin_lock(&xxx_lock);
    xxx_count--;  /* 减少使用计数 */
    spin_unlock(&xxx_lock);
    return 0;
}

4.3 读写自旋锁

普通自旋锁不关心锁定的临界区究竟进行怎样的操作,不管是读还是写,它都一视同仁。即便多个执行单元同时读取临界资源也会被锁住。实际上,对共享资源并发访问时,多个执行单元同时读取它是不会有问题的,而自旋锁的衍生锁:读写自旋锁(rwlock)可允许读的并发。读写自旋锁是一种比自旋锁粒度更小的锁机制,它保留了“自旋”的概念,但是在写操作方面,只能最多1个写进程,在读操作方面,同时可以有多个读执行单元。当然,读和写也不能同时进行。读写自旋锁涉及的操作如下。

1.定义和初始化读写自旋锁

rwlock_t my_rwlock;
rwlock_init(&my_rwlock);  /* 动态初始化 */
rwlock_t my_rwlock = RW_LOCK_UNLOCKED;  /* 静态初始化 */

2.读锁定

void read_lock(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
void read_lock_irq(rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock, unsigned long flags);

3.读解锁

void read_unlock(rwlock_t *lock);
void read_unlock_bh(rwlock_t *lock);
void read_unlock_irq(rwlock_t *lock);
void read_unlock_irqsave(rwlock_t *lock, unsigned long flags);

在对共享资源进行读取之前,应该先调用读锁定函数,完成之后应调用读解锁函数。

read_lock_bh()、read_lock_irq()、read_lock_irqsave()也分别是read_lock()与lock_bh_disable()、lock_irq_disable()和lock_irq_save()的组合,读解锁函数read_unlock_bh()、read_unlock_irq()、read_unlock_irqsave()的情况与此类似。

4.写锁定

void write_lock(rwlock_t *lock);
int write_trylock(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);
void write_lock_irq(rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock, unsigned long flags);

5.写解锁

void write_unlock(rwlock_t *lock);
void write_unlock_bh(rwlock_t *lock);
void write_unlock_irq(rwlock_t *lock);
void write_unlock_irqsave(rwlock_t *lock, unsigned long flags);

write_lock_bh()、write_lock_irq()、write_lock_irqsave()分别是write_lock()与lock_bh_disable()、lock_irq_disable()、lock_irq_save()的组合,写解锁函数write_unlock_bh()、write_unlock_irq()、write_unlock_irqstore()的情况与此类似。

在对共享资源进行写之前,应该先调用写锁定函数,完成之后应调用写解锁函数。和spin_trylock()函数一样,write_trylock()也只是尝试获取读写自旋锁,不管成功失败,都会立即返回。读写自旋锁一般用法如下。

rwlock_t lock;  /* 定义读写自旋锁 */
rwlock_init(&lock);  /* 初始化rwlock */
/* 读时获取锁 */
read_lock(&lock);
. . .  /* 访问临界资源 */
read_unlock(&lock);
/* 写时获取锁 */
write_lock_irqsave(&lock, flags);
. . .  /* 修改临界资源 */
write_unlock_irqrestore(&lock, flags);

4.4 顺序锁

顺序锁(seqlock)是对读写锁的一种优化,若使用顺序锁,读执行单元觉不会被写执行单元阻塞,也就是说,读执行单元在写执行单元对被顺序锁保护的共享资源进行写操作时仍然可以继续读,而不必等待写执行单元完成写操作,写执行单元也不需要等待所有的读执行单元完成读操作才去执行写操作。

但是,写执行单元与写执行单元之间仍然是互斥的,即如果有写执行单元在进行写操作,其他写执行单元必须自旋在那里,直到写执行单元释放了顺序锁。如果读执行单元在读操作期间,写执行单元已经发生了写操作,那么,读执行单元必须重新读取数据,以便确保得到的数据时完整的。这种锁对于读写同时进行的概率比较小的情况,性能是非常好的,而且它允许读写同时进行,因而更大地提高了并发性。

顺序锁有一个限制,它必须要求被保护的共享资源不含有指针,因为写执行单元可能使得指针失效,但读执行单元如果正要访问该指针,将导致oops。

在Linux内核中,写执行单元涉及的顺序锁操作如下:

1.获得顺序锁

void write_seqlock(seqlock_t *sl);
int write_tryseqlock(seqlock_t *sl);
write_seqlock_bh(lock);
write_seqlock_irq(lock);
write_seqlock_irqsave(lock, flags);

其中:

write_seqlock_bh() = local_bh_disable() + write_seqlock();
write_seqlock_irq() = local_irq_disable() + write_seqlock();
write_seqlock_irqsave() = local_irq_save() + write_seqlock();

2.释放顺序锁

void write_sequnlock(seqlock_t *sl);
write_sequnlock_bh(lock);
write_sequnlock_irq(lock);
write_sequnlock_irqrestore(lock);

其中:

write_sequnlock_bh(lock) = write_sequnlock() + local_bh_enable();
write_sequnlock_irq(lock) = write_sequnlock() + local_irq_enable();
write_sequnlock_irqrestore(lock) = write_sequnlock() + local_irq_restore();

写执行单元使用顺序锁的模式如下:

write_seqlock(&seqlock_a);
. . .  /* 写操作代码块 */
write_sequnlock(&seqlock_a);

因此,对写执行单元而言,它的使用与spinlock相同。

读执行单元涉及的顺序锁操作如下。

1.读开始

unsigned read_seqbegin(const seqlock_t *sl);
read_seqbegin_irqsave(lock, flags);

读执行单元在对被顺序锁sl保护的共享资源进行访问前需要调用该函数,该函数仅返回顺序锁sl的当前顺序号,其中:

read_seqbegin_irqsave(lock, flags) = local_irq_save() + read_seqbegin();

2.重读

int read_seqretry(const seqlock_t *sl, unsigned iv);
read_seqretry_irqrestore(lock, iv, flags);

读执行单元在访问完被顺序锁sl保护的共享资源后需要调用该函数来检查,在读访问期间是否有写操作。如果有写操作,读执行单元就需要重新进行读操作。其中:

read_seqretry_irqrestore() = read_seqretry() + local_irq_restore();

读执行单元使用顺序锁的模块如下:

do {
    seqnum = read_seqbegin(&seqlock_a);
    /* 读操作代码块 */
    …
}while(read_seqretry(&seqlock_a, seqnum));

4.5 读-拷贝-更新

RCU(Read-Copy Update,读-拷贝-更新),它是基于其原理命名的。RCU并不是新的的锁机制,它只是对Linux内核而言是新的。早在20世纪80年代就有了这种机制,而在Linux中是开发内核2.5.43中引入该技术的并正式包含在2.6内核中。

对于被RCU保护的共享数据结构,读执行单元不需要获得任何锁就可以访问它,不使用原子指令,而且在除alpha的所有架构上也不需要内存屏障(Memory Barrier),因此不会导致锁竞争、内存延迟以及流水线停滞。不需要锁也使得使用更容易,因为死锁问题就不需要考虑了。使用RCU的写执行单元在访问它前需首先拷贝一个副本,然后对副本进行修改,最后使用一个回调机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据,这个时机就是所有引用该数据的CPU都退出对共享数据的操作的时候。读执行单元没有任何同步开销,而写执行单元的同步开销则取决于使用的写执行单元间同步机制。

RCU可以看作读写锁的高性能版本,相比读写锁,RCU的优点在于既允许多个读执行单元同时访问被保护的数据,又允许多个读执行单元和多个写执行单元同时访问被保护的数据。但是,RCU不能替代读写锁,因为如果写比较多时,对读执行单元的性能提高不能弥补写执行单元导致的损失。因为使用RCU时,写执行单元之间的同步开销会比较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行的其他写执行单元的修改操作。

Linux中提供的RCU操作包括如下4种。

1.读锁定

rcu_read_lock();
rcu_read_lock_bh();

2.读解锁

rcu_read_unlock();
rcu_read_unlock_bh();

使用RCU进行读的模块如下:

. . .  /* 读临界区 */
rcu_read_unlock();

其中rcu_read_lock()和rcu_read_unlock()实质只是禁止和使能内核的抢占调度:

#define rcu_read_lock()  preempt_disable()
#define rcu_read_unlock()  preempt_enable()

其变种rcu_read_lock_bh()、rcu_read_unlock_bh()则定义为:

# define rcu_read_lock_bh()  local_bh_disable()
#define rcu_read_unlock_bh()  local_bh_enable()

3.同步RCU

synchronize_rcu();

五、信号量

5.1 信号量的使用

信号量(semaphore)是用于保护临界区的一种常用方法,它的使用方式和自旋锁类似。与自旋锁相同,只有得到信号量的进程才能执行临界区代码。但是,与自旋锁不同的是,但获取不到信号量时,进程不会原地打转而是进入休眠等待状态。

Linux中与信号量相关的操作主要有:

1.定义信号量

下列代码定义名称为sem的信号量:

struct semaphore sem;

2.初始化信号量

void sema_init(struct semaphore *sem, int val);

该函数初始化信号量,并设置信号量sem的值为val。尽管信号量可以被初始化为大于1的值从而成为一个计数信号量,但是它通常不被这样使用。

#define init_MUTEX(sem)  sema_init(sem, 1);

该宏用于初始化一个用于互斥的信号量,它把信号量sem的值设置为1;

#define init_MUTEX_LOCKED(sem)  sema_init(sem, 0)

该宏也用于初始化一个信号量,但它把信号量sem的值设置为0。此外,下面两个宏是定义并初始化信号量的“快捷方式”:

DECLARE_MUTEX(name);
DECLARE_MUTEX_LOCKED(name);

前者定义一个名为name的信号量并初始化为1;后者定义一个名为name的信号量并初始化为0。

3.获得信号量

void down(struct semaphore *sem);

该函数用于获得信号量sem,它会导致睡眠,因此不能在中断上下文使用;

int down_interruptible(struct semaphore *sem);

该函数功能与down类似,不同之处为:因down()而进入睡眠状态的进程不能被信号打断;但因down_interruptible()而进入睡眠状态的进程能被信号打断,信号也会导致该函数返回,这时候函数的返回值非0;

int down_trylock(struct semaphore *sem);

该函数尝试获得信号量sem,如果能够立即获得,它就获得该信号量并返回0,否则返回非0值。它不会导致调用者睡眠,可以在中断上下文使用。

在使用down_interruptible()获取信号量时,对返回值一般会进行检查,如果非0,通常立即返回-ERESTARTSYS,如:

if(down_interruptible(&sem))
    return -ERESTARTSYS;

4.释放信号量

void up(struct semaphore *sem);

该函数释放信号量sem,唤醒等待者。信号量一般这样被使用:

/* 定义信号量 */
DECLARE_MUTEX(mount_sem);
down(&mount_sem);  /* 获取信号量,保护临界区 */
. . .
critical section  /* 临界区 */
. . .
up(&mount_sem);  /* 释放信号量 */

Linux自旋锁和信号量所采用的“获取锁—访问临界区—释放锁”的方式,姑且称之为“互斥三部曲”,实际存在于几乎所有的多任务操作系统之中,在WIN32、VxWorks等中皆如此。


代码清单2.3给出了使用信号量实现设备只能被一个进程打开的例子,等同于代码清单2.1和2.2。

代码清单2.3 使用信号量实现设备只能被一个进程打开

static DECLARE_MUTEX(xxx_lock);  /* 定义互斥锁 */
static int xxx_open(struct inode *inode, struct file *filp)
{
    . . .
    if(down_trylock(&xxx_lock))   /* 获得打开锁 */
        return -EBUSY;  /* 设备忙 */
    . . .
    return 0;  /* 成功 */
}
static int xxx_release(struct inode *inode, struct file *filp)
{
    up(&xxx_lock);
    return 0;
}

5.2 信号量用于同步

如果信号量被初始化为0,则它可以用于同步,同步意味着一个执行单元的继续执行需等待另一个执行单元完成某事,保证执行的先后顺序。如图2.4所示,执行单元A执行代码区域b之前,不需等待执行单元B执行完代码单元c,信号量可辅助这一同步过程的实现。

图2.4 信号量用于同步


Linux驱动之并发控制_第4张图片
image

5.3 完成量用于同步

Linux提供了一种比2.5.2所述更好的同步机制,及完成量(completion),它用于一个执行单元等待另一个执行单元执行完某事。

Linux中与completion相关的操作有以下4种:

1.定义完成量

下列代码定义名为my_completion的完成量:

struct completion my_completion;

2.初始化completion

下列代码初始化my_completion这个完成量:

init_completion(&my_completion);

对my_completion的定义和初始化可以通过如下快捷方式实现:

DECLARE_COMPLETION(my_completion);

3.等待完成量

下列函数用于等待一个completion被唤醒:

void wait_for_completion(struct completion *c);

4.唤醒完成量
下面两个函数用于唤醒完成量:

void complete(struct completion *c);
void complete_all(struct completion *c);

前者只唤醒一个等待的执行单元,后者释放所有等待某一完成量的执行单元。图2.5描述了使用完成量实现与图2.4对应的信号量实现的同步功能。

图2.5 完成量用于同步


Linux驱动之并发控制_第5张图片
image

5.4 读写信号量

读写信号量和信号量的关系与读写自旋锁和自旋锁的关系类似,读写信号量可能引起进程阻塞,但它可允许N个读执行单元同时访问共享资源,而最多只能有1个写执行单元。因此,读写信号量是一种相对放宽条件的粒度稍大于信号量的互斥机制。

读写自旋锁涉及的操作包括如下5中:

1.定义和初始化读写自旋锁

struct rw_semaphore my_rws;  /* 定义读写信号量 */
void init_rwsem(struct rw_semaphore *sem);  /* 初始化读写信号量 */

2.读信号量获取

void down_read(struct rw_semaphore *sem);
int down_read_trylock(struct rw_semaphore *sem);

3.读信号量释放

void up_read(struct rw_semaphore *sem);

4.写信号量获取

void down_write(struct rw_semaphore *sem);
int down_write_trylock(struct rw_semaphore *sem);

5.写信号量释放

void up_write(struct rw_semaphore *sem);

读写信号量一般这样使用:

rw_semaphore rw_sem;  /* 定义读写信号量 */
init_rwsem(&rw_sem);  /* 初始化读写信号量 */
/* 读时获取信号量 */
down_read(&rw_sem);
. . .  /* 临界资源 */
up_read(&rw_sem);
/* 写时获取信号量 */
down_write(&rw_sem);
. . .  /* 临界资源 */
up_write(&rw_sem);

六、自旋锁VS信号量

自旋锁和信号量都是解决互斥问题的基本手段,面对特定的情况,应该如何取舍这两种手段呢?选择的依据是临界区的性质和系统的特点

从严格意义上说,信号量和自旋锁属于不同层次的互斥手段,前者的实现有赖于后者。在信号量本身的实现上,为了保证信号量结构存取的原子型,在多CPU种需要自旋锁来互斥。

信号量是进程级的,用于多个进程之间对资源的互斥,虽然也是在内核中,但是内核执行路径是以进程的身份,代表进程来争夺资源的。如果竞争失败,会发生进程上下文切换,当前进程进入睡眠状态,CPU将运行其他进程。鉴于进程上下文切换的开销也很大,因此,只有当进程占用资源时间较长时,用信号量才是较好的选择。

当所要保护的临界区访问时间比较短时,用自旋锁是非常方便的,因为它节省上下文切换时间。但是CPU得不到自旋锁会在那里空转直到其他执行单元解锁为止,所以要求锁不能在临界区里长时间停留,否则会降低系统的效率。

由此,可以总结出自旋锁和信号量选用的3项原则:

  • ++当锁不能被获取到时,使用信号量的开销是进程上下文切换时间Tsw,使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定)Tcs,若Tcs比较小,宜使用自旋锁,若Tcs很大,应使用信号量++。
  • ++信号量锁保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区++。因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。
  • ++信号量存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在信号量和自旋锁之间只能选择自旋锁。当然,如果一定要使用信号量,则只能通过down_trylock()方式进行,不能获取就立即返回以避免阻塞++。

七、互斥体

尽管信号量已经可以实现互斥的功能,而且包含DECLARE_MUTEX()、init_MUTEX()等定义信号量的宏或函数,从名字上看就体现出了互斥体的概念,但是“正宗”的mutex在Linux内核中还是真实地存在着。

下面代码定义名my_mutex的互斥体并初始化它:

struct mutex my_mutex;
mutex_init(&my_mutex);

下面的两个函数用于获取互斥体:

void inline __sched mutex_lock(struct mutex *lock);
int __sched mutex_trylock(struct mutex *lock);
int __sched mutex_lock_interruptible(struct mutex *lock);

mutex_lock()与mutex_lock_interruptible()的区别和down()与down_interruptible()的区别完全一致,前者引起的睡眠不能被信号打断,而后者可以。mutex_trylock()用于尝试获得mutex,获取不到mutex时不会引起进程睡眠。

下列函数用于释放互斥体:

void __sched mutex_unlock(struct mutex *lock);

mutex的使用方法和信号量用于互斥的场合完全一样:

struct mutex my_mutex;  /* 定义互斥体 */
mutex_init(&my_mutex);  /* 初始化mutex */
mutex_lock(&my_mutex);  /* 获取mutex */
. . .  /* 临界资源 */
mutex_unlock(&my_mutex);  /* 释放mutex */

你可能感兴趣的:(Linux驱动之并发控制)