Linux 设备驱动并发控制分析(基于Linux6.6)---并发控制分析
一、基础概念
1.1、Linux 并发相关基础概念
a -- 并发(concurrency):并发指的是多个执行单元同时、并发被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(race condition);
b -- 竞态(race condition) :竞态简单的说就是两个或两个以上的进程同时访问一个资源,同时引起资源的错误;
c -- 临界区(Critical Section):每个进程中访问临界资源的那段代码称为临界区;
d -- 临界资源 :一次仅允许一个进程使用的资源称为临界资源;多道程序系统中存在许多进程,它们共享各种资源,然而有很多资源一次只能供一个进程使用;
在宏观上并行或者真正意义上的并行(这里为什么是宏观意义的并行呢?时间片”这个概念,微观上还是串行的,所以这里称为宏观上的并行),可能会导致竞争; 类似两条十字交叉的道路上运行的车。当他们同一时刻要经过共同的资源(交叉点)的时候,如果没有交通信号灯,就可能出现混乱。在linux 系统中也有可能存在这种情况:
1.2、并发产生的场合
a -- 对称多处理器(SMP)的多个CPU
SMP 是一种共享存储的系统模型,它的特点是多个CPU使用共同的系统总线,因此可访问共同的外设和储存器,这里可以实现真正的并行;
b -- 单CPU内进程与抢占它的进程
一个进程在内核执行的时候有可能被另一个高优先级进程打断;
c -- 中断和进程之间
中断可以打断正在执行的进程,如果中断处理函数程序访问进程正在访问的资源,则竞态也会发生;
1.3、解决竞态问题的途径
为了解决竞态问题,常用的途径主要包括同步机制、原子操作、锁机制以及无锁编程等。下面详细介绍一些常见的解决竞态条件的方式。
1. 使用互斥锁(Mutex)
互斥锁是最常用的同步机制,用于确保在同一时刻只有一个线程能够访问共享资源,从而避免竞态条件。
- 作用:通过在访问共享资源时加锁,其他线程在锁被释放之前无法访问该资源。
- 使用方式:
- 使用
pthread_mutex_t
创建互斥锁(在 POSIX 线程编程中使用pthread
库)。 - 在访问共享资源前使用
pthread_mutex_lock()
上锁,访问结束后使用pthread_mutex_unlock()
解锁。
- 使用
示例代码:
#include <pthread.h>
pthread_mutex_t lock;
void* thread_func(void* arg) {
pthread_mutex_lock(&lock); // 上锁
// 访问共享资源
pthread_mutex_unlock(&lock); // 解锁
return NULL;
}
int main() {
pthread_t threads[2];
pthread_mutex_init(&lock, NULL); // 初始化锁
for (int i = 0; i < 2; i++) {
pthread_create(&threads[i], NULL, thread_func, NULL);
}
for (int i = 0; i < 2; i++) {
pthread_join(threads[i], NULL);
}
pthread_mutex_destroy(&lock); // 销毁锁
return 0;
}
2. 使用读写锁(Read-Write Lock)
在某些场景下,多个线程只需要读取共享资源,而不需要修改资源。读写锁可以允许多个线程并发地读取资源,但在写操作时,需要对共享资源加锁,确保只有一个线程能够写入。
- 作用:读写锁通过分离读锁和写锁来优化性能,当资源只是被读取时,多个线程可以同时访问;只有在写操作时,才会对资源加写锁。
- 使用方式:
- 在 POSIX 系统中,可以使用
pthread_rwlock_t
类型。 - 通过
pthread_rwlock_rdlock()
获取读锁,pthread_rwlock_wrlock()
获取写锁。
- 在 POSIX 系统中,可以使用
示例代码:
#include <pthread.h>
pthread_rwlock_t rwlock;
void* reader_func(void* arg) {
pthread_rwlock_rdlock(&rwlock); // 获取读锁
// 读取共享资源
pthread_rwlock_unlock(&rwlock); // 释放锁
return NULL;
}
void* writer_func(void* arg) {
pthread_rwlock_wrlock(&rwlock); // 获取写锁
// 修改共享资源
pthread_rwlock_unlock(&rwlock); // 释放锁
return NULL;
}
int main() {
pthread_t threads[3];
pthread_rwlock_init(&rwlock, NULL); // 初始化读写锁
// 创建读线程
pthread_create(&threads[0], NULL, reader_func, NULL);
pthread_create(&threads[1], NULL, reader_func, NULL);
// 创建写线程
pthread_create(&threads[2], NULL, writer_func, NULL);
for (int i = 0; i < 3; i++) {
pthread_join(threads[i], NULL);
}
pthread_rwlock_destroy(&rwlock); // 销毁读写锁
return 0;
}
3. 使用信号量(Semaphore)
信号量是一种用于进程间或线程间同步和互斥的机制。信号量通常用于控制对共享资源的访问,通过控制信号量的值来限制并发访问资源的线程数。
- 作用:信号量可以用来控制并发线程数(例如,限制同时访问共享资源的线程数),或者用于线程间的协调。
- 使用方式:
- 在 POSIX 中,使用
sem_t
类型来表示信号量,通过sem_wait()
和sem_post()
控制信号量的增加和减少。
- 在 POSIX 中,使用
示例代码:
#include <pthread.h>
#include <semaphore.h>
sem_t sem;
void* thread_func(void* arg) {
sem_wait(&sem); // 获取信号量
// 访问共享资源
sem_post(&sem); // 释放信号量
return NULL;
}
int main() {
pthread_t threads[2];
sem_init(&sem, 0, 1); // 初始化信号量,初值为1,表示互斥锁
for (int i = 0; i < 2; i++) {
pthread_create(&threads[i], NULL, thread_func, NULL);
}
for (int i = 0; i < 2; i++) {
pthread_join(threads[i], NULL);
}
sem_destroy(&sem); // 销毁信号量
return 0;
}
4. 使用自旋锁(Spinlock)
自旋锁是另一种常见的锁机制,与互斥锁不同的是,当线程无法获得锁时,它会不断“自旋”检查锁的状态,而不是主动让出 CPU。自旋锁适用于锁持有时间非常短的场景。
- 作用:适用于锁的竞争较少,锁的持有时间较短的情况。自旋锁比互斥锁开销小,但可能导致 CPU 的浪费。
- 使用方式:
- 在 Linux 中,使用
spinlock_t
类型,可以通过spin_lock()
和spin_unlock()
函数进行操作。
- 在 Linux 中,使用
示例代码:
#include <pthread.h>
#include <linux/spinlock.h>
spinlock_t lock;
void* thread_func(void* arg) {
spin_lock(&lock); // 上锁
// 访问共享资源
spin_unlock(&lock); // 解锁
return NULL;
}
int main() {
pthread_t threads[2];
spin_lock_init(&lock); // 初始化自旋锁
for (int i = 0; i < 2; i++) {
pthread_create(&threads[i], NULL, thread_func, NULL);
}
for (int i = 0; i < 2; i++) {
pthread_join(threads[i], NULL);
}
return 0;
}
5. 原子操作(Atomic Operations)
原子操作是指不可分割的操作,它们在执行过程中不会被中断。通过原子操作,可以避免在对共享资源进行修改时发生竞态条件。
- 作用:通过原子操作,多个线程可以并发地修改共享资源,而无需使用传统的锁机制,从而提高效率。
- 使用方式:
- 在 Linux 中,提供了一些原子操作函数,如
atomic_add()
,atomic_sub()
,atomic_cmpxchg()
等。它们确保操作在执行时不可被中断。
- 在 Linux 中,提供了一些原子操作函数,如
示例代码:
#include <stdatomic.h>
atomic_int counter = 0;
void* thread_func(void* arg) {
atomic_fetch_add(&counter, 1); // 原子增加
return NULL;
}
int main() {
pthread_t threads[2];
for (int i = 0; i < 2; i++) {
pthread_create(&threads[i], NULL, thread_func, NULL);
}
for (int i = 0; i < 2; i++) {
pthread_join(threads[i], NULL);
}
printf("Counter: %d\n", atomic_load(&counter)); // 输出结果应该为 2
return 0;
}
6. 无锁编程(Lock-Free Programming)
无锁编程是一种避免使用传统锁机制的并发编程技术,它通过原子操作和精心设计的数据结构来避免锁的使用,减少了因锁带来的性能开销。
- 作用:通过无锁的数据结构(如无锁队列、栈等),能够在多线程环境下进行高效的并发操作,而不会造成阻塞和锁竞争。
- 技术:无锁编程通常依赖于 CPU 提供的原子操作,如 CAS(Compare And Swap)等。
7. 避免死锁
死锁是多线程程序中常见的问题,它发生在多个线程相互等待对方释放资源的情况下。避免死锁的一些常见方法包括:
- 锁的顺序:确保所有线程按固定的顺序申请锁,避免循环依赖。
- 死锁检测:定期检查线程是否因锁而阻塞。
- 超时机制:当锁在规定时间内没有获取到时,放弃并尝试重新获得锁。
二、并发处理途径详解
2.1、中断屏蔽
在单CPU范围内避免静态的一种简单而省事的方法是在进入临界区之前屏蔽系统的中断,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞争条件的发生。具体而言
a -- 中断屏蔽将使得中断和进程之间的并发不再发生;
b -- 由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也得以避免;
中断屏蔽的使用方法:
local_irq_disable()
local_irq_enable()
只能禁止和使能本地CPU的中断,所以不能解决多CPU引发的竞态
local_irq_save(flags)
local_irq_restore(flags)
除了能禁止和使能中断外,还保存和还原目前的CPU中断位信息
local_bh_disable()
local_bh_disable()
如果只是想禁止中断的底半部,这是个不错的选择。
但是要注意:
a -- 中断对系统正常运行很重要,长时间屏蔽很危险,有可能造成数据丢失乃至系统崩溃,所以中断屏蔽后应尽可能快的执行完毕。
b -- 宜与自旋锁联合使用。
所以,不建议使用中断屏蔽。
2.2、原子操作
原子操作(分为原子整型操作和原子位操作)就是绝不会在执行完毕前被任何其他任务和时间打断,不会执行一半,又去执行其他代码。原子操作需要硬件的支持,因此是架构相关的,其API和原子类型的定义都在include/asm/atomic.h中,使用汇编语言实现。
在linux中,原子变量的定义如下:
typedef struct {
volatile int counter;
} atomic_t;
关键字volatile用来暗示GCC不要对该类型做数据优化,所以对这个变量counte的访问都是基于内存的,不要将其缓冲到寄存器中。存储到寄存器中,可能导致内存中的数据已经改变,而寄存其中的数据没有改变。
原子整型操作:
1)定义atomic_t变量:
#define ATOMIC_INIT(i) ( (atomic_t) { (i) } )
atomic_t v = ATOMIC_INIT(0); //定义原子变量v并初始化为0
2)设置原子变量的值:
#define atomic_set(v,i) ((v)->counter = (i))
void atomic_set(atomic_t *v, int i);//设置原子变量的值为i
3)获取原子变量的值:
#define atomic_read(v) ((v)->counter + 0)
atomic_read(atomic_t *v);//返回原子变量的值
4)原子变量加/减:
static __inline__ void atomic_add(int i, atomic_t * v); //原子变量增加i
static __inline__ void atomic_sub(int i, atomic_t * v); //原子变量减少i
5)原子变量自增/自减:
#define atomic_inc(v) atomic_add(1, v); //原子变量加1
#define atomic_dec(v) atomic_sub(1, v); //原子变量减1
6)操作并测试:
//这些操作对原子变量执行自增,自减,减操作后测试是否为0,是返回true,否则返回false
#define atomic_inc_and_test(v) (atomic_add_return(1, (v)) == 0)
static inline int atomic_add_return(int i, atomic_t *v)
原子操作的优点编写简单;缺点是功能太简单,只能做计数操作,保护的东西太少。下面看一个实例:
static atomic_t v=ATOMIC_INIT(1);
static int hello_open (struct inode *inode, struct file *filep)
{
if(!atomic_dec_and_test(&v))
{
atomic_inc(&v);
return -EBUSY;
}
return 0;
}
static int hello_release (struct inode *inode, struct file *filep)
{
atomic_inc(&v);
return 0;
}
2.3、自旋锁
自旋锁是专为防止多处理器并发而引入的一种锁,它应用于中断处理等部分。对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,不需要自旋锁。
自旋锁最多只能被一个内核任务持有,如果一个内核任务试图请求一个已被争用(已经被持有)的自旋锁,那么这个任务就会一直进行忙循环——旋转——等待锁重新可用(忙等待,即当一个进程位于其临界区内,任何试图进入其临界区的进程都必须在进入代码连续循环)。要是锁未被争用,请求它的内核任务便能立刻得到它并且继续进行。自旋锁可以在任何时刻防止多于一个的内核任务同时进入临界区,因此这种锁可有效地避免多处理器上并发运行的内核任务竞争共享资源。
1)自旋锁的使用:
spinlock_t spin; //定义自旋锁
spin_lock_init(lock); //初始化自旋锁
spin_lock(lock); //成功获得自旋锁立即返回,否则自旋在那里直到该自旋锁的保持者释放
spin_trylock(lock); //成功获得自旋锁立即返回真,否则返回假,而不是像上一个那样"在原地打转"
spin_unlock(lock);//释放自旋锁
下面是一个实例:
static spinlock_t lock;
static int flag = 1;
static int hello_open (struct inode *inode, struct file *filep)
{
spin_lock(&lock);
if(flag !=1)
{
spin_unlock(&lock);
return -EBUSY;
}
flag = 0;
spin_unlock(&lock);
return 0;
}
static int hello_release (struct inode *inode, struct file *filep)
{
flag = 1;
return 0;
}
自旋锁主要针对SMP或单CPU但内核可抢占的情况,对于单CPU和内核不支持的抢占的系统,自旋锁退化为空操作(因为自旋锁本身就需进行内核抢占)。在单CPU和内核可抢占的系统中,自旋锁持有期间内核的抢占将被禁止。由于内核可抢占的单CPU系统的行为实际很类似于SMP系统,因此,在这样的单CPU系统中使用自旋锁仍十分重要。
尽管用了自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候,还可能受到中断和底半部的影响。为了防止这种影响。为了防止影响,就需要用到自旋锁的衍生。
2)注意事项:
a -- 自旋锁是一种忙等待。它是一种适合短时间锁定的轻量级的加锁机制。
b -- 自旋锁不能递归使用。自旋锁被设计成在不同线程或者函数之间同步。这是因为,如果一个线程在已经持有自旋锁时,其处于忙等待状态,则已经没有机会释放自己持有的锁了。如果这时再调用自身,则自旋锁永远没有执行的机会了,即造成“死锁”。
【自旋锁导致死锁的实例】
1)a进程拥有自旋锁,在内核态阻塞的,内核调度进程b,b也要或得自旋锁,b只能自旋,而此时抢占已经关闭了,a进程就不会调度到了,b进程永远自旋。
2)进程a拥有自旋锁,中断来了,cpu执行中断,中断处理函数也要获得锁访问共享资源,此时也获得不到锁,只能死锁。
3)内核抢占
内核抢占是上面提到的一个概念,不管当前进程处于内核态还是用户态,都会调度优先级高的进程运行,停止当前进程;当我们使用自旋锁的时候,抢占是关闭的。
4)自旋锁有几个重要的特性:
a -- 被自旋锁保护的临界区代码执行时不能进入休眠。
b -- 被自旋锁保护的临界区代码执行时是不能被被其他中断中断。
c -- 被自旋锁保护的临界区代码执行时,内核不能被抢占。
从这几个特性可以归纳出一个共性:被自旋锁保护的临界区代码执行时,它不能因为任何原因放弃处理器。
2.4、信号量
linux中,提供了两种信号量:一种用于内核程序中,一种用于应用程序中。这里只讲属前者
信号量和自旋锁的使用方法基本一样。与自旋锁相比,信号量只有当得到信号量的进程或者线程时才能够进入临界区,执行临界代码。信号量和自旋锁的最大区别在于:当一个进程试图去获得一个已经锁定的信号量时,进程不会像自旋锁一样在远处忙等待。
信号量是一种睡眠锁。如果有一个任务试图获得一个已被持有的信号量时,信号量会将其推入等待队列,然后让其睡眠。这时处理器获得自由去执行其它代码。当持有信号量的进程将信号量释放后,在等待队列中的一个任务将被唤醒,从而便可以获得这个信号量。
1)信号量的实现:
在linux中,信号量的定义如下:
struct semaphore {
spinlock_t lock; //用来对count变量起保护作用。
unsigned int count; // 大于0,资源空闲;等于0,资源忙,但没有进程等待这个保护的资源;小于0,资源不可用,并至少有一个进程等待资源。
struct list_head wait_list; //存放等待队列链表的地址,当前等待资源的所有睡眠进程都会放在这个链表中。
};
2)信号量的使用:
static inline void sema_init(struct semaphore *sem, int val); //设置sem为val
#define init_MUTEX(sem) sema_init(sem, 1) //初始化一个用户互斥的信号量sem设置为1
#define init_MUTEX_LOCKED(sem) sema_init(sem, 0) //初始化一个用户互斥的信号量sem设置为0
定义和初始化可以一步完成:
DECLARE_MUTEX(name); //该宏定义信号量name并初始化1
DECLARE_MUTEX_LOCKED(name); //该宏定义信号量name并初始化0
当信号量用于互斥时(即避免多个进程同是在一个临界区运行),信号量的值应初始化为1。这种信号量在任何给定时刻只能由单个进程或线程拥有。在这种使用模式下,一个信号量有时也称为一个“互斥体(mutex)”,它是互斥(mutual exclusion)的简称。Linux内核中几乎所有的信号量均用于互斥。
使用信号量,内核代码必须包含<asm/semaphore.h> 。
3)获取(锁定)信号量:
void down(struct semaphore *sem);
int down_interruptible(struct semaphore *sem);
int down_killable(struct semaphore *sem);
4)释放信号量
void up(struct semaphore *sem);
下面看一个实例:
//定义和初始化
static struct semaphore sem;
sema_init(&sem,1);
static int hello_open (struct inode *inode, struct file *filep)
{
// p操作,获得信号量,保护临界区
if(down_interruptible(&sem))
{
//没有获得信号量
return -ERESTART;
}
return 0;
}
static int hello_release (struct inode *inode, struct file *filep)
{
//v操作,释放信号量
up(&sem);
return 0;
}
三、自旋锁与信号量的比较
信号量 | 自旋锁 | |
1、开销成本 | 进程上下文切换时间 | 忙等待获得自旋锁时间 |
2、特性 | a -- 导致阻塞,产生睡眠 b -- 进程级的(内核是代表进程来争夺资源的) | a -- 忙等待,内核抢占关闭 b -- 主要是用于CPU同步的 |
3、应用场合 | 只能运行于进程上下文 | 还可以出现中断上下文 |
4、其他 | 还可以出现在用户进程中 | 只能在内核线程中使用 |
从以上的区别以及本身的定义可以推导出两都分别适应的场合。只考虑内核态
四、总结
除了上述几种广泛使用的的并发控制机制外,还有中断屏蔽、顺序锁(seqlock)、RCU(Read-Copy-Update)等等,做个简单总结如下图:
同步机制 | 类型 | 定义 | 性能特点 | 应用场景 | 优缺点 |
---|---|---|---|---|---|
Spinlock | 互斥锁 | 一种自旋锁,通过忙等来获取锁,线程会在锁被占用时不断检查锁是否可用。 | 非常高效,但忙等待会消耗 CPU 时间。适用于锁保持时间短的场合。 | 短时间内需要保证线程互斥的场景,如处理内核数据结构时。 | 优:性能好,锁粒度小。缺:锁持有时间长时会浪费 CPU 时间,可能导致死锁。 |
信号量(Semaphore) | 计数信号量 | 计数信号量可以控制资源的数量,限制访问某一共享资源的线程数量。 | 较重,系统开销大,适用于需要控制资源数量的场景。 | 控制对共享资源的访问,如文件系统的读写。 | 优:适用于资源池控制。缺:操作系统开销大,可能导致性能下降。 |
原子操作 | 无锁同步 | 原子操作确保某个操作不可被中断,通常用于简单的计数器等。 | 极其高效,不会引发上下文切换。适用于多线程并发操作。 | 用于简单的计数器或标志位更新。 | 优:效率高,无锁实现。缺:适用于简单操作,不能处理复杂同步。 |
RWLock(读写锁) | 互斥锁 | 读写锁允许多个线程同时读取共享资源,但写操作是互斥的。 | 写操作的性能较差,但读操作效率高。 | 适用于读多写少的场景,如数据库访问。 | 优:读多写少的场景性能好。缺:写操作性能差,读写冲突时性能下降。 |
顺序锁(Seqlock) | 无锁同步 | 顺序锁适用于读多写少的场景,通过序列号避免并发读写冲突。 | 适用于读操作频繁的场景,写操作时需要更新序列号。 | 高并发读写的场景,如高速缓存更新。 | 优:适用于高并发读,低并发写的场景。缺:写操作较慢,复杂场景不适用。 |
RCU(Read-Copy-Update) | 无锁同步 | RCU 允许读操作在不需要加锁的情况下执行,写操作会更新副本,最终切换到新副本。 | 高效的读操作,写操作较复杂,但适合高并发读的场景。 | 高并发读少写的场景,如内核数据结构的访问。 | 优:读性能极好。缺:写操作复杂,内存管理难度大。 |
关闭中断 | 内部同步 | 通过禁用中断来避免中断服务程序在执行过程中打断当前操作。 | 适合短时间的锁保护,避免中断带来的上下文切换。 | 用于在内核中执行不可中断的操作时保证原子性。 | 优:高效,适用于需要高原子性的场景。缺:会影响系统响应性,避免长时间关闭中断。 |