之前的scull字符设备驱动实例中,我说过还有许多不完善的地方。考虑如下状态:当读取的数据量比较大或者比较耗时,此时有其他的线程在我们的数据区域中写入新的数据,那此时我们读到的数据会是什么情况呢?
本节以介绍一些概念为主,主要学习以下内容:
- 竞态和并发的概念;
- 避免出现竞态的一些手段介绍(信号量、互斥体、自旋锁、completion等)
1. 并发与竞态的概念
并发是指系统试图一次完成多个任务。
竞态是指对于共享数据的非控制访问。
为什么会产生并发和竞态呢?主要有以下几个原因:
- 当前的系统是多任务操作系统,用户空间进程可能会以任何组合来访问我们的代码,在SMP(对称多处理器)中,可能在多个CPU上同时执行我们的代码;
- Linux内核是可抢占的,当前操作可能在未完成的情况下,丢失了对CPU的占有,而新的获取CPU的进程可能会去调用我们的代码;
- 设备中断是异步事件,也可能导致代码并发执行;
- Linux内核中提供了延时机制(例如:workqueue、tasklet、timer等),随时都有可能开始执行我们的代码;
那么如何避免并发和竞态的产生呢,主要有以下几个原则:
- 只要有可能,应该尽量避免资源的共享。在代码中的明显体现是少用全局变量,使得方法是可重入的;
- 必须显式地管理对共享资源的访问。常用的技术称为“锁定”或者“互斥”,确保一次只有一个线程操作共享资源;
2. 信号量和互斥体
信号量本质上是一个数值,用于标记资源的可用数量,它和一对函数联合使用,这对函数称为 P
和 V
。
当需要使用某个资源的时候,在相应的信号量上调用P函数,如果信号量的数值大于0,则其数值会减1,同时,当前的操作会继续下去,称为获取信号量;如果信号量的数值为0,则当前操作会进入等待状态。
使用完成后,使用 V
函数来释放资源,此时信号量的数值会增加1,并唤醒其他等待线程,称为释放信号量。
如果信号量的数值为1,则表示当前只能有一个线程拥有资源,其他线程都需要等待,只有等其中拥有资源的权限释放后,才能去获取资源,此时的信号量也称为“互斥体”(mutex)。
在Linux中,用于信号量的函数有以下:
#include <linux/semaphore.h> //书中写的是<asm/semaphore.h>,我实践中发现是<linux/semaphore.h>
void sema_init(struct semaphore *sem, int val); //初始化信号量,设定信号量初值为val
DECLARE_MUTEX(name); //初始化信号量,初始值为1
DECLARE_MUTEX_LOCKED(name); //初始化信号量,初始值为0
void init_MUTEX(struct semaphore *sem); //运行中初始化信号量,初始值为1
void init_MUTEX_LOCKED(struct semaphore *sem); //运行中初始化信号量,初始值为0
void down(struct semaphore *sem); //获取信号量(即上面的P函数),未获取到时一直等待,不可中断
int down_interruptible(struct semaphore *sem); //同down,会等待,但可被中断,返回0表示获取成功,返回非0表示获取失败
int down_trylock(struct semaphore *sem); //同down,但不会等待,而是立即返回,返回0表示获取成功,返回非0表示获取失败
void up(struct semaphore *sem); // 释放信号量(即上面的V函数)
需要注意的是,获取到信号量后,一定别忘了释放信号量。
3. completion机制
在内核编程中,经常遇到这种状态:当前线程需要等待另一个线程完成某个操作或初始化后才能继续运行。
这种情况我们可以使用信号量来完成:初始化一个初值为0的信号量,当前线程去获取这个信号量,而在另一个线程中完成操作后释放信号量,然后让当前线程继续执行下去。这种方法并不是很好的方法,为此,linux提供了completion接口,它是一种轻量级的机制,用于一个线程告诉另外一个线程某个工作已经完成 。
在Linux中,completion机制的常用函数有以下:
#include <linux/completion.h>
DECLARE_COMPLETION(my_completion); //创建一个completion
init_completion(struct completion *c); // 在运行中创建并初始化completion
void wait_for_completion(struct completion *c); // 等待completion,是不可中断的
void complete(struct completion *c); // 触发completion,使得上面的等待线程继续,只能唤醒一个等待线程
void complete_all(struct completion *c); // 触发completion,使得上面等待线程继续,唤醒所有等待线程
4. 自旋锁
信号量对于互斥来讲是一个非常有用的工具,但并不是唯一的工具。在许多情况下,更多的是使用自旋锁(spinlock)机制来实现互斥。
在信号量中,获取锁的等待会产生休眠,因此,不能用于不能休眠的代码中,比如中断线程。而自旋锁的获取不会产生休眠,因此可以用于不能休眠的代码中,且通常能够提供更高的性能。
自旋锁的工作过程可以简单理解为就是一直检测当前锁的状态,然后获取,不断循环,所以称为自旋。
Linux中自旋锁的常用函数如下:
#include <linux/spinlock.h>
spinlock_t my_lock = SPIN_LOCK_UNLOCKED; // 编译时初始化自旋锁
void spin_lock_init(spinlock_t *lock); //运行中初始化自旋锁
void spin_lock(spinlock_t *lock); //自旋等待获取锁,不可中断
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags); // 获取自旋锁前禁止中断,并将中断状态保存到flags中
void spin_lock_irq(spinlock_t *lock); //获取自旋锁前禁止中断,不跟踪中断状态,释放自旋锁时需要自己保证启用中断
void spin_lock_bh(spinlock_t *lock); // 获取自旋锁前禁止软件中断,而硬件中断保存打开
void spin_unlock(spinlock_t *lock); //释放获取的锁,对应spin_lock
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags); //释放获取的锁,对应spin_lock_irqsave
void spin_unlock_irq(spinlock_t *lock); //释放获取的锁,对应spin_lock_irq
void spin_unlock_bh(spinlock_t *lock); //释放获取的锁,对应spin_lock_bh
int spin_trylock(spinlock_t *lock); //不会等待,直接返回,0表示获取成功,非0表示获取失败
int spin_trylock_bh(spinlock_t *lock); //不会等待,直接返回,0表示成功,非0表示获取失败
在使用自旋锁时,也要注意以下问题:
- 拥有自旋锁的代码必须是原子的,即不能休眠,不能被打断;
- 拥有自旋锁时,需要禁止中断,防止中断中获取锁,造成死锁;
- 拥有自旋锁的时间需要尽可能短,越短越好;
5. 原子变量
如果我们共享的变量是一个简单的整数值,用一个锁机制对于一个简单的整数值来说显得十分浪费。针对这种情况,Linux提供了一种原子整数类型,称为 atomic_t
。
atomic_t
中不能记录大于24位的整数。其常用的函数如下:
#include <asm/atomic.h>
void atomic_set(atomic_t *v, int i); //运行时初始化原子变量为i
atomic_t v = ATOMIC_INIT(0); //编译时初始化原子变量为0
int atomic_read(atomic_t *v); //返回原子变量的值
void atomic_add(int i, atomic_t *v); // 原子变量加上i
void atomic_sub(int i, atomic_t *v) // 原子变量减去i
void atomic_inc(atomic_t *v); // 原子变量自加1
void atomic_decatomic_t *v);
以上是常用的一些函数,还有一些结果检测、返回等操作其实都可以用上述的操作来完成。需要说明的是,原子变量只能通过指定的函数来访问,不能直接使用数学运算法进行赋值和运算。
以上是对并发和竞态的一些介绍,以及工作和学习中常用到的一些避免竞态的手段。当然,还有一些其他手段用于避免竞态的发生,我这边没有介绍,有兴趣的可以自己去了解。