现在的 Linux 系统并发产生的原因很复杂,总结一下有下面几个主要原因:
①、多线程并发访问,Linux 是多任务(线程)的系统,所以多线程访问是最基本的原因。
②、抢占式并发访问,从 2.6 版本内核开始,Linux 内核支持抢占,也就是说调度程序可以在任意时刻
抢占正在运行的线程,从而运行其他的线程。
③、中断程序并发访问,这个无需多说,学过 STM32 的同学应该知道,硬件中断的权利可是很大的。
④、SMP(多核)核间并发访问,现在 ARM 架构的多核 SOC 很常见,多核 CPU 存在核间并发访问。
并发访问带来的问题就是竞争,学过 FreeRTOS 和 UCOS 的同学应该知道临界区这个概念,所谓的临
界区就是共享数据段,对于临界区必须保证一次只有一个线程访问,也就是要保证临界区是原子访问的,注
意这里的“原子”不是正点原子的“原子”。我们都知道,原子化学反应不可再分的基本微粒,这里的原子
访问就表示这一个访问是一个步骤,不能再进行拆分。如果多个线程同时操作临界区就表示存在竞争,我们
在编写驱动的时候一定要注意避免并发和防止竞争访问。很多 Linux 驱动初学者往往不注意这一点,在驱动
程序中埋下了隐患,这类问题往往又很不容易查找,导致驱动调试难度加大、费时费力。所以我们一般在编
写驱动的时候就要考虑到并发与竞争,而不是驱动都编写完了然后再处理并发与竞争。
(1)中断屏蔽 (2)自旋锁 (3)信号量 (4)原子操作
一,linux内核解决竞态问题的方法之 —— 中断屏蔽
1.能够解决的竞态问题如下能够解决硬件中断和软中断之间的竞态能够解决高优先级的软中断和低优先级的软中断的竞态由于进程与进程之间的抢占基于软中断实现,也能够解决进程与进程之间的抢占引起的竞态,多核引起的竞态,无法解决!
2.中断屏蔽就是当一个执行单元(中断或者进程)在访问临界区之前,先去屏蔽中断,这样其它的执行单元(中断或者高优先级的进程)就不会产生,那么这个执行单元就可以踏踏实实的访问临界区也就不会发生CPU资源的切换! 由于是屏蔽中断,中断一旦屏蔽,操作系统里很多跟中断相关的机制(tasklet,软件定时器,硬件中断等)就无法得到运行,如果长时间的无法得到CPU资源,势必对系统造成不可预测的后果(例如软件定时器无法获取正确的超时时间等)!
总结:中断屏蔽保护的临界区,要求代码执行速度要快, 更不能进行休眠操作;
3.编程使用步骤:
(1).明确驱动代码中哪些是共享资源
(2).明确驱动代码中哪些是临界区
(3).明确临界区中是否有休眠操作
如果有休眠,势必不考虑使用中断屏蔽,选择别的方法;如果没有休眠,"可以考虑"使用中断屏蔽。
4.在访问临界区之前屏蔽中断
unsigned long flags
local_irq_save(flags); //宏
5.执行单元踏踏实实的访问临界区也不会发生CPU资源的切换
6.访问临界区之后,记得恢复中断
local_irq_restore(flags); 切记:屏蔽中断和恢复中断一定要逻辑上成对使用!
二,linux内核解决竞态问题方法之—— 自旋锁
1.特点
(1)自旋锁必须附加在某个共享资源上
(2)想访问临界区的任务,在访问之前,先去获取自旋锁如果获取成功,即可访问临界区,但是访问临界区的速度要快,也就是说自旋锁保护的临界区的代码执行速度要快, 更不能进行休眠操作(后果很惨痛)! 如果获取失败,此任务将会原地空转,进入忙等待状态,等待的时间较长,势必浪费CPU资源,影响系统性能!所以:"自旋"不是锁自旋,而是没有获取自旋锁的任务自旋!
注意:自旋锁能够解决除了中断,其它竞态问题都可以解决 也就是:能够解决多核引起的竞态问题,能够解决同一个CPU上的,进程与进程的抢占而中断和进程,中断和中断无法解决!
总结:自旋锁能解决除了中断(中断和进程,中断和中断)以外的其他竞态(多核, 同一个CPU上的, 进程与进程的抢占)
2,自旋锁的数据类型:spinlock_t
3,编程使用操作:
(1).明确驱动代码中哪些是共享资源
(2).明确驱动代码中哪些是临界区
(3).明确临界区中是否有休眠
如果有,势必不考虑此方法
如果没有,还要考虑是否有中断
如果有中断参与的竞态,势必也不会考虑此方法
如果没有,可以考虑使用此方法
(4).访问临界区之前获取自旋锁前期要做:
a.定义初始化一个自旋锁对象spinlock_t lock; //定义对象
b.初始化对象spin_lock_init(&lock);
c.获取自旋锁spin_lock(&lock);
d. 释放自旋锁spin_unlock(&lock);
如果获取成功,此函数立即返回,代码继续往下执行
如果获取失败,当前任务再找个函数里进入忙等待状态
4,一旦获取自旋锁成功,踏踏实实的执行临界区, 但此时此刻,再次明确:自旋锁解决不了中断引起的竞态问题
5,访问临界区之后,记得要释放自旋锁spin_unlock(&lock); 一旦释放锁,忙等待获取锁的任务立马就获取自旋锁,就立马继续访问临界区
6,获取锁和释放锁必须在逻辑上成对使用,否则造成死锁。如果有中断参与的竞态,可以考虑使用衍生自旋锁!
7 ,综合关于自旋锁的信息,在使用自旋锁的时候要注意:
①、因为在等待自旋锁的时候处于“自旋”状态,因此锁的持有时间不能太长,一定要短,否则的话
会降低系统性能。如果临界区比较大,运行时间比较长的话要选择其他的并发处理方式,比如稍后要讲的
信号量和互斥体。
②、自旋锁保护的临界区内不能调用任何可能导致线程休眠的 API 函数,否则的话可能导致死锁。
③、不能递归申请自旋锁,因为一旦通过递归的方式申请一个你正在持有的锁,那么你就必须“自
旋”,等待锁被释放,然而你正处于“自旋”状态,根本没法释放锁。结果就是自己把自己锁死了!
④、在编写驱动程序的时候我们必须考虑到驱动的可移植性,因此不管你用的是单核的还是多核的 SOC,
都将其当做多核 SOC 来编写驱动程序。
三,linux内核解决竞态问题方法之——衍生自旋锁
1.特点 —— 衍生自旋锁是基于自旋锁扩展而来,具有自旋锁的所有
2.切记 —— 衍生自旋锁能够解决所有的竞态问题,本质上就是基于自旋锁加了一个中断屏蔽机制
3.自旋锁的数据类型:spinlock_t
4.编程使用操作:
(1)明确驱动代码中哪些是共享资源
(2)明确驱动代码中哪些是临界区
(3)明确临界区中是否有休眠:如果有,势必不考虑此方法;如果没有,可以考虑使用此方法。
(4)访问临界区之前获取衍生自旋锁前期要做:
a.定义初始化一个衍生自旋锁对象spinlock_t lock;
b. 初始化对象spin_lock_init(&lock);
c. unsigned long flags;
先屏蔽中断,再获取自旋锁spin_lock_irqsave(&lock, flags);
如果获取成功,此函数立即返回,代码继续往下执行,执行临界区
如果获取失败,当前任务在这个函数里进入忙等待状态
d. 释放衍生自旋锁 spin_unlock_irqresotore(&lock, flags);
5.一旦获取衍生自旋锁成功,踏踏实实的执行临界区
6.访问临界区之后,要释放衍生自旋锁 spin_unlock_irqresotore(&lock, flags); 一旦释放锁,忙等待获取锁的任务立马就获取自旋锁,就立马继续访问临界区7.获取锁和释放锁必须在逻辑上成对使用,否则造成死锁
7,总结:
(1).普通自旋锁除了中断,都可以解决(2)衍生自旋锁横扫所有的竞态问题(3)保护的临界区都不能进行休眠操作
四,linux内核解决竞态问题方法之 —— 信号量(谈谈进程和线程的区别,以及同步互斥的方法)
1.特点:信号量又称睡眠锁,信号量产生的根本原因就是因为中断屏蔽,自旋锁,衍生自旋锁保护的临界区不能休眠这种问题,有些场合需要在临界区中进行休眠操作,此时此刻要必须使用信号量来加以保护!信号量是允许临界区进行休眠操作!
2.数据结构:struct semaphore
3.编程使用操作步骤:
(1).明确驱动代码中哪些是共享资源
(2).明确驱动代码中哪些是临界区
(3).明确临界区中是否有休眠如果有,必须用此方法,如果没有,可以考虑使用中断屏蔽啦,自旋锁啦,衍生自旋锁啦或者信号量
(4).访问临界区之前获取信号量
a.定义初始化好一个信号量对象 struct semaphore sema;
b. 将信号量初始化为互斥信号量 sema_init(&sema, 1); // (信号量的值只有两个:1和0)
//获取信号量成功1减1=0
//释放信号量0加1=1
c.获取信号量的方法:
方法1:down(&sema); //1->0
如果获取信号量成功,函数立即返回,代码继续执行,访问临界区,如果获取信号量失败,进程在此函数中将进入不可中断的休眠状态,
“不可中断的休眠状态”:进程在休眠期间,如果接受到了信号(kill/ctrl+c).休眠的进程不会立即响应处理信号,而是进程被唤醒 以后才会处理信号(去死)进程如果进入这种休眠状态,将来被唤醒的方法只有一个:获取信号量的任务在释放信号量的同时再唤醒这个休眠的进程
方法2:down_interruptible(&sema); //1->0
如果获取信号量成功,函数立即返回,代码继续执行,访问临界区如果获取信号量失败,进程在此函数中将进入可中断的休眠状态
“可中断的休眠状态”:进程在休眠期间,如果接受到了信号(kill/ctrl+c). 休眠的进程会立即响应处理信号,进程如果进入这种休眠状态,将来被唤醒的将来唤醒的方法有两个:获取信号量的任务在释放信号量的同时再唤醒这个休眠的进程 和 信号唤醒。
if(down_interruptible(&sema))
{
printk("进程是由于接受到了去死信号引起的唤醒!\n");
return -ERESTARTSYS;
}
else
{
printk("获取信号量的进程释放信号量引起当前休眠的进程唤醒!\n"); //可以继续访问临界区
}
(5).一旦获取信号量,踏踏实实访问临界区
(6)访问临界区之后,释放信号量并且唤醒之前休眠的进程 up(&sema); //0->1
(7)获取信号量和释放信号量在逻辑上必须要成对使用
四,linux内核解决竞态方法之原子操作
1.原子操作所有的竞态问题都可以解决
2.原子操作的分类:位原子操作:位操作具有原子性,对共享资源位操作的时候不允许发生CPU资源的切换
3, 编程步骤:
(1)明确驱动代码中哪些是共享资源
(2)明确驱动代码中哪些是临界区
(3)观察临界区的代码中是否有对共享资源进行位操作,如果有位操作,可以考虑使用位原子操作相关的函数,调用内核提供的位原子操作的相关函数对共享资源进行位操作,这个过程具有原子性,也不会发生CPU资源的切换!
(4)内核提供的位原子操作的相关函数:
void set_bit(int nr, void *addr); //将addr地址内的数据的第nr位(从0开始)设置1
void clear_bit(int nr, void *addr); //将addr地址内的数据的第nr为清为0
void change_bit(int nr, void *addr); //将addr地址内的数据的第nr为反转
int test_bit(int nr, void *addr); //获取addr地址内的数据的第nr位的值
总结:利用以上函数对共享资源进行位操作具有原子性
4,参考代码
static int open_cnt = 1; //共享资源
open_cnt &= (1 << 5); //临界区
//此时此刻此代码没有考虑竞态问题,相当危险
解决方案:
(1)采用中断屏蔽
unsigned long flags
local_irq_save(flags);
open_cnt &= ~(1 << 5); //临界区
local_irq_restore(flags);
(2)采用自旋锁(衍生自旋锁)
spin_lock(&lock);
open_cnt &= (1 << 5); //临界区
spin_unlock(&lock);
(3).采用信号量
down(&sema);
open_cnt &= (1 << 5); //临界区
up(&sema);
(4)采用位原子操作
clear_bit(5, &open_cnt);
整型原子操作:整型操作具有原子性,对共享资源进行整型操作的过程不允许发生CPU资源的切换
整型原子变量数据类型:atomic_t(本质结构体,最最本质就unsigned int counter)
编程步骤:
<1>明确驱动代码中哪些是共享资源
<2>明确驱动代码中哪些是临界区
<3>观察临界区的代码中是否有对共享资源进行整型操作如果有,可以考虑使用内核提供的整型原子操作来解决竞态问题
<4>具体使用如下:
原先共享资源可能用char/int/long/short数据类型来定义、此时可以考虑使用整型原子变量的数据类型定义一个整型原子变量进行替换:
static int open_cnt = 1; //之前
static atomic_t open_cnt = ATOMIC_INIT(1); //采用整型原子操作替换
注意:“atmic_t当成int即可”
接下来只需利用内核提供的整型原子操作的相关函数
对整型原子变量访问即可,访问过程具有原子性:
atomic_add
atomic_sub
atomic_inc
atomic_dec
atomic_return
atomic_sub_and_test://分析源码
整型原子变量减1,然后判断整数原子变量的值是否为0如果为0,返回真;否则返回假等(去看SI)
参考代码:结论:此代码裸奔中,相当危险!
static int open_cnt = 1; //共享资源
if (--open_cnt != 0) { //临界区}
解决方案:
(1).采用中断屏蔽
local_irq_save(flags);
if (--open_cnt != 0)
local_irq_restore(flags);
(2).采用自旋锁(衍生自旋锁)
spin_lock(&lock);
if (--open_cnt != 0)
spin_unlock(&lock);
(3)采用信号量
down(&sema);
if (--open_cnt != 0)
up(&sema);
(4)整型原子操作
static atomic_t open_cnt = ATOMIC_INIT(1); //整型原子变量
if (!atomic_dec_and_test(&open_cnt)) //临界区
{
//打开失败
}