并发与竞争
并发与竞争
并发与竞争的产生
Linux是一个多任务操作系统,肯定会存在多个任务共同操作同一段内存或者设备的情况,多个任务甚至中断都能访问的资源叫做共享资源,就和共享单车一样。在驱动开发中要注意对共享资源的保护,也就是要处理对共享资源的并发访问。
并发就是多个“用户”同时访问同一个共享资源。
Linux 系统是个多任务操作系统,会存在多个任务同时访问同一片内存区域,这些任务可能会相互覆盖这段内存中的数据,造成内存数据混乱。针对这个问题必须要做处理,严重的话可能会导致系统崩溃。现在的 Linux 系统并发产生的原因很复杂,总结一下有下面几个主要原因:
①、多线程并发访问,Linux 是多任务(线程)的系统,所以多线程访问是最基本的原因。
②、抢占式并发访问,从 2.6 版本内核开始,Linux 内核支持抢占,也就是说调度程序可以在任意时刻抢占正在运行的线程,从而运行其他的线程。
③、中断程序并发访问,硬件中断的权利是很大的。
④、SMP(多核)核间并发访问,现在 ARM 架构的多核 SOC 很常见,多核 CPU 存在核间并发访问。
并发访问带来的问题就是竞争(临界区就是共享数据段),对于临界区必须保证一次只有一个线程访问,也就是要保证临界区是原子访问的,原子访问就表示这一个访问是一个步骤,不能再进行拆分。如果多个线程同时操作临界区就表示存在竞争,编写驱动的时候要注意避免并发和防止竞争访问。
原子操作
原子操作简介
原子操作就是指不能再进一步分割的操作,一般原子操作用于变量或者位操作。
例如C语言的a = 3
,对应的汇编是:
ldr r0, = 0X30000000 /* 变量 a 地址
*ldr r1, = 3
str r1, [r0] /* 将 3 写入到 a 变量中 */
一个最简单的设置变量值的并发与竞争的例子:
原子整形操作 API 函数
Linux 内核定义了叫做 atomic_t 的结构体来完成整形数据的原子操作,在使用中用原子变量来代替整形变量,此结构体定义在 include/linux/types.h 文件中,如果要使用原子操作 API 函数,首先要先定义一个 atomic_t 的变量。
typedef struct {int counter;
} atomic_t;
例如:一个定时器计数的变量可以定义为:atomic_t count;
定义原子变量的时候给原子变量赋初值:atomic_t b = ATOMIC_INIT(0);
不能直接(atomi_t b=0)
如果使用 64 位的 SOC 的话,就要用到 64 位的原子变量,Linux 内核也定义了 64 位原子结构体。
typedef struct {long counter;
} atomic64_t;
#endif
相应的也提供了 64 位原子变量的操作 API 函数,这里我们就不详细讲解了,和atomic_t的 API 函数有用法一样,只是将“atomic_”前缀换为“atomic64_”,将 int 换为 long long。
原子位操作 API 函数
函数 | 描述 |
---|---|
set_bit(int nr, void *p) | 将p地址的第nr位置1。 |
clear_bit(int nr, void *p) | 将p地址的第nr位清零。 |
change_bit(int nr, void *p) | 将p地址的第nr位进行翻转。 |
test_bit(int nr, void *p) | 获取p地址的第nr位的值。 |
test_and_set_bit(int nr, void *p) | 将p地址的第nr位置1,并且返回nr位原来的值。 |
test_and_clear_bit(int nr, void *p) | 将p地址的第nr位清零,并且返回nr位原来的值。 |
test_and_change_bit(int nr, void *p) | 将p地址的第nr位翻转,并且返回nr位原来的值。 |
自旋锁
原子操作只能对整形变量或者位进行保护,在实际的使用环境中不可能只有整形变量这么简单的临界区。例如:结构体变量就不是整型变量,对于结构体中成员变量的操作也要保证原子性,在线程 A 对结构体变量使用期间,应该禁止其他的线程来访问此结构体变量,这些工作原子操作都不能实现。
自旋锁简介
自旋锁(Spinlock)是 Linux 内核中用于同步不同 CPU 之间对共享资源访问的一种锁机制。当一个CPU 获得自旋锁时,它会阻止其他 CPU 获取同一把锁,直到锁被释放。自旋锁的主要特点是,等待锁的 CPU 不会进入睡眠状态,而是在循环中不断检查锁是否可用,因此称为“自旋”。
- 不可睡眠:持有自旋锁的 CPU 不能进入睡眠状态,这意味着自旋锁适用于不会被长时间持有的场景,否则可能会导致 CPU 资源的浪费。
- 无阻塞:请求自旋锁的进程不会释放 CPU,而是忙等待(busy-wait),直到获得锁。
- 适用于中断上下文:自旋锁可以在中断处理程序中使用,因为中断处理程序不能睡眠。
- 避免死锁:由于自旋锁不会导致进程睡眠,因此不会导致死锁。
- 性能开销:在单核处理器上,自旋锁实际上是无操作,因为不存在多个 CPU 竞争的问题。但在多核处理器上,如果锁被长时间持有,会导致其他 CPU 忙等,从而浪费 CPU 资源。
Linux 内核使用结构体 spinlock_t 表示自旋锁,结构体定义如下所示:
#include <linux/spinlock.h>
typedef struct spinlock {union {struct raw_spinlock rlock;#ifdef CONFIG_DEBUG_LOCK_ALLOC
# define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))struct {u8 __padding[LOCK_PADSIZE];struct lockdep_map dep_map;};
#endif};
} spinlock_t;
定义自旋锁:spinlock_t lock;
自旋锁 API 函数
函数 | 描述 |
---|---|
DEFINE_SPINLOCK(spinlock_t lock) | 定义并初始化一个自旋锁变量。 |
spin_lock_init(spinlock_t *lock) | 初始化自旋锁。 |
spin_lock(spinlock_t *lock) | 获取指定的自旋锁,也叫做加锁。 |
spin_unlock(spinlock_t *lock) | 释放指定的自旋锁。 |
spin_trylock(spinlock_t *lock) | 尝试获取指定的自旋锁,如果没有获取到就返回0。 |
spin_is_locked(spinlock_t *lock) | 检查指定的自旋锁是否被获取,如果没有被获取就返回非0,否则返回0。 |
自旋锁API 函数适用于 SMP(多核)或支持抢占的单 CPU下线程之间的并发访问,也就是用于线程与线程之间,被自旋锁保护的临界区一定不能调用任何能够引起睡眠和阻塞的API 函数,否则的话会可能会导致死锁现象的发生。自旋锁会自动禁止抢占,也就说当线程 A得到锁以后会暂时禁止内核抢占。如果线程 A 在持有锁期间进入了休眠状态,那么线程 A 会自动放弃 CPU 使用权。线程 B 开始运行,线程 B 也想要获取锁,但是此时锁被 A 线程持有,而且内核抢占还被禁止了!线程 B 无法被调度出去,那么线程 A 就无法运行,锁也就无法释放,死锁发生了!
中断需要访问共享资源
中断里面可以使用自旋锁,但是在中断里面使用自旋锁的时候,在获取锁之前一定要先禁止本地中断(也就是本CPU中断,对于多核 SOC来说会有多个 CPU 核),否则可能导致锁死现象的发生。
发生死锁的情况:线程 A 先运行,并且获取到了 lock 这个锁,当线程 A 运行 functionA 函数的时候中断发生了,中断抢走了 CPU 使用权。右边的中断服务函数也要获取 lock 这个锁,但是这个锁被线程 A 占有着,中断就会一直自旋,等待锁有效。但是在中断服务函数执行完之前,线程 A 是不可能执行的。
最好的解决方法就是获取锁之前关闭本地中断,Linux 内核提供了相应的 API 函数:
函数(include\linux\spinlock.h) | 描述 |
---|---|
void spin_lock_irq(spinlock_t *lock) | 禁止本地中断,并获取自旋锁 |
void spin_unlock_irq(spinlock_t *lock) | 激活本地中断,并释放自旋锁 |
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags) | 保存中断状态,禁止本地中断,并获取自旋锁 |
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags) | 将中断恢复到以前的状态,并且激活本地中断,释放自旋锁 |
使用方法:
使用 spin_lock_irq/spin_unlock_irq 的时候需要用户能够确定加锁之前的中断状态,但实际上很难确定中断状态,因此不推荐使用spin_lock_irq/spin_unlock_irq。建议使用 spin_lock_irqsave/ spin_unlock_irqrestore,因为这一组函数会保存中断状态,在释放锁的时候会恢复中断状态。一般在线程中使用 spin_lock_irqsave/spin_unlock_irqrestore,在中断中使用 spin_lock/spin_unlock,示例代码如下所示:
DEFINE_SPINLOCK(lock)/* 定义并初始化一个锁 */
/* 线程 A */
void functionA (){
unsigned long flags;/* 中断状态*/
spin_lock_irqsave(&lock, flags)/* 获取锁*/
/* 临界区 */
spin_unlock_irqrestore(&lock, flags)/* 释放锁*/
}/* 中断服务函数 */
void irq() {
spin_lock(&lock)/* 获取锁*/
/* 临界区 */
spin_unlock(&lock)/* 释放锁*/
}
下半部(BH)也会竞争共享资源,有些资料也会将下半部叫做底半部。如果要在下半部里面使用自旋锁,可以使用下面的API 函数:
函数(include\linux\spinlock.h) | 描述 |
---|---|
void spin_lock_bh(spinlock_t *lock) | 关闭下半部,并获取自旋锁。 |
void spin_unlock_bh(spinlock_t *lock) | 打开下半部,并释放自旋锁。 |
其它类型的自旋锁
读写自旋锁
现在有个学生信息表,此表存放着学生的年龄、家庭住址、班级等信息,此表可以随时被修改和读取。此表肯定是数据,那么必须要对其进行保护,如果我们现在使用自旋锁对其进行保护。每次只能一个读操作或者写操作,但是,实际上此表是可以并发读取的。只需要保证在修改此表的时候没人读取,或者在其他人读取此表的时候没有人修改此表就行了。也就是此表的读和写不能同时进行,但是可以多人并发的读取此表。像这样,当某个数据结构符合读/写或生产者/消费者模型的时候就可以使用读写自旋锁。读写自旋锁为读和写操作提供了不同的锁,一次只能允许一个写操作,也就是只能一个线程持有写锁,而且不能进行读操作。但是当没有写操作的时候允许一个或多个线程持有读锁,可以进行并发的读操作。
Linux 内核使用 rwlock_t
结构体表示读写锁。头文件包含:<linux/rwlock.h>
函数 | 描述 |
---|---|
DEFINERWLOCK(rwlock_t lock) | 定义并初始化读写锁。 |
void rwlock_init(rwlock_t *lock) | 初始化读写锁。 |
读锁 | |
void read_lock(rwlock_t *lock) | 获取读锁。 |
void read_unlock(rwlock_t *lock) | 释放读锁。 |
void read_lock_irq(rwlock_t *lock) | 禁止本地中断,并且获取读锁。 |
void read_unlock_irq(rwlock_t *lock) | 打开本地中断,并且释放读锁。 |
void read_lock_irqsave(rwlock_t *lock, unsigned long flags) | 保存中断状态,禁止本地中断,并获取读锁。 |
void read_unlock_irqrestore(rwlock_t *lock, unsigned long flags) | 将中断状态恢复到以前的状态,并且激活本地中断,释放读锁 |
void read_lock_bh(rwlock_t *lock) | 关闭下半部,并获取读锁。 |
void read_unlock_bh(rwlock_t *lock) | 打开下半部,并释放读锁。 |
写锁 | |
void write_lock(rwlock_t *lock) | 获取写锁。 |
void write_unlock(rwlock_t *lock) | 释放写锁。 |
void write_lock_irq(rwlock_t *lock) | 禁止本地中断,并且获取写锁。 |
void write_unlock_irq(rwlock_t *lock) | 打开本地中断,并且释放写锁。 |
void write_lock_irqsave(rwlock_t *lock, unsigned long flags) | 保存中断状态,禁止本地中断,并获取写锁。 |
void write_unlock_irqrestore(rwlock_t *lock, unsigned long flags) | 将中断状态恢复到以前的状态,并且激活本地 |
中断,释放读锁。 | |
void write_lock_bh(rwlock_t *lock) | 关闭下半部,并获取读锁 |
void write_unlock_bh(rwlock_t *lock) | 打开下半部,并释放读锁。 |
顺序锁
自旋锁使用注意事项
①、因为在等待自旋锁的时候处于“自旋”状态,因此锁的持有时间不能太长,一定要短,否则的话会降低系统性能。如果临界区比较大,运行时间比较长的话要选择其他的并发处理方式,比如稍后要讲的信号量和互斥体。
②、自旋锁保护的临界区内不能调用任何可能导致线程休眠的 API 函数,否则的话可能导致死锁。
③、不能递归申请自旋锁,因为一旦通过递归的方式申请一个你正在持有的锁,那么你就必须“自旋”,等待锁被释放,然而你正处于“自旋”状态,根本没法释放锁。结果就是自己把自己锁死了!
④、在编写驱动程序的时候我们必须考虑到驱动的可移植性,因此不管你用的是单核的还是多核的 SOC,都将其当做多核 SOC 来编写驱动程序。
信号量
信号量(Semaphore)是操作系统中用于控制多个进程或线程对共享资源访问的一种同步机制。它是一个计数器,用于多进程(或线程)之间的同步,以及提供对共享资源的访问控制。信号量主要解决的问题是资源共享和互斥。
信号量和自旋锁的区别
• 阻塞与非阻塞:
自旋锁:当一个线程尝试获取一个已经被其他线程持有的自旋锁时,它会进入忙等(busy-wait)状态,即不断检查锁是否可用,而不进入睡眠状态。这意味着CPU资源会被占用,因为线程在不断地检查锁的状态。
信号量:当一个线程尝试获取一个不可用的信号量时,它会被阻塞,即操作系统将其挂起,不会占用CPU资源直到信号量变为可用状态。
• 使用场景:
自旋锁:适用于持有锁的时间非常短的情况,因为忙等避免了线程上下文切换的开销,但如果锁被持有的时间较长,会导致CPU资源浪费。
信号量:适用于持有锁的时间可能较长的情况,或者需要同步的资源数量较多时,因为它们允许线程在等待时释放CPU资源。
• 上下文切换:
自旋锁:不涉及上下文切换,因为线程在等待锁时仍然保持运行状态。
信号量:涉及上下文切换,因为线程在等待信号量时会被挂起,直到信号量可用时再被唤醒。
• 优先级问题:
自旋锁:可能导致优先级反转问题,因为低优先级的线程持有锁时,高优先级的线程可能无法获取锁而陷入忙等。
信号量:不会导致优先级反转问题,因为线程在等待信号量时会被阻塞,不会占用CPU资源。
• 可中断性:
自旋锁:通常不可中断,线程在忙等期间不能响应中断。
信号量:可以是可中断的,线程在等待信号量时可以响应中断并处理。总的来说,自旋锁适用于需
信号量的开销要比自旋锁大,因为信号量使线程进入休眠状态以后会切换线程,切换线程会有开销。
如果共享资源的持有时间比较短,那就不适合使用信号量了,因为频繁的休眠、切换线程引起的开销要远大于信号量带来的那点优势。
计数信号量:当信号量的值可以大于1时,它被称为计数信号量。这种类型的信号量可以用来控制多个相同的源。
二值信号量:当信号量的值只能是0或1时,它被称为二进制信号量或互斥锁。用于确保只有一个线程或进程可以访问特定的资源。