Linux 驱动框架---驱动中的并发
并发指多个执行单元被同时、并行的执行,而并发执行的单元对共享资源的访问就容易导致竟态。并发产生的情况分为抢占和并行(多核)和硬抢占(中断)。Linux为解决这一问题增加了一系列的接口来解决并发导致的竟态问题。其中原子操作是最基本的机制。
原子操作
通常一句C代码在被翻译成汇编时可能不止一句,又比如常见的直接使用C语言用一个全局变量作为标志位来标志共享资源的实现情况如下:
if(flags!= BUSY){
flasg = BUSY;
//开始操作
。。。
}
两种情况都会有如下的风险。如果内核支持抢占且存在多个线程使用一段共享资源,当一个线程获取资源执行了判断之后因为,调度抢占未来的及置起标志位就有另一个进程开始执行并获取资源此时它进行互斥检查,此时资源是可用的,然后就会有不止一个执行单元拥有共享资源这就形成了竟态。还有一种情况就是中断和线程同时会使用共享资源,此时也会发生这种情况。除此之外还有不容易理解的编译乱序和执行乱序导致的竟态,所谓执行乱序是CPU的指令执行优化导致的,就是在有些情况下CPU因为前一条指令的执行因为IO等原因阻塞而认为下一条执行不care上一条执行的指令结果的时候就会调整指令的执行顺序可能就会导致原本想在后面执行的语句先执行。编译乱序是编译器的优化策略导致的问题,同样是因为代码的执行顺序被“优化”导致执行顺序不是按设计执行,可以通过增加编译屏障来解决。所以需要一种单步(无法在分割为更小的执行步骤)就可以完成的变量操作来保证这种互斥所以有了原子操作,这是一种由硬件支持的操作所以肯定是汇编实现。主要分为整形和位原子操作。
整形
//设置原子变量 void atomic_set(atomic_t *v,int i); //定义并初始化为x atomic_t v = ATOMIC_INIT(x); //获取原子变量 atomic_read(atomic_t* v); //v++ void atomic_inc(atomic_t* v); //v-- void atomic_dec(atomic_t* v);//v-- //操作并测试 ++v==0? int atomic_inc_and_test(atomic_t* v); //v+=i;v==0? int atomic_add_and_test(in i ,atomic_t* v); //v-=i;v==0? int atomic_sub_and_test(in i ,atomic_t* v); //操作并返回 int atomic_inc_and_return(atomic_t* v);//return ++v; int atomic_dec_and_return(atomic_t* v);//return --v; int atomic_add_and_return(in i ,atomic_t* v);return(v+=i); int atomic_sub_and_return(in i ,atomic_t* v);return(v-=i);
如果是64位的平台还支持64位的整形操作atomic64_xxx()。原子操作是后续其他有些互斥实现操作的基础。
位原子
//设置bit void set_bit(unsigned long nr, volatile unsigned long *m) //清除bit void clear_bit(unsigned long nr, volatile unsigned long *m) //取反对应bit void change_bit(unsigned long nr, volatile unsigned long *m) //测试bit int test_bit(unsigned int nr, const volatile unsigned long *addr) //测试和操作bit int test_and_set_bit(unsigned int nr, const volatile unsigned long *addr) int test_and_clear_bit(unsigned int nr, const volatile unsigned long *addr) int test_and_change_bit(unsigned int nr, const volatile unsigned long *addr)
自旋锁
自旋锁最初就是为了多核(SMP)系统设计的,实现在多处理器情况下保护临界区。所以在SMP系统中,自旋锁的实现是本来完整的面目。但是对于单核(up)系统自旋锁可以说是SMP版本的阉割版。因为只有在SMP系统中的自旋锁才需要真正“自旋”归其原因是竟态产生有三种情形:
- 单核支持抢占的内核中的进程间抢占
- 单核不支持抢占中断和进程间的抢占
- 多核系统的真正并发执行和单核中的情况
所以自旋锁的目的就是在不同的场景下分别阻止其中一种或多种竟态产生,从而保证这个临界区的资源不会同时被两个执行单元访问而造成数据不同步或混乱。在内核中常常用自旋锁来保护内核数据结构的操作。执行的过程就是执行单元到达数据临界区判断临界区是否可用如果可以获取临界资源,成功则获取资源继续执行;此时若另一个执行单元执行到达这里会发现资源被占用则会原地执行检查直到资源可用才可以继续往下执行。
说到这里应该就会发现一个问题如果一个低优先级的的执行单元先获取了临界资源还未使用完此时高优先级任务来到了那么高优先级任务就会一直自旋并且低优先级无法获取CPU时间无法继续执行临界资源无法释放就会造成死锁同样中断也会导致出现类似情况。因此单核系统中如果不支持抢占则自旋锁获取锁只需要关闭中断就可以;如果支持抢占则还需要关闭抢占。
最后再来看多核系统环境,除了上面的两种情况的肯定都会有发生外还有额外的情况就是在多个处理器平台下代码的执行是可以正真正并行的。要想A核心拿到共享资源后B核心不会拿到关闭中断和抢占是不够的,因为Linux不提供接口关闭整个系统的中断的机制因为成本太大,其次是关闭抢占是针对调度器而言的但在多核平台(SMP)的每个处理器的调度是独立的所以还需要另外一层机制来保证多核间的互斥。所以在SMP平台下的自旋锁是需要内存辅助操作作为标志才能做到和其他核心互斥访问共享资源,因为SMP系统多个处理器之间内存访问是相互可见的。 综上这就是自旋锁在不同情况下的工作细节内容,其中抢占和核心数是在编译构建内核时可以配置的所以配置好后自旋锁对应的API接口的实现就已经确定并且是支持的当前系统的自旋操作,而是否需要屏蔽中断则是只有程序开发者知道,因为共享资源的访问会不会发生在中断中这是应用逻辑的内容。
自旋锁的常用API接口:
static inline void spin_lock(spinlock_t*lock) static inline void spin_unlock(spinlock_t*lock)
static inline void spin_lock_bh(spinlock_t*lock) static inline void spin_unlock_bh(spinlock_t*lock)
static inline void spin_lock_irq(spinlock_t*lock) static inline void spin_unlock_irq(spinlock_t*lock)
spin_lock_irqsave(lock, flags) static inline void spin_unlock_irqrestore(spinlock_t*lock, unsigned long flags)
读写锁
读写锁的出现是为了优化自旋锁的不足,因为常常存在这样一种情况,有多个线程只会读取共享资源而另一个进程只会进行写操作,这种情况很常见如果使用自旋锁效率就很低。因为我们知道读接口和读接口肯定不会产生竟态但却互斥操作了,所以为了解决这样缺点Linux内核定义了读写锁,即多个执行单元写操作相互互斥写和读也互斥,但是读和读之间就不需要互斥这样的好处是读取锁可以多次获取提高了读取的效率。常用API如下:
//定义 rwlock_t xxx_rwlock; //初始化 rwlock_init(rwlock_t* lock); //读锁定 read_lock(rwlock_t* lock); read_lock_irqsave(rwlock_t* lock,unsigned long flags);//关中断并记录中断之前的值后续恢复 read_lock_irq(rwlock_t* lock);//关中断 read_lock_bh(rwlock_t* lock);//关底半部 //读解锁 read_unlock(rwlock_t* lock); read_unlock_irqsave(rwlock_t* lock,unsigned long flags);//开中断并恢复中断之前的值后续 read_unlock_irq(rwlock_t* lock);//开中断 read_unlock_bh(rwlock_t* lock);//开中断 //写锁定 write_lock(rwlock_t* lock); write_lock_irqsave(rwlock_t* lock,unsigned long flags); write_lock_irq(rwlock_t* lock); write_lock_bh(rwlock_t* lock); //写解锁 write_unlock(rwlock_t* lock); write_unlock_irqsave(rwlock_t* lock,unsigned long flags); write_unlock_irq(rwlock_t* lock); write_unlock_bh(rwlock_t* lock);
顺序锁
顺序锁是对读写锁性能的优化,增加了对读和写的并发支持。实现的机制就是如果读取过程有过写的操作就需要重读,这个机制是要有编程人员主动调用接口查询的,读取结束后通过重读接口查询是否发生过写如果发生过就需要重新读取。API:
seqlock_init(x); //动态初始化 DEFINE_SEQLOCK(x); //静态初始化 //获取顺序锁 void write_seqlock(seqlock_t* sl); //写加锁 int write_tryseqlock(seqlock_t* sl); //尝试写加锁 write_seqlock_irqsave(lock, flags); //local_irq_save() + write_seqlock() write_seqlock_irq(lock); //local_irq_disable() + write_seqlock() write_seqlock_bh(lock); //local_bh_disable() + write_seqlock() //释放顺序锁 void write_sequnlock(seqlock_t* sl); //写解锁 write_sequnlock_irqrestore(lock, flags); //write_sequnlock() + local_irq_restore() write_sequnlock_irq(lock); //write_sequnlock() + local_irq_enable() write_sequnlock_bh(lock); //write_sequnlock() + local_bh_enable()
读操作
//读操作 unsigned int read_seqbegin(const seqlock_t* sl); read_seqbegin_irqsave(lock, flags); //local_irq_save() + read_seqbegin()
读执行单元在访问共享资源时要调用顺序锁的读函数,返回顺序锁s1的顺序号;该函数没有任何获得锁和释放锁的开销,只是简单地返回顺序锁当前的序号;
重读检查
int read_seqretry(const seqlock_t* sl, unsigned start); read_seqretry_irqrestore(lock, iv, flags);
在顺序锁的一次读操作结束之后,调用顺序锁的重读函数,用于检查是否有写执行单元对共享资源进行过写操作;如果有就会重新读取共享资源;iv为顺序锁的id号;
信号量
信号量是所有系统软件中最典型也更高层次的用于同步和互斥的软件手段,进程执行前先获取信号量如果获取成功则继续执行如果获取不到则会阻塞。并且会将当前进程挂接到对应的等待队列上,如果由有进程释放这个信号量时就会唤醒这个队列上的线程(是谁先阻塞先唤醒谁)内容比较简直接看API:
//定义 struct semaphore sem; //初始化 void sema_init(); //获取信号量 void down(); //进程阻塞后可被信号唤醒 void down_interruptible(); //获取信号量非阻塞版 void down_trylock(); //释放信号量 void up();
互斥体
互斥体应该是比信号量更加适合资源互斥的机制了,他和信号量的最大区别就是信号量的值可以大过1,而互斥信号量只能是0和1所以也叫二值信号量。他的常用API:
//定义 struct mutex my_mutex; //初始化 void mutex_init(); //获取互斥 void mutex_lock(); //进程阻塞后可被信号唤醒 void mutex_lock_interruptible(); //获取互斥量非阻塞版 void mutex_lock_trylock(); //释放 void mutex_unlock(); //实例 struct mutex my_mutex; mutex_init(&my_mutex); mutex_lock(&my_mutex); . . . void mutex_unlock(&my_mutex);
完成量
网上看到的资料介绍说完成量是一种比信号量更好的同步机制,相比信号量他比信号量更加轻量化,这一部分我的体会不是很深刻反而觉得完成量比信号量多了一个机制就是支持唤醒多个等待在当前完成量上的执行单元。而信号量如果要唤醒多个就需要多次调用信号释放接口,因为信号量默认的唤醒顺序是根据在当前信号量加入阻塞的顺序唤醒的,比如A-B-C进程依次添加到阻塞队列上则唤醒顺序也是如此,最后看一下完成量的使用接口函数。
//静态定义 struct completion my_completion; #define DECLARE_COMPLETION(work) //初始化 static inline void init_completion(struct completion *x) void reinit_completion(struct completion *x) //等待 void wait_for_completion(struct completion *); void wait_for_completion_io(struct completion *); int wait_for_completion_interruptible(struct completion *x); int wait_for_completion_killable(struct completion *x); unsigned long wait_for_completion_timeout(struct completion *x,unsigned long timeout); unsigned long wait_for_completion_io_timeout(struct completion *x,unsigned long timeout); long wait_for_completion_interruptible_timeout(struct completion *x, unsigned long timeout); long wait_for_completion_killable_timeout(struct completion *x, unsigned long timeout); bool try_wait_for_completion(struct completion *x); //唤醒 void complete(struct completion *); void complete_all(struct completion *);
唤醒的两个接口在有多个线程在等待相同的完成量的时候的效果有所不同,complete只会唤醒一个例程,而complete_all会唤醒所有的等待待者。不过大多数时候只有一个进程等待一个完成量,还有就是完成量一般是单次使用的,就是使用一次后就需要重新初始化,也有特例不过通常情况下就是按单次使用,每一次完整的使用过程即:初始化 ,等待,完成。这个过程结束后建议调用reinit_completion快速重新初始化。完成量的典型使用是在模块退出时的内核线程终止,在这种原型中,某些驱动程序的内部工作由一个内核线程在while (1)循环中完成,当内核准备清除该模块时,exit函数会告诉该线程退出并等待完成量;为了实现这个目的,内核包含了可用于这种线程的一个特殊函数。
void complete_and_exit(struct completion *comp, long code)
实例
static int ldlm_pools_thread_main(void *arg) { struct ptlrpc_thread *thread = (struct ptlrpc_thread *)arg; int c_time; thread_set_flags(thread, SVC_RUNNING); wake_up(&thread->t_ctl_waitq); CDEBUG(D_DLMTRACE, "%s: pool thread starting, process %d\n", "ldlm_poold", current_pid()); while (1) { struct l_wait_info lwi; /* * Recal all pools on this tick. */ c_time = ldlm_pools_recalc(LDLM_NAMESPACE_CLIENT); /* * Wait until the next check time, or until we're * stopped. */ lwi = LWI_TIMEOUT(cfs_time_seconds(c_time), NULL, NULL); l_wait_event(thread->t_ctl_waitq, thread_is_stopping(thread) || thread_is_event(thread), &lwi); if (thread_test_and_clear_flags(thread, SVC_STOPPING)) break; thread_test_and_clear_flags(thread, SVC_EVENT); } thread_set_flags(thread, SVC_STOPPED); wake_up(&thread->t_ctl_waitq); CDEBUG(D_DLMTRACE, "%s: pool thread exiting, process %d\n", "ldlm_poold", current_pid()); complete_and_exit(&ldlm_pools_comp, 0); } static void ldlm_pools_thread_stop(void) { if (!ldlm_pools_thread) return; thread_set_flags(ldlm_pools_thread, SVC_STOPPING); wake_up(&ldlm_pools_thread->t_ctl_waitq); /* * Make sure that pools thread is finished before freeing @thread. * This fixes possible race and oops due to accessing freed memory * in pools thread. */ wait_for_completion(&ldlm_pools_comp); kfree(ldlm_pools_thread); ldlm_pools_thread = NULL; }
最后需要注意的是在使用互斥操作时一定要注意避免死锁和优先级反转,死锁和优先级反转是相同的原因导致的不同现象。死锁是指一个进程先获得了锁A此时另一个进程获得了锁B然后又要获取锁A刚好获取了A的进程也想获得锁B这时候就会发生死锁,解决这一问题的办法是关闭抢占,如果是进程和中断之间的需要关闭中断。而优先级反转是低优先级的进程因为高优先级的进程无法获取锁然后先于高优先级进程而被执行的从而降低了系统的实时性的现象。解决办法有三种如下:
1、优先级天花板。当低优先级进程使用资源时,就把低优先级进程的优先级提升到能访问资源的最高优先级,执行完成释放资源之后,把优先级再改回来;这样的方法,简单易行,解决了多个高优先级任务抢占资源的问题。但是带来了一些缺点,就是不一定每次都有高优先级任务抢占资源每次都提升优先级是对CPU资源的一种浪费。
2、优先级继承。当进程A使用资源时,进程B抢占执行权,申请资源S,比较进程A和B的优先级,假如进程B优先级高就提升进程A的优先级到和进程B相同的优先级,当进程A释放资源后,将优先级再调整回来。相对于优先级天花板方法的特点是逻辑复杂,需要操作系统支持相同优先级。
3、两者结合的方案:当进程A使用资源时,进程B抢占执行权,申请资源,比较进程A和进程B的优先级,假如进程B优先级高才提升进程A到能访问资源的最高优先级当进程A释放资源后将优先级再调整回来。
参考:https://www.cnblogs.com/wanpengcoder/p/11759841.html