Linux内核的竞态与并发(中断屏蔽、原子操作、自旋锁、信号量、互斥体的互斥机制)


所有的热爱都要不遗余力,真正喜欢它便给它更高的优先级,和更多的时间吧!

关于 LINUX驱动 的其它文章请点击这里:     LINUX驱动


一. 基本概念

● linux内核中产生竞态的原因

  • SMP对称多处理器 (多核CPU)
    比如都要操作LCD
  • 进程和进程之间的抢占共享资源,进程和中断之间发生共享资源的抢占,中断和中断之间的资源抢占(中断是有优先级的)
    比如:LCD 网卡 可见的内存 (文件 共享内存 全局变量)。

● 共享资源

  • 文件、硬件设备、共享内存、内核中的全局变量等

● 并发

  • 多任务同时执行,对于单核的CPU来说,宏观上并行,微观上串行。而并发的执行单元对共享资源的访问则很容易导致竞态(Race Conditions)

● 临界区

  • 访问共享资源的代码段
    对某段代码而言,可能会在程序中多次被执行,每次执行的过程我们称作代码的执行路径。当两个或多个代码路径要竞争共同的资源的时候,该代码段就是临界区。

二. 解决竞争状态的策略

    常用一下四种策略:(速记:中原武林很自信)

     1)中断屏蔽(内核空间)
        不推荐使用
    2)原子操作(内核空间)
        事务的原子性:要么做完 ,要么不做
    3)自旋锁(内核空间)
        自旋锁相应快,逻辑不允许重入,要等待锁释放的
    4)信号量 (用户空间)
         相对慢,要从睡眠态唤醒

1. 中断屏蔽

    中断屏蔽可以保证正在执行的内核执行路径不被中断处理程序抢占,防止竞态的产生,但内核的正常运行依赖于中断机制。在屏蔽中断期间,任何中断都无法得到处理,而必须等待屏蔽解除。所以关中断的时间要非常短, 如果关中断时间过长,可能直接造成内核崩溃,建议在写驱动过程中尽量不使用。

    使用流程为:关中断----访问共享资源----开中断

    使用方法如下:

   local_irq_disable()
   local_irq_enable()
   //更安全的:
   local_irq_save()                       //保存中断的状态(开/关)       关闭中断
   local_irq_restore()                    //恢复保存的中断状态  

2. 原子操作

    原子操作底层表现为一条汇编指令(ldrex、strex)。所以他们在执行过程中不会被别的代码路径所中断。

    事务的原子性就是要么做完 要么不做。而如何实现的原子性不被打断,不需要去关注,内核中实现的原子操作都是与CPU架构息息相关的,只需要掌握原子的使用方法即可。

很好理解,用上厕所的例子来说明。厕所就是共享资源,去上厕所的行为被称作代码路径。
原子操作就是大家每次上厕所都用时非常短,短到什么程度呢,只要一条汇编指令的时间。当然拉的量也非常少(只改变一个整型或者是位)。所以就不存在抢厕所的问题了。

2.1 位原子操作

  // arch/arm/include/asm/bitops.h
  set_bit(nr, void *addr)      // addr内存中的nr位置1
  clear_bit
  change_bit
  test_bit
  ...

2.2 整型原子操作

    使用步骤:

//1)定义原子变量     atomic_t tv;  //就是用原子变量来代替整形变量
	//核心数据结构:
	typedef struct {
    
    
		int counter;
	} atomic_t;
//2) 设置初始值的两种方法   
 	tv = ATOMIC_INIT(0);    //① 定义原子变量 v 并初始化为0
 	atomic_set(&tv, i)      //② 设置原子变量的值为 i
//3) 操作原子变量
	int atomic_read(atomic_t *v)       //返回原子变量的值        
	atomic_add(int i, atomic_t *v);    //v += i
	atomic_sub(int i, atomic_t *v);    //v -= i
	atomic_inc(atomic_t *v);           //v++;
	atomic_dec(atomic_t *v)            //v--  
	...   

    代码过长,具体代码:Linux内核的竞态与并发——原子操作实例

3. 自旋锁

    多处理器之间设置一个全局变量V,表示锁。并定义当V=1时为锁定状态,V=0时为解锁状态自旋锁同步机制是针对多处理器设计的,属于忙等机制。

    自旋锁,逻辑不允许重入,要等待锁释放的,注意以下:

    1) 自旋锁的获取与释放逻辑上要保证成对出现
    2) 只允许一个持有单元,获取锁不成功原地自旋等待
    3) 临界区中不能调用引起阻塞或者睡眠的函数
    4) 临界区执行速度要快, 持有自旋锁期间,整个系统几乎不做任务切换,持有自旋锁时间过长,会导致整个系统性能严重下降
    5) 避免死锁, A,B互相锁死,可以建议使用spin_trylock(&btn_lock)

还是用上厕所的例子:这次给厕所上把锁,只有拥有这个锁钥匙的人A才能进厕所。进去后把锁锁上,外面的人B急得团团转(自旋),A出来后把锁释放,在门口等着的B拿了钥匙赶紧开了锁进去了。但是缺点就是,B在外面团团转,没有功夫去做别的事情,所以一旦A 上厕所的时间很长,B就浪费了很长时间在自旋上。对系统的性能有所影响。

    使用步骤:

// 1)定义一个自旋锁变量: 
	spinlock_t btn_lock;
// 2) 初始化自旋锁 :  
	spin_lock_init(&btn_lock)
// 3) 获取自旋锁 (获取权利)
   spin_lock(&btn_lock);       //获取自旋锁不成功,原地自旋等待,直到锁被释放,获取成功才返回
   //或:
   int spin_trylock(&btn_lock);//不成功,直接返回一个错误信息,调试的时候可用,可以避免死锁
// 4) 访问共享资源
// 5) 释放自旋锁
   spin_unlock(&btn_lock);

    自旋锁还有很多衍生自旋锁:读锁 写锁 顺序锁 内核的大锁:

// 1)定义一个自旋锁变量: 
	spinlock_t btn_lock;
// 2) 初始化自旋锁 :  
	spin_lock_init(&btn_lock)
// 3) 获取自旋锁 (获取权利)
	unsigned long flags;
    spin_lock_irq(&lock);            // = spin_lock() + local_irq_disable()
    //或
    spin_lock_irqsave(&lock, flags); // = spin_lock() local_irq_save()
// 4) 访问共享资源
// 5) 释放自旋锁
   spin_unlock_irq(&lock);          // = spin_unlock()+ local_irq_enable()
   //或
   spin_unlock_irqrestore(&lock, flags); // = spin_unlock() + local_irq_restore()

Linux内核的竞态与并发——自旋锁实例

Q:编程时有可能需要在临界区代码中执行阻塞睡眠函数 怎么办?
A:这时可以考虑使用信号量来保护临界区。

4 信号量

     在用户空间只有进程的概念。当一个临界区有多个用户态进程竞争时,最好的方法是用信号量保护这个临界区。只有得到信号量进程才能执行临界区代码,当获取不到信号量时,进程进入休眠状态。

    因此,我们可以说,信号量是进程级的互斥机制,它代表进程来争夺共享资源,如果竞争失败,就会发生进程上下文切换,当前进程进入睡眠状态,CPU运行其他进程。

    此外,信号量在SMP(对称多处理器)系统同样起作用;内核中的信号量也只能用于内核态编程

比方说:一间公共厕所N 个坑位,N 不为 1, 且 N为有限个,算是N个资源。在同一时间可以容纳N个人,当满员的时候,外面的人必须等待里面的人出来,释放一个资源,然后才能在进一个,当他进去之后,厕所又满员了,外面的人还得继续等待……

● 特点:

  a.基于自旋锁机制实现的
  b.可以有多个持有者,获取信号量不成功睡眠等待
  c. 可以调用引起阻塞或者睡眠的函数
  d. 用信号量保护的临界区执行速度相对慢(见图二 )

● 内核中关于信号量的核心数据结构

struct semaphore {
    
    
	raw_spinlock_t        lock;
	unsigned int          count;//计数器
	...
 };

● 使用步骤:

 // 1)定义一个信号量   
 	struct semaphore btn_sem;
 // 2) 初始化信号量          
 	void sema_init(&btn_sem, 5);    //该信号量可以被5个执行单元持有
    //还可以通过以下宏完成信号量的定义和赋值为1             
    DEFINE_SEMAPHORE(btn_sem);
 // 3) 获取信号量,本质就是给其中的计数-1(获取权利)
   	//成功立即返回,失败使用调用者进程进入睡眠状态(深度睡眠kiii -9都杀不死) ,
  	//直到可以获取信号量成功才被唤醒、返回
	void down(struct semaphore *sem);

  	//成功立即返回,失败进入可中断的睡眠状态(潜睡眠,可被ctrl+c打断)
    //可以获取信号量 + 收到信号(ctrl+c)
  	int down_interruptible(struct semaphore *sem); //关注返回值

    //失败立即返回一个错误信息,不会导致睡眠
    //可以在中断上下文中使用
 	int down_trylock(struct semaphore *sem);

	//失败进入可以kill的睡眠状态 
	int down_killable(struct semaphore *sem);     
	
	//获取信号量,指定超时时间为x
    //如果获取信号量不成功,对应的进程进入睡眠状态
    //可能因为信号量可用而被唤醒/也可能因为定时时间到而被唤醒
	int down_timeout(struct semaphore *sem, long jiffies);
 // 4) 执行临界区代码,访问共享资源
 // 5)释放信号量,本质就是给计数器+1
    void up(struct semaphore *sem);

Linux内核的竞态与并发——信号量实例

5 互斥体

    在 FreeRTOS 和 UCOS 中也有互斥体,将信号量的值设置为 1 就可以使用信号量进行互斥访问了,虽然可以通过信号量实现互斥,但是 Linux 提供了一个比信号量更专业的机制来进行互斥,它就是互斥体—mutex。互斥访问表示一次只有一个线程可以访问共享资源,不能递归申
请互斥体。在我们编写 Linux 驱动的时候遇到需要互斥访问的地方建议使用 mutex。 Linux 内核
使用 mutex 结构体表示互斥体,定义如下(省略条件编译部分):

struct mutex {
    
    
/* 1: unlocked, 0: locked, negative: locked, possible waiters */
	atomic_t count;
	spinlock_t wait_lock;
};

    在使用 mutex 之前要先定义一个 mutex 变量。在使用 mutex 的时候要注意如下几点:
①、 mutex 可以导致休眠,因此不能在中断中使用 mutex,中断中只能使用自旋锁。
②、和信号量一样, mutex 保护的临界区可以调用引起阻塞的 API 函数。
③、因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。并
且 mutex 不能递归上锁和解锁。

参考:
[Linux]互斥机制(中断屏蔽、原子操作、自旋锁、信号量)
Linux内核并发和竟态 (解决竟态的5种方式屏蔽中断,原子操作,自旋锁,信号量,互斥体


关于 LINUX驱动 的其它文章请点击这里:     LINUX驱动

猜你喜欢

转载自blog.csdn.net/qq_16504163/article/details/109309707