linux设备驱动:并发和竞态

综述

首先什么是并发与竟态呢?并发(concurrency)指的是多个执行单元同时、并行被执行。而并发的执行单元对共享资源(硬件资源和软件上的全局、静态变量)的访问则容易导致竞态(race conditions)。因此再设计自己的驱动程序时,第一个要记住的原则是,只要可能,避免资源的共享。这种思想最明显的应用就是避免使用全局变量。竞态通常作为可能导致并发和竟态的情况有:

  1. SMP(Symmetric Multi-Processing),对称多处理结构。SMP是一种紧耦合、共享存储的系统模型,它的特点是多个CPU使用共同的系统总线,因此可访问共同的外设和存储器。
  2. 中断。中断可 打断正在执行的进程,若中断处理程序访问进程正在访问的资源,则竞态也会发生。中断也可能被新的更高优先级的中断打断,因此,多个中断之间也可能引起并发而导致竞态。
  3. 内核进程的抢占。linux是可抢占的,所以一个内核进程可能被另一个高优先级的内核进程抢占。如果两个进程共同访问共享资源,就会出现竟态。

以上三种情况只有SMP是真正意义上的并行,而其他都是宏观上的并行,微观上的串行。但其都会引发对临界共享区的竞争问题。而解决竞态问题的途径是保证对共享资源的互斥访问,即一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。那么linux内核中如何做到对对共享资源的互斥访问呢?在linux驱动编程中,常用的解决并发与竟态的手段有信号量与互斥锁,Completions 机制,自旋锁(spin lock),以及一些其他的不使用锁的实现方式。下面一一介绍。

信号量和互斥体

一个信号量本质是上是一个整数值,进入临界区的进程调用P, 如果信号量的值大于零, 则这个值减一, 而进程可以继续。相反,如果信号量的值为零(或更小),进程必须等待,直到其他人释放该信号量。对信号量的解锁调用V,该函数增加信号量的值,并在必要时唤醒等待的进程。当信号量的初始值为1时, 就变成了互斥锁。这种信号量在任何给定时刻只能有单个进程或线程拥有。
信号量的典型使用形式:

//声明信号量
struct semaphore sem;
//初始化信号量
void sema_init(struct semaphore *sem, int val);

//以下简便的声明和初始化一个互斥体
DECLEAR_MUTEX(NAME);  //信号量name 被初始化为1
DECLEAR_MUTEX_LOCKED(NAME);  //信号量name 被初始化为0

//常用操作
DECLEAR_MUTEX(test_sem);
down(&test_sem);  // 获取信号量,减小信号量的值
....
//临界区
....
up(&test_sem);  //释放信号量, 增加信号量的值

常见的down 操作还有

//down() 等待信号量进入休眠,不能被外部信号打断,而down_interruptible() 进入休眠的进程能被信号打断
int down_interruptible(struct semaphore *sem);
//尝试获得信号量,若立即获得,他就获得信号量并返回0,**否则,他不会导致调用者休眠,返回非0值,可在中断上下文使用**。
int down_trylock(struct semaphore *sem);

Completions 机制

  1. 什么是completions机制?
    在内核编程中常有这样的场景,在当前线程中创建一个线程,并且等待它完成之后再继续执行。通常可以用信号量来解决它,也可以用completion机制来解决。
  2. 为什么用completions ,它比信号量好在哪?
    使用completion比使用信号量简单。使用completion可以一次性唤醒所有等待进程,而用信号量会比较麻烦。
//接口方式创建completion
DECLARE_COMPLETION(my_completion);

//动态初始化
struct completion my_completion;
/*....*/
init_comp;etion(&my_completion);

//等待一个completion被唤醒, 非中断的等待如果代码调用了wait_for_completion() 且没有人会完成该任务,则产生一个不可杀死的进程
void wait_for_completion(struct completion *c);


// 唤醒completion
void complete(struct completion *c);
void complete_all(struct completion *c);

自旋锁

自旋锁可在不能休眠的代码中使用,当锁被其他人获得,则代码进入忙循环并重复检查这个锁,知道该锁可用。自旋锁典型的应用在中断处理函数中。使用于自旋锁的核心规则是:1.任何拥有自旋锁的代码都必须是原子的,他不能休眠 2.必须在可能的最短时间内拥有

// 定义自旋锁
spinlock_t my_lock;

//初始化自旋锁
void spin_lock_init(spinlock_t *lock);

//进入临界区,调用下面函数获得锁
void spin_lock(spinlock_t *lock);

//释放锁
void spin_unlock(spinlock_t *lock);

//非阻塞的自旋锁的操作,没有获取锁,返回零, 不在自旋
int spin_tyrlock(spinlock_t *lock);

还有另外一种情形: 我们的驱动正在执行,并且已经获得一个锁,这个锁控制着对设备的访问,在拥有这个锁的时候,设备产生一个中断,它导致中断处理例程被调用。而中断处理例程在访问设备之前,也要获得这个锁。这个时候中断例程自旋,非中断代码将没有机会来释放这个锁。为防止这种影响,提供了以下api

void spin_lock_irqsave(spinlock_t *lock, unsigned long falgs);
void spin_lock_irq(spinlock_t *lock); //禁用本地处理器的中断
void spin_lock_bh(spinlock_t *lock)   //禁用软件中断

除了锁之外的办法

免锁算法

经常用于免锁的生产者/消费者任务的数据结构之一是循环缓冲区。在linux内核中就有一个通用的无锁的环形缓冲实现,具体内容参考

原子变量与位操作

原子操作指的是在执行过程中不会被别的代码路径所中断的操作。原子变量与位操作都是原子操作。以下是其相关操作介绍。



// 设置原子变量的值  
void atomic_set(atomic_t *v, int i);  // 设置原子变量的值为i  
atomic_t v = ATOMIC_INIT(0);  // 定义原子变量v,并初始化为0  

// 获取原子变量的值  
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_dec(atomic_t *v);  // 原子变量减少1  

// 操作并测试:对原子变量进行自增、自减和减操作后(没有加)测试其是否为0,为0则返回true,否则返回false  
int atomic_inc_and_test(atomic_t *v);  
int atomic_dec_and_test(atomic_t *v);  
int atomic_sub_and_test(int i, atomic_t *v);  

// 操作并返回: 对原子变量进行加/减和自增/自减操作,并返回新的值  
int atomic_add_return(int i, atomic_t *v);  
int atomic_sub_return(int i, atomic_t *v);  
int atomic_inc_return(atomic_t *v);  
int atomic_dec_return(atomic_t *v);  
  位原子操作:  
// 设置位  
void set_bit(nr, void *addr);  // 设置addr地址的第nr位,即将位写1  

// 清除位  
void clear_bit(nr, void *addr);  // 清除addr地址的第nr位,即将位写0  

// 改变位  
void change_bit(nr, void *addr);  // 对addr地址的第nr位取反  

// 测试位  
test_bit(nr, void *addr); // 返回addr地址的第nr位  

// 测试并操作:等同于执行test_bit(nr, void *addr)后再执行xxx_bit(nr, void *addr)  
int test_and_set_bit(nr, void *addr);  
int test_and_clear_bit(nr, void *addr);  
int test_and_change_bit(nr, void *addr);  

seqlock(顺序锁)

使用seqlock锁,读执行单元不会被写执行单元阻塞,即读执行单元可以在写执行单元对被seqlock锁保护的共享资源进行写操作时仍然可以继续读,而不必等待写执行单元完成写操作,写执行单元也不需要等待所有读执行单元完成读操作才去进行写操作。写执行单元之间仍是互斥的。若读操作期间,发生了写操作,必须重新读取数据。seqlock锁必须要求被保护的共享资源不含有指针。

// 获得顺序锁  
void write_seqlock(seqlock_t *sl);  
int write_tryseqlock(seqlock_t *sl);  
write_seqlock_irqsave(lock, flags)  
write_seqlock_irq(lock)  
write_seqlock_bh()  

// 释放顺序锁  
void write_sequnlock(seqlock_t *sl);  
write_sequnlock_irqrestore(lock, flags)  
write_sequnlock_irq(lock)  
write_sequnlock_bh()  

// 写执行单元使用顺序锁的模式如下:  
write_seqlock(&seqlock_a);  
...  // 写操作代码块  
write_sequnlock(&seqlock_a);  
  读执行单元操作:  
// 读开始:返回顺序锁sl当前顺序号  
unsigned read_seqbegin(const seqlock_t *sl);  
read_seqbegin_irqsave(lock, flags)  

// 重读:读执行单元在访问完被顺序锁sl保护的共享资源后需要调用该函数来检查,在读访问期间是否有写操作。若有写操作,重读  
int read_seqretry(const seqlock_t *sl, unsigned iv);  
read_seqretry_irqrestore(lock, iv, flags)  

// 读执行单元使用顺序锁的模式如下:  
do{  
    seqnum = read_seqbegin(&seqlock_a);  
    // 读操作代码块   
    ...  
}while(read_seqretry(&seqlock_a, seqnum));  

读取-拷贝-更新(RCU)

读取-拷贝-更新(RCU) 是一个高级的互斥方法,在合适的时候可以取得非常高的效率。RCU可以看作读写锁的高性能版本,相比读写锁,RCU的优点在于既允许多个读执行单元同时访问被保护的数据,又允许多个读执行单元和多个写执行单元同时访问被保护的数据。但是RCU不能替代读写锁,因为如果写比较多时,对读执行单元的性能提高不能弥补写执行单元导致的损失。由于平时应用较少,所以不做多说。

小结

以上就是linux驱动编程中涉及的并发与竞态的内容,下面做一个简单的小结。

现在的处理器基本上都是SMP类型的,而且在新的内核版本中,基本上都支持抢占式的操作,在linux中很多程序都是可重入的,要保护这些数据,就得使用不同的锁机制。而锁机制的基本操作过程其实大同小异的,声明变量,上锁,执行临界区代码,然后再解锁。不同点在于,可以重入的限制不同,有的可以无限制重入,有的只允许异种操作重入,而有的是不允许重入操作的,有的可以在可睡眠代码中使用,有的不可以在可睡眠代码中使用。而在考虑不同的锁机制的使用时,也要考虑CPU处理的效率问题,对于不同的代码长度,不同的代码执行时间,选择一个好的锁对CPU的良好使用有很大的影响,否则将造成浪费。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值