Linux驱动开发之并发控制

 

概述

并发(Concurrency)指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(Race Conditions)。 在Linux内核中,主要的竞态发生于如下几种情况:

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

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

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

解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。访问共享资源的代码区域称为临界区(Critical Sections),临界区需要被以某种互斥机制加以保护.以下是一些实现并发控制的方法:

  1. 中断屏蔽

  2. 原子操作

  3. 自旋锁(Spinlock)

  4. 信号量(Semaphore)

  5. 互斥体(Mutex)

  6. 完成量

中断屏蔽

中断屏蔽是一种保护机制,用于保护关键代码不被中断打断。当中断屏蔽被启用时,处理器将忽略所有中断请求,直到中断屏蔽被禁用。在Linux系统中,中断屏蔽通常使用两种方法来实现:软件中断屏蔽和硬件中断屏蔽。

  1. 软件中断屏蔽:在Linux系统中,通过设置CPU的中断屏蔽标志(IF标志)来实现软件中断屏蔽。当IF标志被清除时,处理器将忽略所有中断请求。可以使用cli(Clear Interrupt flag)汇编指令清除IF标志,使用sti(Set Interrupt flag)汇编指令设置IF标志。在Linux内核中,使用local_irq_save函数和local_irq_restore函数来实现软件中断屏蔽。

  2. 硬件中断屏蔽:在Linux系统中,硬件中断屏蔽通常通过中断控制器来实现。中断控制器可以屏蔽和允许特定的中断请求。在x86架构中,中断控制器有两种类型:可编程中断控制器(PIC)和本地高级可编程中断控制器(APIC)。通过配置中断控制器,可以实现对特定中断请求的屏蔽和允许。

原子操作

原子操作可以保证对一个整型数据的修改是排他性的。Linux内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对位和整型变量进行原子操作。位和整型变量的原子操作都依赖于底层CPU的原子操作,因此所有这些函数都与CPU架构密切相关。以下是一些常见的原子操作:

  1. int atomic_read(atomic_t *v):读取原子变量的值。

  2. void atomic_set(atomic_t *v, int i):设置原子变量的值。

  3. void atomic_add(int i, atomic_t *v):将一个值加到原子变量中。

  4. void atomic_sub(int i, atomic_t *v):将一个值从原子变量中减去。

  5. void atomic_inc(atomic_t *v):原子地增加原子变量的值。

  6. void atomic_dec(atomic_t *v):原子地减少原子变量的值。

  7. int atomic_add_return(int i, atomic_t *v):将一个值加到原子变量中,并返回加法后的值。

  8. int atomic_sub_return(int i, atomic_t *v):将一个值从原子变量中减去,并返回减法后的值。

  9. int atomic_inc_and_test(atomic_t *v):原子地增加原子变量的值,并检查是否为零。

  10. int atomic_dec_and_test(atomic_t *v):原子地减少原子变量的值,并检查是否为零。

  11. int atomic_add_negative(int i, atomic_t *v):将一个值加到原子变量中,并检查是否小于零。

  12. int atomic_sub_and_test(int i, atomic_t *v):将一个值从原子变量中减去,并检查是否为零。

自旋锁

自旋锁(Spin Lock)是一种典型的对临界资源进行互斥访问的手段,其名称来源于它的工作方式。自旋锁的实现是通过忙等待来实现的,即当一个执行单元想要获取自旋锁时,如果发现自旋锁已经被其他执行单元持有,则该执行单元会一直循环等待,直到自旋锁被释放为止。

在Linux内核中,自旋锁的定义和使用如下:

spinlock_t lock;
spin_lock_init(&lock); // 初始化自旋锁

spin_lock(&lock); // 获取自旋锁
// 访问共享资源
spin_unlock(&lock); // 释放自旋锁

spin_trylock(&lock); //尝试获得自旋锁lock,如果能立即获得锁,它获得锁并返回true,否则立即返回false

spin_is_locked(&lock); //检查lock是否已经被获取

当自旋锁被持有时,如果发生中断,会导致系统死锁或者其他问题。为了避免这种情况,Linux内核提供了自旋锁的另一种变种——自旋锁中断保护锁。自旋锁中断保护锁在自旋锁的基础上增加了中断保护,可以在自旋锁被持有时禁用中断,以避免中断处理程序与持有自旋锁的执行单元之间的竞争。

自旋锁中断保护锁的定义和使用如下:

spinlock_t lock;
spin_lock_irqsave(&lock, flags); // 获取自旋锁,并保存中断状态
// 访问共享资源
spin_unlock_irqrestore(&lock, flags); // 释放自旋锁,并恢复中断状态

需要注意的是,自旋锁中断保护锁只适用于短时间持有锁的情况,因为禁用中断会导致系统性能下降,从而影响系统的响应速度。对于需要长时间持有锁的情况,应该使用信号量等其他同步机制。

读写自旋锁

在Linux内核中,读写自旋锁是一种特殊的自旋锁,它可以用于保护共享资源的读写操作。读写自旋锁允许多个执行单元同时读取共享资源,但只允许一个执行单元写入共享资源,以避免读写操作之间的竞态条件。

所有API:

    /* 动态初始化 */
rwlock_t lock;
rwlock_init(&lock);
    /* 读锁定 */
void read_lock(rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock, unsigned long flags);
void read_lock_irq(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
    /* 读解锁 */
void read_unlock(rwlock_t *lock);
void read_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void read_unlock_irq(rwlock_t *lock);
void read_unlock_bh(rwlock_t *lock);
    /* 写锁定 */
void write_lock(rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock, unsigned long flags);
void write_lock_irq(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);
int write_trylock(rwlock_t *lock);
    /* 写解锁 */
void write_unlock(rwlock_t *lock);
void write_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void write_unlock_irq(rwlock_t *lock);
void write_unlock_bh(rwlock_t *lock);

读写自旋锁的使用如下:

rwlock_t lock;
read_lock(&lock); // 获取读锁
// 访问共享资源(读操作)
read_unlock(&lock); // 释放读锁

write_lock(&lock); // 获取写锁
// 访问共享资源(写操作)
write_unlock(&lock); // 释放写锁

顺序锁

顺序锁(seqlock)是对读写锁的一种优化,若使用顺序锁,读执行单元不会被写执行单元阻塞,也就是说,读执行单元在写执行单元对被顺序锁保护的共享资源进行写操作时仍然可以继续读,而不必等待写执行单元完成写操作,写执行单元也不需要等待所有读执行单元完成读操作才去进行写操作。但是,写执行单元与写执行单元之间仍然是互斥的,即如果有写执行单元在进行写操作,其他写执行单元必须自旋在那里,直到写执行单元释放了顺序锁。

顺序锁通过一个序列号来保证对共享资源的访问顺序,从而避免多个CPU同时访问导致的竞态条件。

顺序锁的实现是通过一个序列号和一个锁来实现的。当一个执行单元访问共享资源时,它需要获取顺序锁并读取序列号,然后访问共享资源。当访问完成后,执行单元需要更新序列号并释放锁。其他执行单元在访问共享资源之前需要等待序列号更新,以确保访问顺序的正确性。

API:

seqlock_t lock;
void seqlock_init(seqlock_t *sl);
// 获取顺序锁(写锁)
void write_seqlock(seqlock_t *sl);
int write_tryseqlock(seqlock_t *sl);
int write_seqlock_irqsave(seqlock_t *sl, unsigned long flags);
int write_seqlock_irq(seqlock_t *sl);
int write_seqlock_bh(seqlock_t *sl);
// 释放顺序锁(写锁)
void write_sequnlock(seqlock_t *sl);
void write_sequnlock_irqrestore(seqlock_t *sl, unsigned long flags);
void write_sequnlock_irq(seqlock_t *sl);
void write_sequnlock_bh(seqlock_t *sl);
// 获取序列号(读锁)
unsigned read_seqbegin(const seqlock_t *sl);
unsigned read_seqbegin_irqsave(const seqlock_t *sl, unsigned long flags);
// 检查序列号(读锁)
int read_seqretry(const seqlock_t *sl, unsigned iv);
unsigned read_seqretry_irqrestore(const seqlock_t *sl, unsigned iv, unsigned long flags);

顺序锁的定义和使用如下:

seqlock_t lock;

write_seqlock(&lock); // 获取顺序锁(写锁)
// 访问共享资源
write_sequnlock(&lock); // 释放顺序锁(写锁)

unsigned int seq;
do {
    seq = read_seqbegin(&lock); // 获取序号号
    // 访问共享资源
} while (read_seqretry(&lock, seq)); // 检查序列号,并重试

RCU

RCU(Read-Copy-Update)是Linux内核中的一种并发编程技术,通常用于读多写少的场景中,例如网络数据包处理和文件系统操作等。

RCU的基本思想是,当需要更新共享数据时,不直接修改原始数据,而是先复制一份副本,然后在副本上进行修改。同时,在副本修改完成之前,原始数据仍然可以被读取。当副本修改完成后,再将修改后的数据复制回原始数据中。这种方式避免了锁的使用,从而提高了并发性能。

在Linux内核中,RCU的实现主要有两种方式:基于内核态RCU和基于用户态RCU。基于内核态RCU的实现使用了内核态的读写自旋锁和延迟释放等技术,而基于用户态RCU的实现则使用了用户态的线程库和内存管理等技术。

RCU的使用需要注意以下几点:

  1. RCU适用于读多写少的场景,对于写频繁的场景,使用RCU可能会降低性能。

  2. 在使用RCU时,需要确保读操作和写操作不会同时发生,否则可能会导致数据不一致的问题。

  3. RCU的实现需要占用一定的内存空间,因此需要根据实际情况调整RCU的参数。

RCU(Read-Copy-Update,读-复制-更新),它是基于其原理命名的。RCU并不是新的锁机制,早在20世纪80年代就有了这种机制,而在Linux中是在开发内核2.5.43时引入该技术的,并正式包含在2.6内核中。Linux社区关于RCU的经典文档位于https://www.kernel.org/doc/ols/2001/read-copy.pdf,Linux内核源代码Documentation/RCU/也包含了RCU的一些讲解。

信号量

信号量的基本概念是一个计数器,它可以被多个执行单元同时访问。当某个执行单元需要访问共享资源时,它会尝试获取信号量。如果信号量的计数器大于零,该执行单元将减少信号量的计数器并获得访问权。如果计数器为零,则该执行单元将被阻塞,直到另一个执行单元释放信号量并增加计数器。

Linux内核中提供了两种类型的信号量:二进制信号量和计数信号量。二进制信号量只有两个可能的值:0和1,用于控制对共享资源的互斥访问。计数信号量可以有任意数值,用于控制对共享资源的并发访问。

在Linux内核中,信号量通常使用信号量结构体来表示。这个结构体包含一个计数器,以及一个等待队列,用于存储被阻塞的执行单元。

在Linux内核中,信号量的头文件是<linux/semaphore.h>。这个头文件定义了信号量结构体struct semaphore,以及与信号量相关的函数和宏。

以下是一些常用的信号量函数和宏:

  • sema_init():用于初始化信号量。
  • init_MUTEX():用于初始化二进制信号量。
  • init_MUTEX_LOCKED():用于初始化一个已经被锁定的二进制信号量。
  • down():用于获取信号量。
  • down_interruptible():用于获取信号量,并且可以被中断。
  • up():用于释放信号量。
  • DECLARE_MUTEX():用于声明一个二进制信号量。
  • DECLARE_MUTEX_LOCKED():用于声明一个已经被锁定的二进制信号量。
  • DEFINE_SEMAPHORE():用于定义并初始化一个信号量。

需要注意的是,有些信号量函数和宏已经被弃用,不再建议使用。在编写Linux内核代码时,应该根据具体的内核版本和需求来选择合适的函数和宏。

互斥体

尽管信号量已经可以实现互斥的功能,但是“正宗”的mutex在Linux内核中还是真实地存在着。

在 Linux 内核中,互斥体相关的头文件是 linux/mutex.h。该头文件定义了互斥体相关的结构体、宏和函数原型。

下面是 linux/mutex.h 头文件中一些常用的结构体和宏:

  1. struct mutex:互斥体结构体,用于表示一个互斥体。

  2. DEFINE_MUTEX(mutexname):宏,用于定义并初始化一个互斥体。

  3. DECLARE_MUTEX(mutexname):宏,用于声明一个互斥体,但不初始化它。

  4. DECLARE_MUTEX_LOCKED(mutexname):宏,用于声明一个已经被锁定的互斥体。

下面是一些常用的互斥体相关函数原型:

  1. void mutex_init(struct mutex *lock):初始化一个互斥体。

  2. void mutex_destroy(struct mutex *lock):销毁一个互斥体。

  3. void mutex_lock(struct mutex *lock):锁定一个互斥体。

  4. int mutex_lock_interruptible(struct mutex *lock):锁定一个互斥体,如果等待期间被信号中断则返回 EINTR。

  5. int mutex_trylock(struct mutex *lock):尝试锁定一个互斥体,如果已经被锁定则立即返回 EBUSY。

  6. void mutex_unlock(struct mutex *lock):释放一个互斥体的锁定。

自旋锁和互斥体选用的3项原则。

  1. 当锁不能被获取到时,使用互斥体的开销是进程上下文切换时间,使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定)。若临界区比较小,宜使用自旋锁,若临界区很大,应使用互斥体。

  2. 互斥体所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区。因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。

  3. 互斥体存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在互斥体和自旋锁之间只能选择自旋锁。当然,如果一定要使用互斥体,则只能通过mutex_trylock()方式进行,不能获取就立即返回以避免阻塞。

完成量

Linux提供了完成量(Completion,关于这个名词,至今没有好的翻译,笔者将其译为“完成量”),它用于一个执行单元等待另一个执行单元执行完某事。

在Linux内核中,完成量的头文件为<linux/completion.h>。该头文件定义了完成量的数据结构和相关函数。

以下是该头文件中的一些重要结构和函数:

  1. struct completion:完成量结构,用于同步多个进程之间的操作。该结构包含一个等待队列和一个计数器。

  2. DECLARE_COMPLETION(comp):定义一个完成量变量,名称为comp

  3. init_completion(struct completion *):初始化完成量,将其计数器设置为0,并将等待队列清空。

  4. wait_for_completion(struct completion *):等待完成量的计数器达到0。如果计数器不为0,则当前进程将被阻塞并加入等待队列中,直到计数器为0。

  5. wait_for_completion_interruptible(struct completion *):与wait_for_completion(struct completion *)类似,但是如果进程接收到中断信号,则会返回-EINTR错误。

  6. complete(struct completion *):增加完成量的计数器,并唤醒等待队列中的进程。

  7. complete_all(struct completion *):增加完成量的计数器,并唤醒等待队列中的所有进程。