并发与竞争
在驱动开发中要注意对共享资源的保护,也就是要处理对共享资源的并发访问,在 Linux 驱动编写过程中对于并发控制的管理非常重要。
并发与竞争
并发与竞争简介
思考问题:
小李和小王要同时使用这一台打印机,都要打印一份文件。—并发
都争着要打印,结果打印了一份儿混乱的文件。—竞争
Linux 系统也是如此,Linux 系统是个多任务操作系统:
会存在多个任务同时访问同一片内存区域,—并发
可能会相互覆盖这段内存中的数据,造成内存数据混乱。—竞争
针对这个问题必须要做处理,严重的话可能会导致系统崩溃。有以下几种
①、多线程并发访问, Linux 是多任务(线程)的系统,所以多线程访问是最基本的原因。
②、抢占式并发访问,从 2.6 版本内核开始, Linux 内核支持抢占,也就是说调度程序可以在任意时刻抢占正在运行的线程,从而运行其他的线程。
③、中断程序并发访问,这个无需多说,学过 STM32 的同学应该知道,硬件中断的权利可是很大的。
④、 SMP(多核)核间并发访问,现在 ARM 架构的多核 SOC 很常见,多核 CPU 存在核间并发访问。
临界区:所谓的临界区就是共享数据段,对于临界区必须保证一次只有一个线程访问,也就是要保证临界区是原子访问的。我们都知道,原子是化学反应不可再分的基本微粒,这里的原子访问就表示这一个访问是一个步骤,不能再进行拆分。如果多个线程同时操作临界区就表示存在竞争,我们在编写驱动的时候一定要注意避免并发和防止竞争访问。
保护内容是什么
是保护共享资源,防止进行并发访问,保护的不是代码,而是数据!多个线程都会访问的共享数据那个数据呀。
1、全局变量,而不是某个线程的局部变量。
2、打印的文档也是数据。
3、驱动程序中设备结构体。
原子操作
原子操作简介
原子操作就是指不能再进一步分割的操作,一般原子操作用变量
或者位操作
。
for example:
一个线程给一个变量赋值:
a=3
||汇编后
\/
ldr r0, =0X30000000 /* 变量 a 地址 */
ldr r1, = 3 /* 要写入的值 */
str r1, [r0] /* 将 3 写入到 a 变量中 */
那么,线程A和线程B同时对一个变量a赋值:
理想情况:
实际上可能的情况:
完蛋了!两个并发的产生竞争了。
这就需要将三行的汇编操作看作是一组原子操作
,要执行一块儿执行。
这样的功能当然有linux内核的API函数承担啦。
原子整型操作 API 函数
普通C语言中:定义变量int a,可进行加减操作a++,a–。
linux驱动中的原子整型:定义变量使用了新的结构体,和普通C语言中类似,都是基本的加减操作,不过就是添加了原子整型操作,变得更安全了。
Cortex-A7 是 32 位的架构,所以以下都是32位的。
typedef struct {
int counter;
} atomic_t;
atomic_t a; //定义 a
atomic_t b = ATOMIC_INIT(0); //定义原子变量 b 并赋初值为 0
linux内核生活中常用也提供了大量的API原子操作函数修改变量的值:
原子位操作 API 函数
Linux 内核也提供了一系列的原子位操作 API 函数,原子位操作是直接对内存进行操作,API 函数如表:
自旋锁
自旋锁简介
除了变量
和位
等数据需要保护,还有设备结构体变量
需要被保护,这就需要自旋锁出马啦。。。
as the saying gose,“占着茅坑不拉屎,多个进程要进行什么操作必须一个一个来”。
for example steps:
1、一个线程A
正在使用设备结构体变量dev_struct
;
2、结构体变量
处于自旋锁状态,只对线程A
开放;
3、另一个线程B
想要访问结构体变量,自己只能在原地打转;
4、等到线程A
用完了,线程B
才能使用。
自旋锁适用于短时期的轻量级加锁,如果遇到需要长时间持有锁的场景那就需要换其他的方法了。
自旋锁 API 函数
同样,linux内核中自旋锁的操作也有提供API函数,并使用了新的结构体来存设备结构体变量。
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; //定义自旋锁
linux内核生活中常用也提供了一部分的API自旋操作函数修改变量的值:
自旋锁API函数适用于SMP或支持抢占的单CPU下线程之间的并发访问,也就是用于线程与线程之间。
将会遇到的问题:
1、被自旋锁保护的临界区一定不能调用任何能够引起睡眠和阻塞的API 函数,否则的话会可能会导致死锁现象的发生。(线程A正用着变量呢,突然被睡眠了,那么这个自旋锁除了睡着的线程A都无法访问)
2、使用自旋锁的时候,一个线程在获取锁之前一定要先禁止本地中断(也就是本 CPU 中断,对于多核 SOC来说会有多个 CPU 核)。
听一个小故事:中断随时都有可能发生且中断也能获取锁,当一个弱小的线程A刚获得了一个自旋锁,还没捂热乎呢,就被中断夺走了CPU的使用权,中断也想获得自旋锁,只能一直原地打转,这样,线程A不放自旋锁,中断不给线程A运行权,两败俱伤,死锁。
最好的解决方法就是获取锁之前关闭本地中断, Linux 内核提供了相应的 API 函数:
各有特点:
1、spin_lock_irq/spin_unlock_irq
,使用它们的时候需要用户能够确定加锁之前的中断状态,中断状态不好把控。
2、spin_lock_irqsave/ spin_unlock_irqrestore
,因为这一组函
数会保存中断状态
,在释放锁的时候会恢复中断状态,从而保障了系统更稳定的运行。
如何使用以上API函数(一般情况):
在线程中使用 spin_lock_irqsave/spin_unlock_irqrestore
,
在中断中使用 spin_lock/spin_unlock
。
莫名其妙出现了下半部(BH)
(或底半部)这个名词,姑且认为也是一种运行状态,那么,在这里边儿使用自旋锁,该如何呢?
同样是使用API函数啦!
读写自旋锁
从字面意思理解,就是有读自旋锁
和写自旋锁
。
for instant:
一个包含用户信息的数据,是需要被保护的,多个线程对其访问时,需满足:
1、只能一个线程对其持有写自旋锁。
2、可以多个线程对其持有读自旋锁,可并发读取。
3、以上读写锁不能同时进行。
Linux 内核使用 rwlock_t 结构体表示读写锁:
typedef struct {
arch_rwlock_t raw_lock;
} rwlock_t;
读写锁操作 API 函数
读锁API函数
和写锁API函数
顺序锁
顺序锁在读写锁的基础上衍生而来的,
for instant ,满足:
1、只能一个线程对其持有写自旋锁。
2、可以多个线程对其持有读自旋锁,可并发读取。
3、可同时进行读写操作。
注意事项:
1、读的过程中发生了写操作,最好重新进行读取,保证数据完整性。
2、顺序锁保护的资源不能是指针,如果在写操作的时候可能会导致指针无效,这个时候恰巧有读操作访问指针的话就可能导致意外发生,比如读取野指针导致系统崩溃。
Linux 内核使用 seqlock_t 结构体表示顺序锁:
typedef struct {
struct seqcount seqcount;
spinlock_t lock; //这里就是普通自旋锁
} seqlock_t;
顺序锁的 API 函数
自旋锁使用注意事项
①、因为在等待自旋锁的时候处于“自旋”状态,因此锁的持有时间不能太长,一定要短,否则的话会降低系统性能。如果临界区比较大,运行时间比较长的话要选择其他的并发处理方式,比如稍后要讲的信号量和互斥体。
②、自旋锁保护的临界区内不能调用任何可能导致线程休眠的 API 函数,否则的话可能导致死锁(占着茅坑不拉屎!)。
③、不能递归申请自旋锁,因为一旦通过递归的方式申请一个你正在持有的锁,那么你就必须“自旋”,等待锁被释放,然而你正处于“自旋”状态,根本没法释放锁。结果就是自己把自己锁死了!
④、在编写驱动程序的时候我们必须考虑到驱动的可移植性,因此不管你用的是单核的还是多核的 SOC,都将其当做多核 SOC 来编写驱动程序。
信号量
信号量简介
信号量类似于自旋锁,他们的不同是,当线程A在使用资源时,且信号量值为1:
自旋锁
中线程B在等线程A时自旋;
信号量
中线程B不等待,自己去休眠,等线程A好了在叫它。
特别的,当信号量值为N时,同时可允许的线程数量为N。
信号量的特点:
①、因为信号量可以使等待资源线程进入休眠状态,因此适用于那些占用资源比较久的场合。
②、因此信号量不能用于中断中,因为信号量会引起休眠,中断不能休眠。
③、如果共享资源的持有时间比较短,那就不适合使用信号量了,因为频繁的休眠、切换
线程引起的开销要远大于信号量带来的那点优势。
Linux 内核使用 semaphore 结构体表示信号量:
struct semaphore {
raw_spinlock_t lock;
unsigned int count;
struct list_head wait_list;
};
信号量 API 函数
有关信号量的 API 函数:
使用示例:
struct semaphore sem; /* 定义信号量 */
sema_init(&sem, 1); /* 初始化信号量 */
down(&sem); /* 申请信号量 */
/* 临界区 */
up(&sem); /* 释放信号量 */
互斥体
互斥体简介
互斥体就是信号量的值为1的情况,即共享数据一次只能有一个线程访问。
Linux 内核使用 mutex 结构体表示互斥体:
struct mutex {
/* 1: unlocked, 0: locked, negative: locked, possible waiters */
atomic_t count;
spinlock_t wait_lock;
};
使用 mutex 的时候要注意如下几点:
①、 mutex 可以导致休眠,因此不能在中断中使用 mutex,中断中只能使用自旋锁。
②、和信号量一样, mutex 保护的临界区可以调用引起阻塞的 API 函数。
③、因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。并且 mutex 不能递归上锁和解锁。
互斥体 API 函数
有关互斥体的 API 函数:
互斥体的使用如下所示:
struct mutex lock; /* 定义一个互斥体 */
mutex_init(&lock); /* 初始化互斥体 */
mutex_lock(&lock); /* 上锁 */
/* 临界区 */
mutex_unlock(&lock); /* 解锁 */
实验
咱们简单见识一下应用,不完整驱动程序
基本流程
- 在设备结构体中定义一个原子变量。
- 驱动入口函数初始化为1。
- 一个
进程A
打开此驱动文件时,原子变量1-1=0,并判断能获得进入权限。 - 另一个
进程B
再次打开此驱动文件,原子变量0-1=-1,判断无法获得进入权限,赶紧给第一个进程A原子变量-1+1=0,完好无损地放回去。 - 等进程A用完这个驱动程序,原子变量0+1=1,还原为初始状态。
- 如此,实现了设备的
互斥访问
。
后边儿的自旋锁也是这么个意思,暂时不做验证。
/* gpioled设备结构体 添加一个原子变量*/
struct gpioled_dev{
dev_t devid; /* 设备号 */
......
atomic_t locked; /*原子变量 */
};
/*初始化原子变量 */
static int __init led_init(void)
{
......
atomic_set(&gpioled.lock, 1); /* 原子变量初始值为 1 */
}
static int led_open(struct inode *inode, struct file *filp)
{
/* 通过判断原子变量的值来检查 LED 有没有被别的应用使用 */
if(!atomic_dec_and_test(&gpioled.lock))
{
atomic_inc(&gpioled.lock);/* 小于 0 的话就加 1,使其原子变量等于 0 */
return -EBUSY; /* LED 被使用,返回忙 */
}
filp->private_data = &gpioled; /* 设置私有数据 */
return 0;
}
static int led_release(struct inode *inode, struct file *filp)
{
/* 关闭驱动文件的时候释放原子变量 */
atomic_inc(&dev->lock);
return 0;
}