Linux驱动开发七.并发与竞争——1.基础概念

今天要讲的是Linux的一些基本概念。由于Linux是个多任务系统,肯定存在多个任务共同操作一个设备也可以说是操作同一段内存的情况,这种情况就叫做共享资源的并发访问

并发与竞争

为了把事情讲清楚,我们比方有一个变量a,然后有条线程1要修改a的值,如果a的内存对应的映射地址是0x10000000,那么正常的操作是这样的

ldr r0,=0x10000000
ldr r1,=3
str r1,[r0]

正常的话没什么问题,可以如果这时候有另外一条线程(线程2)也要对这个变量进行修改,正常的话应该是这样的

 

因为CPU是一条指令一条指令进行数据操作的,这样是没问题的,但是如果这样的流程想想会有什么问题:

a初始值为3

先取a的值,加100以后重新赋值给a

重新取a的值,加50重新赋值给a

如果这两个操作是在一个线程里的,那就不会有问题,最终a的值是153,但是如果这两个操作是由两个独立的线程分别操作的,很有可能在线程1拿到a的值再计算过程中,还没有将103返回给a,这时候线程2又去拿了a的值(3),线程1在计算完成后a值为103,然后线程2计算完成后将53返回给a。这样是不是就出问题了!这个过程就并发与竞争最简单的例子。(这个数据的并发访问我记得在Python讲全局解释器锁GIL的时候大概说过这个概念)。

原子操作

解决并发与竞争的方法有很多种,最简单的一种就是原子操作。原子操作我在讲sql的时候提到过,就是把一系列操作捆绑成一个整体步骤操作,这个操作不能再被进一步的分割。比如去ATM存钱,现在是可以不用实体卡操作的,有一个账户上有10000元,张三在北京取5000元,账户上应该有5000,同时李四在上海存了3000,理论上就是张三在操作都时候账户信息被调出,取完钱以后将余额5000写入账户,可是张三在进入账户时候跟李四打了个电话让李四也登入账户(还未取钱,李四拿到的信息也是10000元),取完钱李四把钱存进去,余额就变成了13000,这样肯定是不行的,所以就把这种存钱的流程整定成不能被分割的过程,只有张三取完钱了李四才能存钱。这个过程就叫原子操作。一般情况下,原子操作用于变量或者是位操作 ,还以前面a=3这个过程为例,

ldr r0,=0x10000000
ldr r1,=3
str r1,[r0]

通过原子操作将这三条语句整定为不可分割的

原子操作API

Linux内核定义了个数据类型atomic_t结构来完成整形数据的原子操作(注意是整形!!!!),路径在include/linux/types.h文件中

typedef struct {
    int counter;
} atomic_t;

如果要使用原子操作,需要先声明一个atomic_t变量,然后用给定的API对其进行相关操作(路径为include/linux/gpio.h)

函数 描述
ATOMIC_INIT(int i) 定义原子变量的时候对其初始化。
int atomic_read(atomic_t *v) 读取 v 的值,并且返回。
void atomic_set(atomic_t *v, int i) 向 v 写入 i 值。
void atomic_add(int i, atomic_t *v) 给 v 加上 i 值。
void atomic_sub(int i, atomic_t *v) 从 v 减去 i 值。
void atomic_inc(atomic_t *v) 给 v 加 1,也就是自增。
void atomic_dec(atomic_t *v) 从 v 减 1,也就是自减
int atomic_dec_return(atomic_t *v) 从 v 减 1,并且返回 v 的值。
int atomic_inc_return(atomic_t *v) 给 v 加 1,并且返回 v 的值。
int atomic_sub_and_test(int i, atomic_t *v) 从 v 减 i,如果结果为 0 就返回真,否则返回假
int atomic_dec_and_test(atomic_t *v) 从 v 减 1,如果结果为 0 就返回真,否则返回假
int atomic_inc_and_test(atomic_t *v) 给 v 加 1,如果结果为 0 就返回真,否则返回假
int atomic_add_negative(int i, atomic_t *v) 给 v 加 i,如果结果为负就返回真,否则返回假

 比方我们要定义个变量a,把a初始值设置为10,自增以后把值传递给整形变量b

int b = 0;
atomic_t a = ATOMIC_INIT(10);   //定义原子变量a=10
atoimc_inc(&a);                 //a值自增,a==11
b = atoimc_read(&a);            //b=a

要注意的是传递的参数是指针类型,使用时要加取址符。

原子位操作API

前面讲到的原子操作API是对应的整形变量,Linux内核也提供了一系列针对原子位操作的API,和整形原子操作不同的是原子位操作没有那个atomic_t那样的结构体,而是直接对内存进行操作,给定API如下

函数 描述
void set_bit(int nr, void *p) 将 p 地址的第 nr 位置 1
void clear_bit(int nr,void *p) 将 p 地址的第 nr 位清零。
void change_bit(int nr, void *p) 将 p 地址的第 nr 位进行翻转。
int test_bit(int nr, void *p) 获取 p 地址的第 nr 位的值。
int test_and_set_bit(int nr, void *p) 将 p 地址的第 nr 位置 1,并且返回 nr 位原来的值。
int test_and_clear_bit(int nr, void *p) 将 p 地址的第 nr 位清零,并且返回 nr 位原来的值。
int test_and_change_bit(int nr, void *p) 将 p 地址的第 nr 位翻转,并且返回 nr 位原来的值。

 

自旋锁

前面的那个原子操作可以解决一部分竞争导致的问题,但是只能保护整形变量或者位,而实际环境不能能只用到这种情形,比方我们需要对一个结构体访问的时候也要保持原子性,而这个结构体就不是简单的整形变量,那么这种原子操作就不能胜任了。这时候就要引入一个叫做锁机制的概念。最常用到的锁就是自旋锁。

自旋锁从死路上来看是一种悲观锁,就是在对数据操作都时候将数据加锁。在一个线程想要访问某个共享资源时先获取相应的锁,该锁只能被一个线程保持,如果该线程不把锁释放,另一个县城就会处于循环等待的状态,就像一个人去了卫生间把门锁上了,另外一个人只能急得在外面转圈圈的等待,直到第一个人打开门把锁释放掉。这个“自旋”可以理解为“原地打转”的意思,由于自旋锁是在等待锁被释放的,就避免了线程挂起以后再切回来导致上下文切换浪费的开销,但同样由于这个原因,始终保持自旋也会浪费处理器的时间,所以自旋锁只适合短时期轻量级加锁。长时间的锁需要用其他的方法来实现。

自旋锁在Linux内核中用结构体spinlock_t来表示,结构体定义如下(路径为include/linux/spinlock_types.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;

在使用前要先定义一个自旋锁变量,然后通过响应的API来操作。

自旋锁API函数

最常用到的自旋锁的API有下面几个(路径为include/linux/spinlock.h)

函数 描述
DEFINE_SPINLOCK(spinlock_t lock) 定义并初始化一个自选变量。
int spin_lock_init(spinlock_t *lock) 初始化自旋锁。
void spin_lock(spinlock_t *lock) 获取指定的自旋锁,也叫做加锁。
void spin_unlock(spinlock_t *lock) 释放指定的自旋锁。
int spin_trylock(spinlock_t *lock) 尝试获取指定的自旋锁,如果没有获取到就返回 0
int spin_is_locked(spinlock_t *lock) 检查指定的自旋锁是否被获取,如果没有被获取就返回非0,否则返回0

前面写linux驱动的时候一直在强调说Linux内核里好多函数都是成对使用的,有加载就必须有卸载,这个自旋锁也是一样的,上面的函数中第3、4条是一组的,最后两条是一组的,使用的时候要注意被搞反了!

死锁

在使用自旋锁的时候一定要防止死锁现象的发生,因为自旋锁在使用过程中会自动禁止抢占(视CPU而定),当A线程拿到自旋锁后会暂时禁止内核抢占,如果这个时候线程A在持有锁期间发生了阻塞或者休眠等情况CPU会把资源调度出去,线程B开始运行,而A由于无法拿到CPU资源就无法把锁释放掉,B也在死死等待A放锁,就像两个打架的人都对对方说你先放手,场面就僵持了!于是死锁就发生了。同样中断操作也一样,在获取锁之前一定要先禁止本地中断(就是对应的CPU),否则也会导致死锁的发生。这就要用到下面几个函数

函数 描述
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/unlock_irq这组函数的,最好使用后面那组,该组函数在被调用的时候会保存当前中断状态,释放锁的时候会根据状态恢复中断。

内核中断有个概念叫下半部或底半部(BH),以后会用到,如果在下半部里使用到自旋锁也有两个函数要使用

函数 描述
void spin_lock_bh(spinlock_t *lock) 关闭下半部,并获取自旋锁。
void spin_unlock_bh(spinlock_t *lock) 打开下半部,并释放自旋锁。

信号量

前面说过对于自旋锁来说,是无法使用中断和阻塞类的操作的,然而这些操作时无法避免的,为了解决这种情况,引入了信号量这个机制。信号量这个概念和线程池差不多,用来控制共享资源在同时被访问的数量。比方一个公共卫生间,一共就是10个坑,那么最多就能进去10个人,当前在卫生间的人数就是信号量。如果进去一个人,信号量就自增1,一直到10时说明卫生间已经满员了。需要访问数据的线程只能在外面等一会,直到出来一个人后信号量自减1。这种信号量就是计数型信号量。相比于自旋锁,信号量是支持线程进入阻塞状态的。比方甲乙丙三个人合租了个房子,早上甲进了卫生间把门锁了,这时候乙想要去但是发现门锁了,如果没有信号量机制的话他只能在门口转圈等,也就是自旋锁的状态。但是引入信号量后相当于有个调度,告诉乙你再回去睡会觉,甲出来了通知他。于是乙就去干别的事情,直到调度通知他可以来卫生间了。这个过程和自旋锁相比有个缺点就是乙在去干别的事等到通知在回来的情况有个上下文切换的过程,相当于将线程进入休眠状态以后切换到别的线程。这样就导致了操作的开销要比自旋锁大一些。总之,信号量的特点如下:

  • 因为信号量支持阻塞操作,适用于占用资源比较长的场合
  • 信号量会引起休眠,而中断过程是不能休眠的,所以信号量不能用在中断中
  • 如果共享资源被占用的时间较短,那么为了避免上下文切换时候差产生的开销,就不适合使用信号量了,频繁的休眠、切换线程的开销要远大于信号量产生的优势。在没有激烈锁争用的情况下,自旋锁的性能要大大优于信号量; 因为没有锁拥塞,获取自旋锁的开销仅为几十个CPU周期, 而上下文切换的开销则至少几百/上千个时钟周期,而且操作系统的时间片切换周期还有可能会丢弃几千万个时钟周期。

信号量API函数

LInux内使用了semaphore结构体来表示信号量路径为include/linux/semaphore.h

struct semaphore {
    raw_spinlock_t        lock;
    unsigned int        count;
    struct list_head    wait_list;
};

相关的ape也在那个文件里

函数 描述
DEFINE_SEAMPHORE(name) 定义一个信号量,并且设置信号量的值为 1。
void sema_init(struct semaphore *sem, int val) 初始化信号量 sem,设置信号量值为 val
void down(struct semaphore *sem) 获取信号量,因为会导致休眠,因此不能在中断中使用。
int down_trylock(struct semaphore *sem); 尝试获取信号量,如果能获取到信号量就获取,并且返回 0。如果不能就返回非 0,并且不会进入休眠。
int down_interruptible(struct semaphore *sem) 获取信号量,和 down 类似,只是使用 down 进入休眠状态的线程不能被信号打断。而使用此函数进入休眠以后是可以被信号打断的。
void up(struct semaphore *sem) 释放信号量

信号量的使用如下

semaphore test;         //定义信号量
sema_init(&test,10);    //初始化信号量
down(&test);            //申请信号量
/*临界区*/
up(&test);              //释放信号量

 

互斥体

互斥体可以看做信号量的值为1的情况,只是Linux提供了另外一种机制来进行互斥操作——mutex。互斥访问表示一次只能有一个线程可以访问共享资源。所以我们在写驱动的时候如果要涉及到互斥访问的时候尽量使用这个mutex,linux内核提供了mutex结构体来表示互斥体,路径为include/linux/mutex.h

struct mutex {
    /* 1: unlocked, 0: locked, negative: locked, possible waiters */
    atomic_t        count;
    spinlock_t        wait_lock;
    struct list_head    wait_list;
#if defined(CONFIG_DEBUG_MUTEXES) || defined(CONFIG_MUTEX_SPIN_ON_OWNER)
    struct task_struct    *owner;
#endif
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
    struct optimistic_spin_queue osq; /* Spinner MCS lock */
#endif
#ifdef CONFIG_DEBUG_MUTEXES
    void            *magic;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
    struct lockdep_map    dep_map;
#endif
};

互斥体本质上等同于信号量(信号量为互斥量),但是还是有一些区别要注意下:

互斥体是用于线程之间互斥,而信号量由于数据可以指定访问的线程数,可以用于线程之间的同步

互斥体每次只允许一个访问者进行访问,所以具有唯一性和排他性。但是这样就无法限制访问资源的顺序,也就是说访问是无序的,而同步是可以通过某些机制来实现资源访问的顺序。

互斥体的信号量只能为0或1,而信号量可以是非负整数,所以一个互斥量实现资源的多线程互斥的。而信号量可以实现多个线程之间的互斥和同步。当信号量为单值信号量时也可以完成资源的互斥访问

互斥量的加锁和解锁必须由一个线程操作,而信号量可以由一个线程解锁,另外的线程加锁

互斥体API

函数 描述
DEFINE_MUTEX(name) 定义并初始化一个 mutex 变量。
void mutex_init(mutex *lock) 初始化 mutex。
void mutex_lock(struct mutex *lock) 获取 mutex,也就是给 mutex 上锁。如果获取不到就进休眠。
void mutex_unlock(struct mutex *lock) 释放 mutex,也就给 mutex 解锁。
int mutex_trylock(struct mutex *lock) 尝试获取 mutex,如果成功就返回 1,如果失败就返回 0。
int mutex_is_locked(struct mutex *lock) 判断 mutex 是否被获取,如果是的话就返回1,否则返回 0。
int mutex_lock_interruptible(struct mutex *lock) 使用此函数获取信号量失败进入休眠以后可以被信号打断。
由于后面我们会经常用到原子操作、自旋锁、信号量和互斥体这几种机制,所以思路上要有个大致的概念。并发和竞争一定要在写驱动前考虑好,否则整体框架完成后调试的过程中很有可能随机出现bug。那时候处理就很麻烦了!
posted @ 2022-07-06 23:57  银色的音色  阅读(162)  评论(0编辑  收藏  举报