7.Linux设备驱动中的并发控制
7.1 并发与竞态
并发(concurrency)指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(race conditions)。
1.对称多处理器(SMP)的多个CPU
SMP是一种紧耦合、共享存储的系统模型,其体系结构如下图,它的特点是多个CPU使用共同的系统总线,因此可访问共同的外设和存储器。
2.单CPU内进程与抢占它的进程
Linux 2.6内核支持抢占调度,一个进程内的内核执行的时候可能被另一高优先级进程打断,进程与抢占它的进程访问共享资源的情况类似于SMP的多个CPU。
3.中断(硬中断、软中断、Tasklet、底半部)与进程之间
中断可以打断正在执行的进程,如果中断处理程序访问进程正在访问的资源,则竞态也会发生。
此外,中断也有可能被新的更高优先级的中断打断,因此,多个中断之间本身也可能引起并发而导致竞态。
上述并发的发生情况除了SMP是真正的并行以外,其他的都是“宏观并行、微观串行”的,但其引发的实质问题和SMP相似。
解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。
访问共享资源的代码区域被称为临界区(critical sections),临界区需要被以某种互斥机制加以保护。中断屏蔽、原子操作、自旋锁和信号量等是Linux设备驱动中采用的互斥途径。
7.2 中断屏蔽
在单CPU范围内避免竞态的一种简单而省事的方法是在进入临界区之前屏蔽系统的中断。CPU一般都具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生,具体而言,中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于Linux内核的进程调度等操作等都依赖中断实现,内核抢占进程之间的并发也得以避免了。
中断屏蔽的使用方法:
local_irq_disable() /*屏蔽中断*/
...
critical_section /*临界区*/
...
local_irq_enable() /*开中断*/
由于Linux的异步I/O、进程调度等很多重要操作都依赖于中断,中断对于内核的运行非常重要,在屏蔽中断期间所有的中断都无法得到处理,因此长时间屏蔽中断时很危险的,有可能造成数据丢失乃至系统崩溃等后果,这就要求在屏蔽了中断之后,当前的内核执行路径应当尽快地执行完临界区的代码。
local_irq_disable()和local_irq_enable()都只能禁止和使能本CPU内的中断,因此,并不能解决SMP多个CPU引发的竞态。因此,单独使用中断屏蔽通常不是一种值得推荐的避免竟态的方法,它适宜与下文将要介绍的自旋锁联合使用。
与local_irq_disable不同的是,local_irq_save(flags)除了进行禁止中断的操作以外,还保存目前的CPU的中断位信息,local_irq_restore(flags)进行的是与local_irq_save(flags)相反的操作。
如果只是想禁止中断的底半部,应使用local_bh_disable,还能被local_bh_disable禁止的底半部应该调用local_bh_enable()。
7.3 原子操作
原子操作指的是在执行过程中不会被别的代码路径所中断的操作。
Linux内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对位和整形变量进行原子操作。它们的共同点是在任何情况下操作都是原子的,内核代码可以安全地调整他们而不被打断。位和整形变量原子操作都依赖底层CPU的原子操作来实现,因此所有这些函数都与CPU架构密切相关。
7.3.1 整形原子操作
1.设置原子变量的值
void atomic_set(atomic_t *v, int i); /*设置原子变量的值为i*/
atomic_t v=ATOMIC_INIT(0); /*定义原子变量V并初始化为0*/
2.获取原子变量的值
atomic_read(atomic_t *v); /*返回原子变量的值*/
3.原子变量加/减
void atomic_add(int i, atomic_t *v); /*原子变量增加i*/
void atomic_sub(int i, atomic_t *v); /*原子变量减少i*/
4.原子变量自增/自减
void atomic_inc(atomic_t *v); /*原子变量增加1*/
void atomic_dec(atomic_t *v); /*原子变量减少1*/
5.操作并测试
int atomic_inc_and_test(atomic_t *v); /*测试其是否为0,为0返回true,否则返回false*/
int atomic_dec_and_test(atomic_t *v);
int atomic_sub_and_test(int i, atomic_t *v);
6.操作并返回
int atomic_add_reture(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) /*操作完成后返回新的值*/
7.3.2 位原子操作
1.设置位
void set_bit(nr, void *addr); /*设置addr地址的第nr位,所谓设置位即是将位写为1*/
2.清除位
void clear_bit(nr, void *addr); /*清除addr地址的第nr位,所谓清除位即是将位写入为0*/
3.改变位
void change_bit(nr, void *addr); /*对addr地址的第nr位进行反置*/
4.测试位
void test_bit(nr, void *addr); /*返回addr地址的第nr位*/
5.测试并操作位
int test_and_set_bit(nr, void *addr);
int test_and_clear_bit(nr, void *addr);
int test_and_change_bit(nr, void *addr);
上述test_and_xxx_bit(nr, void* addr)操作等同于执行test_bit(nr, void *addr)后再执行xxx_bit(nr, void *addr)。
7.4 自旋锁
7.4.1 自旋锁的使用
自旋锁(spin lock)是一种典型的对临界资源进行互斥访问的手段,其名称来源于它的工作方式。为了获得一个自旋锁,在某CPU上运行的代码需先执行一个原子操作,该操作测试并设备(test-and-set)某个内存变量,由于它是原子操作,所以在该操作完成之前其他执行单元不可能访问这个内存变量。如果测试结果表明锁已经空闲,则程序获得这个自旋锁并继续执行:如果测试结果表明锁仍被占用,程序将在一个小的循环内重复这个“测试并设置”操作,即进行所谓的“自旋”,通俗地说就是“在原地打转”。当自旋锁的持有者通过重置该变量释放这个自旋锁后,某个等待的“测试并设置”操作向其调用者报告锁已释放。
理解自旋锁最简单的方法是把它作为一个变量看待,该变量把一个临界区或者标记为“我当前在运行,请稍等一会”或者标记为“我当前不在运行,可以被使用”。如果A执行单元首先进入例程,它将持有自旋锁;当B执行单元试图进入同一个例程时,将获知自旋锁已被持有,需等到A执行单元释放后才能进入。
Linux中与自旋锁相关的操作主要有以下4种:
1.定义自旋锁
spinlock_t lock;
2.初始化自旋锁
spin_lock_init(lock) 该宏用于动态初始化自旋锁lock
3.获得自旋锁
spin_lock(lock) 该宏用于获得自旋锁lock,如果能够立即获得锁,它就马上返回,否者他将自旋在那里,直到该自旋锁的保持者释放。
spin_trylock(lock) 该宏尝试获得自旋锁lock,如果立即获得锁,它获得锁并返回真,否者立即返回假,实际上不再“在原地打转”。
4.释放自旋锁
spin_unlock(lock) 该宏释放自旋锁lock,它与spin_trylock或spin_lock配对使用。
自旋锁主要针对SMP或者单CPU单内核可抢占的情况,对于单CPU和内核不支持抢占的系统,自旋锁退化为空操作。在单CPU和内核可抢占的系统中,自旋锁持有期间内核的抢占将被禁止,由于内核可抢占的单CPU系统的行为实际很类似于SMP系统,因此,在这样的单CPU系统中使用自旋锁仍十分必要。
尽管用了自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候,还可能受到中断和底半部(BH)的影响,为了防止这种影响,就需要用到自旋锁的衍生。spin_lock()/spin_unlock()是自旋锁机制的基础,它们和关中断local_irq_disable()/开中断local_irq_enable()、关底半部local_bh_disable()/开底半部local_bh_enable()、关中断并保存状态字local_irq_save()/开中断并恢复状态local_irq_restore()结合就形成了整套自旋锁机制。关系如下:
spin_lock_irq()=spin_lock()+local_irq_disable()
spin_unlock_irq()=spin_unlock()+local_irq_enable()
spin_lock_irqsave()=spin_lock()+local_irq_save();
spin_unlock_irqstore()=spin_unlock()+local_irq_restore()
spin_lock_bh()=spin_lock()+local_bh_disable()
spin_unlock_bh()=spin_unlock()+local_bh_enable()
spin_lock_irq()、spin_lock_irqsave()、spin_lock_bh()类似函数会为自旋锁的使用系好“安全带”以避免突如其来的中断驶入对系统造成的伤害。
驱动工程师应谨慎使用自旋锁,而且在使用中还要特别注意如下几个问题。
(1)自旋锁实际上是忙等锁,当锁不可用时,CPU一直循环执行“测试并设置”该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。当临界区很大,或有共享设备的时候,需要较长时间占用锁,使用自旋锁会降低系统的性能。
(2)自旋锁可能导致系统死锁,引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,则该CPU将死锁。
(3)自旋锁锁定期间不能调用可能引起进程调度的函数,如果进程获得自旋锁之后再阻塞,如调用copy_from_user()、copy_to_user()、kmalloc()和msleep()等函数,则可能导致内核的崩溃。
7.4.2 读写自旋锁
自旋锁不关心锁定的临界区究竟进行怎样的操作,不管是读还是写,它都一视同仁。即便多个执行单元同时读取临界资源也会被锁住。实际上,对共享资源并发访问时,多个执行单元同时读取它是不会有问题的,自旋锁的衍生所读写自旋锁(rwlock)可允许读的并发。读写自旋锁是一种粒度更小的锁机制,它保留了“自旋”的概念,但是在写操作方面,只能最多有1个写进程,在读操作方面,同时可以有多个读执行单元。当然,读和写也不能同时进行。
读写自旋锁涉及的操作如下:
1.定义和初始化读写自旋锁
rwlock_t my_rwlock=RW_LOCK_UNLOCKED; /*静态初始化*/
rwlock_t my_rwlock;
rwlock_init(&my_rwlock); /*动态初始化*/
2.读锁定
void read_lock(rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock, unsigned long flags);
void read_lock_irq(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
3.读解锁
void read_unlock(rwlock_t *lock);
void read_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void read_unlock_irq(rwlock_t *lock);
void read_unlock_bh(rwlock_t *lock);
在对共享资源进行读取之前,应该先调用读锁定函数,完成之后应调用读解锁函数。read_lock_irqsave()、read_lock_irq()和read_lock_bh()也分别是read_lock()分别与local_irq_save()、local_irq_disable()和local_bh_disable()的组合,读解锁函数read_unlock_irqrestore()、read_unlock_irq()、read_unlock_bh()的情况与此类似。
4.写锁定
void write_lock(rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock, unsigned long flags);
void write_lock_irq(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);
int write_trylock(rwlock_t *lock);
5.写解锁
void write_unlock(rwlock_t *lock);
void write_unlock_irqrestore(rwlock_t *lock, unsigned long falgs);
void write_unlock_irq(rwlock_t *lock);
void write_bh(rwlock_t *lock);
write_lock_irqsave()、write_lock_irq()、write_lock_bh()分别是write_lock()与local_irq_save()、local_irq_disable()和local_bh_disable()的组合,写解锁函数write_unlock_irqrestore()、write_unlock_irq()、write_unlock_bh()的情况与此类似。
在对共享资源进行写之前,应该先调用写锁定函数,完成之后应调用写解锁函数。和spin_trylock()一样,write_trylock()也只是尝试获取读写自旋锁,不管成功失败都会立即返回。
7.4.3 顺序锁
顺序锁(seqlock)是对读写锁的一种优化,若使用顺序锁,读执行单元绝不会被写执行单元阻塞,也就是说,读执行单元在写执行单元对被顺序锁保护的共享资源进行写操作时仍然可以继续读,而不必等待写执行单元完成写操作,写执行单元也不需要等待所有读执行单元完成读操作才去进行写操作。
但是,写执行单元与写执行单元之间仍然是互斥的,即如果有写执行单元在进行写操作,其他写执行单元必须自旋在哪里,直到写执行单元释放了顺序锁。如果读执行单元在读操作期间,写执行单元已经发生了些操作,那么,读执行单元必须重新读取数据,以便确保得到的数据是完整的。这种锁对于读写同时进行的概率比较小的情况,性能是非常好的,而且它允许读写同时进行,因而更大地提高了并发性。
顺序锁有一个限制,它必须要求被保护的共享资源不含有指针,因为写执行单元可能使得指针失效,但读执行单元如果正要访问该指针,将导致oops。
在Linux内核中,写执行单元涉及的顺序锁操作如下:
1.获得顺序锁
void write_seqlock(seqlock_t *sl);
int write_tryseqlock(seqlock_t *sl);
write_seqlock_irqsave(lock, flags);
write_seqlock_irq(lock);
write_seqlock_bh(lock);
write_seqlock_irqsave()=local_irq_save()+write_seqlock()
write_seqlock_irq()=local_irq_disable()+write_seqlock()
write_seqlock_bh()=local_bh_disable()+write_seqlock()
2.释放顺序锁
void write_sequnlock(seqlock_t *sl);
write_sequnlock_irqrestore(lock, flags);
write_sequnlock_irq(lock);
write_sequnlock_bh(lock);
write_sequnlock_irqrestore()=write_sequnlock()+local_irq_restore()
write_sequnlock_irq()=write_sequnlock()+local_irq_enable()
write_sequnlock_bh()=write_sequnlock()+local_bh_enable()
读单元涉及的顺序锁操作如下:
1.读开始
unsigned read_seqbegin(const seqlock_t *ls);
read_seqbegin_irqsave(lock, flags);
读执行单元在对被顺序锁sl保护的共享资源进行访问前需要调用该函数,该函数仅返回顺序锁sl的当前顺序号。其中:
read_seqbegin_irqsave()=local_irq_save()+read_seqbegin()
2.重读
int read_seqretry(const seqlock_t *sl, unsigned iv);
read_seqretry_irqrestore(lock, iv, flags)
读执行单元在访问完被顺序锁sl保护的共享资源后需要调用该函数来检查,在读访问期间是否有写操作,如果有写操作,读执行单元就需要重新进行操作,其中:
read_seqretry_irqrestore()=read_seqretry()+local_irq_restore()
7.4.4 读-拷贝-更新
RCU(Read-Copy Update, 读-拷贝-更新),它是基于其原理命名的。RCU并不是新的锁机制,它只是对Linux内核而言是新的。
对于被RCU保护的共享数据结构,读执行单元不需要获得任何锁就可以访问它,不使用原子指令,而且在除alpha的所有架构上也不需要内存屏障(Memory Barrier),因此不会导致所竞争、内存延迟以及流水线停滞。不需要锁也使得使用更容易,因为死锁问题就不需要考虑了。使用RCU的写执行单元在访问它前需首先拷贝一个副本,然后对副本进行修改,最后使用一个回调机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据,这个时机就是所有引用该数据的CPU都退出对共享数据的操作的时候。读执行单元没有任何同步开销,而写执行单元的同步开销则取决于使用的写执行单元间同步机制。
RCU可以看作读写锁的高性能版本,相比读写锁,RCU的优点在于既允许多个读执行单元同时访问被保护的数据,又允许多个读执行单元和多个写执行单元同时访问被保护数据,但是,RCU不能替代读写锁,因为如果写比较多时,对读执行单元的性能提高不能弥补写执行单元导致的损失。因为使用RCU时,写执行单元之间的同步开销会比较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行的其他写执行单元的修改操作。
LInux中提供的RCU操作包括如下4种:
1.读锁定
rcu_read_lock()
rcu_read_lock_bh()
2.读解锁
rcu_read_unlock()
rcu_read_unlock_bh()
其中,rcu_read_lock()和rcu_read_unlock()实质只是禁止和使能内核的抢占调度:
#define rcu_read_lock() preempt_disable()
#define rcu_read_unlock() preempt_enable()
其变种rcu_read_lock_bh()、rcu_read_unlock_bh()则定义为:
#define rcu_read_lock_bh() local_bh_disable()
#define rcu_read_unlock_bh() local_bh_enable()
3.同步
synchronize_rcu()
该函数由RCU写执行单元调用,它将阻塞写执行单元,直到所有的读执行单元已经完成读执行单元临界区,写执行单元才可以继续下一步操作。如果有多个RCU写执行单元调用该函数,它们将在一个grace period(即所有的读执行单元已经完成对临界区的访问)之后全部被唤醒。synchronize_rcu()保证所有CPU都处理完正在运行的读执行单元临界区。
synchronize_kernel()
内核代码使用该函数来等待所有CPU处于可抢占状态,目前功能等同于synchronize_rcu(),但现在已经不建议使用,而是使用synchronize_sched(),该函数用于等待所有CPU都处在可抢占状态,它能保证正在运行的中断处理函数处理完毕,但不能保证正在运行的软中断处理完毕。
4.挂接回调
void call_rcu(struct rcu_head *head, void(*func)(struct rcu_head* rcu));
函数call_rcu()也由RCU写执行单元调用,它不会使写执行单元阻塞,因而可以在中断上下文或软中断使用。该函数将把函数func挂接到RCU回调函数链上,然后立即返回。函数synchronize_rcu()的实现实际上使用了call_rcu()函数。
void call_rcu_bh(struct rcu_head * head, void(*func)(struct rcu_head *head));
call_rcu_bh()函数的功能几乎与call_rcu()完全相同,唯一差别就是它把软中断的完成也当作经历一个quiescent state(静默状态),因此如果写执行单元使用了该函数,在进程上下文的读执行单元必须使用rcu_read_lock_bh()。
每个CPU维护两个数据结构rcu_data和rcu_bh_data,它们用于保存回调函数,函数call_rcu()把回调函数注册到rcu_data,而call_rcu_bh()则把回调函数注册到rcu_bh_data,在每一个数据结构上,回调函数被组成一个链表,先注册的排在前头,后注册的排在末尾。
使用RCU时,读执行单元必须提供一个信号给写执行单元以便写执行单元能够确定数据可以被安全地释放或修改的时机。有一个专门的垃圾收集器来探测读执行单元的信号,一旦所有的读执行单元都已经发送信号告知他们都不在使用被RCU保护的数据结构,垃圾收集器就调用回调函数完成最后的数据释放或修改操作。
5.RCU还增加了链表操作函数的RCU版本:
static inline void list_add_rcu(struct list_head *new, struct list_head *head);
该函数把链表元素new插入RCU保护的链表head的开头,内存栅保证了在引用这个新插入的链表元素之前,新链表元素的链接指针的修改对所有读执行单元是可见的。
static inline void list_add_tail_rcu(struct list_head *new, struct list_head *head);
该函数类似于list_add_rcu(),它将把新的链表元素new添加到被RCU保护的链表的末尾。
static inline void list_del_rcu(struct list_head *entry);
该函数从RCU保护的链表中删除指定的链表元素entry。
static inline void list_replace_rcu(struct list_head *old, struct list_head *new);
该函数是RCU新添加的函数,并不存在非RCU版本。它使用新的链表元素new取代旧的链表元素old,内存栅保证在引用新的链表元素之前,它对链接指针的修正对所有读执行单元是可见的。
list_for_each_rcu(pos, head);
该宏用于遍历由RCU保护的链表head,只要在读执行单元临界区使用该函数,它就可以安全地和其他RCU链表操作函数,如list_add_rcu()并发运行。
list_for_each_safe_rcu(pos, n, head)
该宏类似于list_for_each_rcu,但不同之处在于它允许安全地删除当前链表元素pos。
list_for_each_entry_rcu(pos, head, member)
该宏类似于list_for_each_rcu,不同之处在于它用于遍历指定类型的数据结构链表,当前链表元素pos为一包含struct list_head结构的特定的数据结构。
static inline void hlist_del_rcu(struct hlist_node *n)
它从由RCU保护的哈希链表中移走链表元素n。
static inline void hlist_add_head_rcu(struct hlist_node *n, struct hlist_head *h);
该函数用于把链表元素n插入被RCU保护的哈希链表的开头,但同时允许读执行单元对该哈希链表的遍历。内存栅确保在引用新链表元素之前,它对指针的修改对所有读执行单元可见。
hlist_for_each_rcu(pos, head);
该宏用于遍历由RCU保护的哈希链表head,只要在读端临界区使用该函数,它就可以安全地和其他rcu哈希链表操作函数(如hlist_add_rcu)并发运行。
hlist_for_each_entry_rcu(tpos, pos, head, member);
类似于hlist_for_each_rcu(),不同之处在于它用于遍历指定类型的数据结构哈希链表,当前链表元素pos为一包含struct list_head结构的特定的数据结构。
目前,RCU的使用在内核中已经非常普遍,内核中大量原先使用读写锁的代码被RCU替换。
7.5 信号量
7.5.1 信号量的使用
信号量(semaphore)是用于保护临界区的一种常用方法,它的使用方式和自旋锁类似。与自旋锁相同,只有得到信号量的进程才能执行临界区代码,但是,与自旋锁不同的是,当获取不到信号量时,进程不会原地打转而是进入休眠等待状态。
Linux中与信号量相关的操作主要有:
1.定义信号量
下面代码定义名称为sem的信号量:struct semaphore sem;
2.初始化信号量
void sema_init(struct semaphore *sem, int val);
该函数初始化信号量,并设置信号量sem的值为val。尽管信号量可以被初始化为大于1的值,从而成为一个计数信号量,但是它通常不被这样使用。
#define init_MUTEX(sem) sema_init(sem, 1)
该宏用于初始化一个用于互斥的信号量,它把信号量sem的值设置为1。
#define init_MUTEX_LOCKED(sem) sema_init(sem, 0)
该宏也用于初始化一个信号量,但他把信号量sem的值设置为0.
下面两个宏是定义并初始化信号量的“快捷方式”:
DECLARE_MUTEX(name)
DECLARE_MUTEX_LOCKED(name)
前者定义一个名为name的信号量并初始化为1,后者定义一个名为name的信号量并初始化为0。
3.获得信号量
void down(struct semaphore *sem);
该函数用于获得信号量sem,它会导致休眠,因此不能在中断上下文使用。
int down_interruptible(struct semaphore *sem);
该函数功能与down类似,不同之处为,因为down()而进入睡眠状态的进程不能被信号打断,但因为down_interruptible()而进入睡眠状态的进程能被信号打断,信号会导致该函数返回,这时候的返回值非0.
int down_trylock(struct semphore *sem);
该函数尝试获得信号量sem,如果能够立刻获得,它就获得该信号量并返回0,否则,返回非0值。它不会导致调用者睡眠,可以在中断上下文使用。
在使用down_interruptible()获取信号量时,对返回值一般会进行检查,如果非0,通常立即返回-ERESTARTSYS。
4.释放信号量
void up(struct semphore *sem);
该函数释放信号量sem,唤醒等待者。
7.5.2 信号量用于同步
如果信号量被初始化为0,则它可以用于同步,同步意味着一个执行单元的继续执行需等待另一执行单元完成某事,保证执行的先后顺序。
7.5.3 完成量用于同步
完成量(completion),它用于一个执行单元等待另一个执行单元执行完成某事。
Linux中与completion相关的操作主要有以下4种:
1.定义完成量
下列代码定义名为my_completion的完成量:struct completion my_completion;
2.初始化completion
下列代码初始化my_completion这个完成量:init_completion(&my_completion);
对my_completion的定义和初始化可以通过如下快捷方式下实现:DECLARE_COMPLETION(my_completion);
3.等待完成量
下列函数用于等待一个completion被唤醒:void wait_for_completion(struct completion *c);
4.唤醒完成量
下面两个函数用于唤醒完成量:
void completion(struct completion *c);
void complete_all(struct completion *c);
前者只唤醒一个等待的执行单元,后者释放所有等待同一完成量的执行单元。
7.5.4 自旋锁vs型号量
自旋锁和信号量都是解决互斥问题的基本手段,面对特定的情况,应该如何取舍这两种手段,选择的依据是临界区的性质和系统的特点。
从严格意义上说,信号量和自旋锁属于不同层次的互斥手段,前者的实现有赖于后者。在信号量本身的实现上,为了保证信号量结构存取的原子性,在多CPU中需要自旋锁来互斥。
信号量是进程级的,用于多个进程之间对资源的互斥,虽然也是在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺资源的。如果竞争失败,会发生进程上下文切换,当前进程进入睡眠状态,CPU将运行其他进程。鉴于进程上下文切换的开销也很大,因此,只有当进程占用资源时间较长时,用信号量才是较好的选择。
当所要保护的临界区访问时间比较短时,用自旋锁是非常方便的,因为它节省上下文切换的时间。但是CPU得不到自旋锁会在那里空转直到其他执行单元解锁为止,所有要求锁不能在临界区里长时间停留,否者会降低系统的效率。由此,可以总结出自旋锁和信号量选用的3项原则。
(1)当锁不能被获取到时,使用信号量的开销是进程上下文切换的时间,使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定),那个用的时间少就选哪种方法。
(2)信号量所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样的代码的临界区。因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。
(3)信号量存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在信号量和自旋锁之间只能选择自旋锁。当然,如果一定要使用信号量,则只能通过down_trylock()方式进行,不能获取就立即返回以避免阻塞。
7.5.5 读写信号量
读写信号量与信号量的关系与读写自旋锁和自旋锁的关系类似,读写信号量可能引起进程阻塞,但它允许N个读执行单元同时访问共享资源,而最多只能有一个写执行单元。因此,读写信号量是一种相对放宽条件的粒度稍大于信号量的互斥机制。
读写信号量涉及的操作包括以下5种:
1.定义和初始化读写信号量
struct rw_semaphore my_rws; /*定义读写信号量*/
void init_rwsem(struct rw_semaphore *sem); /*初始化读写信号量*/
2.读信号量获取
void down_read(struct rw_semaphore *sem);
int down_read_trylock(struct rw_semaphore *sem);
3.读信号量释放
void up_read(struct rw_semaphore *sem);
4.写信号量获取
void down_write(struct rw_semaphore *sem);
int down_write_trylock(struct rw_semaphore *sem);
5.写信号量释放
void up_write(struct rw_semaphore *sem);
7.6 互斥体
尽管信号量已经可以实现互斥的功能,而且包含DECLARE_MUTEX()、init_MUTEX()等定义信号量的宏或函数,
下面代码定义名为my_mutex的互斥体并初始化它:
struct mutex my_mutex;
mutex_init(&my_mutex);
下面的两个函数用于获取互斥体:
void inline __sched mutex_lock(struct mutex *lock);
int __sched mutex_lock_interruptible(struct mutex *lock);
int __sched mutex_trylock(struct mutex *lock);
mutex_lock()与mutex_lock_interruptible()的区别和down()与down_trylock()的区别完全一致,前者引起的睡眠不能被信号打断,而后者可以。mutex_trylock()用于尝试获得mutex,获取不到mutex时不会引起进程睡眠。
下列函数用于释放互斥体:
void __sched mutex_unlock(struct mutex *lock);
7.7 增加并发控制后的globalmem驱动
在globalmem()的读写函数中,由于要调用copy_from_user()、copy_to_user()这些可能导致阻塞的函数,因此不能使用自旋锁,宜使用信号量。
驱动工程师习惯将某设备所使用的自旋锁、信号量等辅助手段也放在设备结构中。在访问globalmem_dev中的共享资源时,需先获取这个信号量,访问完成后,随机释放这个信号量。
8.Linux设备驱动中的阻塞与非阻塞I/O
8.1 阻塞与非阻塞I/O
阻塞操作是指在执行设备操作时,若不能获得资源,则挂起进程直到满足可操作条件后再进行操作。被挂起的进程进入休眠状态,被从调度器的运行队列移走,直到等待的条件被满足。而非阻塞操作的进程在不能进行设备操作时,并不挂起,它或者放弃或者不停地查询,直至可以进行操作为止。
驱动程序通常需要提供这样的能力;当应用程序进行read()、write()等系统调用时,若设备的资源不能获取,而用户又希望以阻塞的方式访问设备,驱动程序应在驱动的xxx_read()、xxx_write()等操作中将进程阻塞直到资源可以获取,此后,应用程序的read()、write()等调用才返回,整个过程仍然进行了正确的设备访问,用户并没有感知到。若用户以非阻塞的方式访问设备文件,则当设备资源不可获取时,设备驱动的xxx_read()、xxx_write()等操作应立即返回,read()、write()等系统调用也随即被返回。
阻塞从字面上听起来似乎意味着低效率,实则不然,如果设备驱动不阻塞,则用户想获取设备资源只能不停地查询,这反而无谓地消耗CPU资源。而阻塞访问时,不能获取资源的进程将进入休眠,它将CPU资源“礼让”给其他进程。
因为阻塞的进程会进入休眠状态,因此,必须确保有一个地方能够唤醒休眠的进程,否则,进程就真的“寿终正寝”了。唤醒进程的地方最大可能发生在中断里面,因为硬件资源获得的同时往往伴随着一个中断。
实际的串口编程中,若使用非阻塞模式,还可借助信号(sigaction)以异步的方式访问串口以提高CPU利用率。
8.1.1 等待队列
在Linux驱动程序中,可以使用等待队列(wait queue)来实现阻塞进程的唤醒。wait queue很早就作为一个基本的功能单位出现在Linux内核里了,它以队列为基础数据结构,与进程调度机制紧密结合,能够用于实现内核中的异步事件通知机制。等待队列可以用来同步对系统资源的访问。
Linux 2.6提供如下关于等待队列的操作:
1.定义“等待队列头”
wait_queue_head_t my_queue;
2.初始化“等待队列头”
init_waitqueue_head(&my_queue);
DECLARE_WAIT_QUEUE(name); 作为定义并初始化等待队列头的“快捷方式”
3.定义等待队列
DECLARE_WAITQUEUE(name, tsk); 定义并初始化一个名为name的等待队列。
4.添加/移除等待队列
void fastcall add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait); 将等待队列wait添加到等待队列头q指向的等待队列链表中。
void fastcall remove_wait_queue(wait_queue_head_t *q, wait_queue_t *wait); 将等待队列wait从附属的等待队列头q指向的等待队列链表中移除。
5.等待事件
wait_event(queue, condition);
wait_event_interruptible(queue, condition);
wait_event_timeout(queue, condition, timeout);
wait_event_interruptible_timeout(queue, condition, timeout);
等待第1个参数queue作为等待队列头的等待队列被唤醒,而且第2个参数condition必须满足,否则继续阻塞。wait_event()和wait_event_interruptible()的区别在于后者可以被信号打断,而前者不能,加上timeout后的宏意味着阻塞等待的超时时间,以jiffy为单位,在第3个参数的timeout到达时,不论condition是否满足,均返回。
6.唤醒队列
void wake_up(wait_queue_head_t *queue);
void wake_up_interruptible(wait_queue_head_t *queue);
上述操作会唤醒以queue作为等待队列头的所有等待队列中所有属于该等待队列头的等待队列对应的进程。
wake_up()应该与wait_event()或wait_event_timeout()成对使用,而wake_up_interruptible()则应与wait_event_interruptible()或wait_event_interruptible_timeout()成对使用。wake_up()可唤醒处于TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE的进程,而wake_up_interruptible()只能唤醒处于TASK_INTERRUPTIBLE的进程。
7.在等待队列上的睡眠
sleep_on(wait_queue_head_t *q);
interruptible_sleep_on(wait_queue_head_t *q);
sleep_on()函数的作用就是将目前进程的状态置成TASK_UNINTERRUPTIBLE,并定义一个等待队列,之后把它附属到等待队列头q,直到资源可获得,q引导的等待队列被唤醒。
interruptible_sleep_on()与sleep_on()函数类似,其作用是将目前进程的状态设置成TASK_INTERRUPTIBLE,并定义一个等待队列,之后把它附属到等到队列头q,直到资源可获得,q引导的等待队列被唤醒或者进程收到信号。
sleep_on()函数应该与wake_up()成对使用,interruptible_sleep_on()应该与wake_up_interruptible()成对使用。
不论是sleep_on()还是interruptible_sleep_on(),都会调用sleep_on_common(),其流程如下:
(1)定义并初始化一个等待队列,将进程状态改变为TASK_UNINTERRUPTIBLE(不能被信号打断)或TASK_INTERRUPTIBLE(可以被信号打断),并将等待队列添加到等待队列头。
(2)通过schedule_timeout()放弃CPU(这两个函数传递的超时参数都是MAX_SCHEDULE_TIMEOUT,即不会发生超时),调度其他进程执行。
(3)进程被其他地方唤醒,将等待队列移出等待队列头。
在内核中使用set_current_state()函数或_add_current_state()函数来实现目前进程状态的改变,直接采用current->state=TASK_UNINETERRUPTIBLE类似的赋值语句也是可行的。通常而言,set_current_state()函数在任何环境下都可以使用,不会存在并发问题,但是效率要低于_add_current_state()。
因此,在许多设备驱动中,并不调用sleep_on()或interruptible_sleep_on(),而是亲自进行进程的状态改变和切换。
8.1.2 支持阻塞操作的globalfifo设备驱动
现在我们给globalmem增加这样的约束,把globalmem中的全局内存变成一个FIFO,只有当FIFO中有数据的时候(即有进程把数据写到这个FIFO而且没有被读进程读空),读进程才能把数据读出,而且读取后的数据会从globalmem的全局内存中被拿掉;只有当FIFO非满时(即还有一些空间未被写,或写满后被读进程从这个FIFO中读出了数据),写进程才能往这个FIFO中写入数据。
所谓死锁,就是多个进程循环等待它方占有的资源而无限期地僵持下去的局面。如果没有外力的作用,那么死锁涉及的各个进程都将永远处于封锁状态。因此,驱动工程师一定要注意;当多个等待队列、信号量等机制同时出现时,谨防死锁。
8.1.3 在用户空间验证globalfifo的读写
/home/lihacker/develop/svn/ldd6410-read-only/reaining/kernel/drivers/globalfifo/ch8包含了globalfifo的驱动,运行“make”命令编译得到globalfifo.ko。接着insmod模块:
~develop/svn/ldd6410-read-only/training/kernel/drivers/globalfifo/ch8$sudo su
/home/lihacker/develop/svn/ldd6410-read-only/training/kernel/drivers/globalfifo/ch8#insmod globalfifo.ko
创建设备文件节点“/dev/globalfifo”:
/home/lihacker/develop/svn/ldd6410-read-only/training/kernel/drivers/globalfifo/ch8#mknod /dev/globalfifo c 249 0
启动两个进程,一个进程“cat/dev/globalfifo&”在后台执行,一个进程"echo字符串 /dev/globalfifo"在前台执行:
/home/lihacker/develop/svn/ldd6410-read-only/training/kernel/drivers/globalfifo/ch8# cat /dev/globalfifo &
/home/lihacker/develop/svn/ldd6410-read-only/training/kernel/drivers/globalfifo/ch8# echo ‘I want to be‘ > /dev/globalfifo
/home/lihacker/develop/svn/ldd6410-read-only/training/kernel/drivers/globalfifo/ch8# echo ‘a great Chinese Linux drivers Engineer‘ >/dev/globalfifo
每当echo进程向/dev/globalfifo写入一串数据,cat进程就立即将该串数据显现出来。
8.2 轮训操作
8.2.1 轮训的概念与作用
用户程序中,select()和poll()也是与设备阻塞与非阻塞访问息息相关的论题。使用非阻塞I/O的应用程序通常会select()和poll()系统调用查询是否可对设备进行无阻塞的访问。select()和poll()系统调用最终会引发设备驱动中的poll()函数被执行,在2.5.45内核还引入了epoll(),即扩展的poll()。
select()和poll()系统调用的本质一样,前者的BSD UNIX中引入,后者在System V中引入。
8.2.2 应用程序中的轮训编程
应用程序中最广泛用到的是BSD UNIX中引入的select()系统调用,其原型为:
int select(int numfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其中readfds、writefds、exceptfds分别是被select()监视的读、写和异常处理的文件描述符集合,numfds的值是需要检查的号码最高的文件描述符加1。timeout参数是一个指向struct timeval类型的指针,它可以使select()在等待timeout时间后若没有文件描述符准备好则返回。struct timeval数据结构的定义如下:
struct timeval{
int tv_sec; /*秒*/
int tv_usec; /*微秒*/
};
下列操作用来设置、清除、判断文件描述符集合:
FD_ZERO(fd_set *set) 清除一个文件描述符集
FD_SET(int fd, fd_set *set) 将一个文件描述符加入文件描述符集合
FD_CLR(int fd, fd_set *set) 将一个文件描述符从文件描述符集合清除
FD_ISSET(int fd, fd_set *set) 判断文件描述符是否被置位
8.2.3 设备驱动中的轮训编程
设备驱动中poll()函数的原型是:
unsigned int(*poll) (struct file *filp, struct poss_table* wait);
第1个参数为file结构体指针,第2个参数为轮训表指针。这个函数应该进行两项工作。
(1)对可能引起设备文件状态变化的等待队列调用poll_wait()函数,将对应的等待队列头添加到poll_table。
(2)返回表示是否能对设备进行无阻塞读、写访问的掩码。
关键的用于向poll_table注册等待队列的poll_wait()函数的原型如下:
void poll_wait(struct file *filp, wait_queue_head_t *queue, poll_table * wait);
poll_wait()函数的名称非常容易让人产生误会,以为它和wait_event()等一样,会阻塞地等待某事件的发生,其实这个函数并不会引起阻塞。poll_wait()函数所做的工作是把当前进程添加到wait参数指定的等待列表(poll_table)中。
驱动程序poll()函数应该返回设备资源的可获取状态,即POLLIN、POLLOUT、POLLPRI、POLLERR、POLLNVAL等宏的位“或”结果。每个宏的含义都表明设备的一种状态,如POLLIN(定义为0x0001)意味着设备可以无阻塞地读,POLLOUT(定义为0x0004)意味着设备可以无阻塞地写。
8.3 支持轮训操作的globalfifo驱动
8.3.1 在globalfifo驱动中增加轮训操作
在globalfifo的poll函数中,首先将设备结构体中的r_wait和w_wait等待队列头添加到等待列表,然后通过判断dev->current_len是否等于0来获得设备的可读状态,通过判断dev->current_len是否等于GLOBALFIFO_SIZE来获得设备的可写状态。
8.3.2 在用户空间验证globalfifo设备的轮训
9. Linux设备驱动中的异步通知与异步I/O
9.1 异步通知的概念与作用
阻塞与非阻塞访问、poll()函数提供了较好地解决设备访问的机制,但是如果有了异步通知整套机制就更加完整了。
异步通知的意思是:一旦设备就绪,则主动通知应用程序,这样应用程序根本就不需要查询设备状态,这一点非常类似于硬件上“中断”的概念,比较准确的称谓是“信号驱动的异步I/O”。信号是在软件层次上对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是异步的,一个进程不必通过任何操作来等待信号的达到,事实上,进程也不知道信号到底什么时候到达。
阻塞I/O意味着一直等待设备可访问后再访问,非阻塞I/O中使用poll()意味着查询设备是否可访问,而异步通知则意味着设备通知自身可访问,实现了异步I/O,由此可见,这几种方式I/O可以互为补充。
这里要强调的是,阻塞、非阻塞I/O、异步通知本身没有优劣,应该根据不同的应用场景合理选择。
9.2 Linux异步通知编程
9.2.1 Linux信号
使用信号进行进程间通信(IPC)是UNIX中的一种传统机制,Linux也支持这种机制。在Linux中,异步通知使用信号来实现,Linux中可用的信号及其定义如下:
除了SIGSTOP和SIGKILL两个信号外,进程能够忽略或捕获其他的全部信号。一个信号被捕获的意思是当一个信号达到时有相应的代码处理它。如果一个信号没有被这个进程或捕获,内核将采用默认行为处理。
9.2.2 信号的接收
在用户程序中,为了捕获信号,可以使用signal()函数来设备对应信号的处理函数:
void (*signal(int signum, void (*handler))(int))(int);
该函数原型较难理解,它可以分解为:
typedef void(*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
第一个参数指定信号的值,第二个参数指定针对前面信号值得处理函数,若为SIG_IGN,表示忽略该信号;若为SIG_DFL,表示采用系统默认方式处理信号;若为用户自定义的函数,则信号被捕获到后,该函数将被执行。
如果signal()调用成功,它返回最后一次为信号signum绑定的处理函数handler值,失败则返回SIG_ERR。
在进程执行时,按下“Ctrl+c”将向其发出SIGINT信号,kill正在运行的进程将向其SIGTERM信号。
除了signal()函数外,sigaction()函数可用于改变进程接收到特定信号后的行为,它的原型为:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
该函数的第一个参数为信号的值,可以为除SIGKILL及SIGSTOP外的任何一个特定有效的信号。第二个参数是指向结构体sigaction的一个实例的指针,在结构体sigaction的实例中,指定了对特定信号的处理函数,若为空,则进程会以缺省方式对信号处理;第三个参数oldact指向的对象用来保存原来对相应信号的处理函数,可指定oldact为NULL。如果把第二、第三个参数都设为NULL,那么该函数可用于检查信号的有效性。
为了在用户空间中能处理一个设备释放的信号必须完成以下工作:
(1)通过F_SETOWNIO控制命令设置设备文件的拥有者为本进程,这样从设备驱动发出的信号才能被本进程接收到。
(2)通过F_SETFLIO控制命令设置文件支持FASYNC,及异步通知模式。
(3)通过signal()函数连接信号和信号处理函数。
9.2.3 信号的释放
在设备驱动和应用程序的异步通知交互中,仅仅在应用程序端捕获信号是不够的,因为信号没有的源头在设备驱动端。因此,应该在合适的时机让设备驱动释放信号,在设备驱动程序中增加信号释放的相关代码。
(1)支持F_SETOWN命令,能在这个控制命令处理中设置filp->f_owner为对应进程ID。不过此项工作已由内核完成,设备驱动无需处理。
(2)支持F_SETFL命令的处理,每当FASYNC标志改变时,驱动程序中的fasync()函数将得以执行。因此,驱动中应该实现fasync()函数。
(3)在设备资源可获得时,调用kill_fasync()函数激发相应的信号。
驱动中的上述3项工作和应用程序中的3项工作是一一对应的。下图是异步通知处理过程中用户空间和设备驱动的交互。
设备驱动中异步通知编程比较简单,主要用到一项数据结构和两个函数。数据结构是fasync_struct结构体,两个函数分别是:
int fasync_helper(int fd, struct file *flip, int mode, struct fasync_struct **fa); //处理FASYNC标志变更
void kill_fasync(struct fasync_struct **fa, int sig, int band); //释放信号用的函数
和其他的设备驱动一样,将fasync_struct结构体指针放在设备结构体中仍然是最佳选择。
9.3 支持异步通知的globalfifo驱动
9.3.1 在globalfifo驱动中增加异步通知
9.3.2 在用户空间验证globalfifo的异步通知
9.4 Linux 2.6异步I/O
9.4.1 AIO概念与GNU C库函数
Linux中最常用的输入/输出(I/O)模型是同步I/O。在这个模型中,当请求发出之后,应用程序就会阻塞,直到请求满足为止。这是很好的一种解决方案,因为调用应用程序在等待I/O请求完成时不需要占用CPU。但是在某些情况下,I/O请求可能需要与其他进程产生交叠。可移植操作系统接口(POSIX)异步I/O(AIO)应用程序接口(API)就提供了这种功能。
Linux异步I/O是2.6版本内核的一个标准特性,但是我们在2.4版本内核的补丁中也可以找到它,AIO基本思想是允许线程发起很多I/O操作,而不用阻塞或等待任何操作完成。稍后或在接收到I/O操作完成的通知时,进程在检索I/O操作的结果。
select()函数所提供的功能(异步阻塞I/O)与AIO类似,它对通知事件进行阻塞,而不是对I/O调用进行阻塞。
在异步非阻塞I/O中,我们可以同时发起多个传输操作。这需要每个传输操作都有唯一的上下文,这样才能在他们完成时区分到底哪个传输操作完成了,在AIO中,通过aiocb(AIO I/O Control Block)结构体进行区分。这个结构体包含了有关传输的所有信息,以及为数据准备的用户缓冲区。在产生I/O通知(称为完成)时,aiocb结构就被用来唯一标识所完成的I/O操作。
AIO系列API被GUN C库函数所包含,它被POSIX.1b所要求,主要包含如下函数:
1.aio_read
int aio_read(struct aiocb *aiocbp);
在请求进行排队之后会立即返回。如果执行成功,返回值是0,如果出现错误,返回值为-1,并设置errno的值。
请求对一个有效的文件描述符进行异步读操作。这个文件描述符可以表示一个文件、套接字甚至管道。
2.aio_write
int aio_write(struct aiocb *aiocbp);
请求一个异步写操作,函数会立即返回,说明请求已经进行排队(成功时返回值为0,失败时返回值为-1,并相应地设置errno)。
3.aio_error
int aio_error(struct aiocb *aiocbp);
用来去顶请求的状态,函数返回EINPROGRESS说明请求尚未完成,返回ECANCELLED说明请求被应用程序取消了,返回-1说明发生了错误。
4.aio_return
ssize_t aio_return(struct aiocb *aiocbp);
异步I/O和标准I/O方式之间的另外一个区别是不能立即访问这个函数的返回状态,因为异步I/O并没阻塞在read()调用上。在标准的read()调用中,返回状态是在该函数返回时提供的。但是在异步I/O中,我们要使用aio_return函数。
只有在aio_error()调用确定请求已经完成(可能成功,也可能发生了错误)之后,才会调用这个函数。aio_return()的返回值就等价于同步情况中read或write系统调用的返回值(所传输的字节数,如果发生错误,返回值为负数)。
5.aio_suspend
int aio_suspend(const struct aiocb *const cblist[], int n, const struct timespec *timeout);
用户可以使用aio_suspend函数来挂起(或阻塞)调用进程,直到异步请求完成为止,此时会产生一个信号,或者发生其他超时操作。调用者提供了一个aiocb引用列表,其中任何一个完成都会导致aio_suspend返回。
6.aio_cancel
int aio_cancel(int fd, struct aiocb *aiocbp);
允许用户取消对某个文件描述符执行的一个或所有I/O请求。要取消一个请求,用户需提供文件描述符和aiocb指针。如果这个请求被成功取消了,那么这个函数就会返回AIO_CANCELED。如果请求请求完成了,函数会返回AIO_NOTCANCELED。
要取消对某个给定文件描述符的所有请求,用户需要提供这个文件的描述符,并将aiocbp参数设置为NULL。如果所有的请求都取消了,这个函数会返回AIO_CANCELED;如果至少有一个请求没有被取消,那么这个函数就会返回AIO_NOT_CANCELED;如果没有一个请求可以被取消,那么这个函数就会返回AIO_ALLDONE。然后可以使用aio_error来验证每个AIO请求,如果某请求已经被取消了,那么aio_error就会返回-1,并且erron会被设置为ECANCELED。
7.lio_listio
int lio_listio(int mode, struct aiocb* list[], int nent, struct sigevent *sig);
lio_listio()函数可用于同时发起多个传输。这个函数非常重要,它使得用户可以在一个系统调用(一次内核上下文切换)中启动大量的I/O操作。
mode参数可以是LIO_WAIT或LIO_NOWAIT。LIO_WAIT会阻塞这个调用,直到所有的I/O都完成为止。在操作进行排队之后,LIO_NOWAIT就返回。list是一个aiocb引用的列表,最大元素的个数是由nent定义的。如果list的元素为NULL,lio_listio()会将其忽略。
9.4.2 使用信号作为AIO的通知
信号作为异步通知的机制在AIO中仍然适用,为使用信号,使用AIO的应用程序同样需要定义信号处理程序,在指定的信号被产生时会触发调用这个处理程序。作为信号上下文的一部分,特定的aiocb请求被提供给信号处理函数用来区分AIO请求。
9.4.3 使用回调函数作为AIO的通知
除了信号之外,应用程序还提供一个回调(Callback)函数给内核,以便AIO的请求完成后内核调用这个函数。
在创建aiocb请求之后,使用SIGEV_THREAD请求了一个线程回调函数作为通知方法。在回调函数中,通过(struct aiocb*)sigval.sival_ptr可以获得对应的aiocb指针,使用AIO函数可验证请求是否已经完成。
proc文件系统包含了两个虚拟文件,他们可以用来对异步I/O的性能进行优化。
(1)/proc/sys/fs/aio-nr文件提供了系统范围异步I/O请求现在的数目。
(2)/proc/sys/fs/aio-max-nr文件是所允许的并发请求的最大个数,最大个数通常是64KB,这对于大部分应用程序来说都已经足够了。
9.4.4 AIO与设备驱动
在内核中,每个I/O请求都对应一个kiocb结构体,其ki_filp成员指向对应的file指针,通过is_sync_kiocb()可以判断某kiocb是否为同步I/O请求,如果返回非真,表示为异步I/O请求,
块设备和网络设备本身是异步的,只有字符设备必须明确表明应支持AIO。AIO对于大多数字符设备而言都不是必须的,只有极少数设备需要,比如,对于磁带机,由于I/O操作很慢,这时候使用异步I/O将可改善性能。
字符设备驱动程序中,file_operations包含3个与AIO相关的成员函数:
ssize_t (*aio_read)(struct kiocb *iocb, char *buffer, size_t count, loff_t offset);
ssize_t (*aio_write)(struct kiocb *iocb, const char * buffer, size_t count, loff_t offset);
int (*aio_fsync)(struct kiocb *iocb, int datasync);
aio_read和aio_write与file_operations中的read和write中的offset参数不同,它直接传递值,而后者传递的是指针,这是因为AIO从来不需要改变文件的位置。
aio_read和aio_write函数本身不一定完成了读和写操作,它只是发起、初始化读和写操作。
通常而言,具体的字符设备驱动一般不需要实现AIO支持,而内核中仅有fs/direct-io.c,drivers/usb/gadget/inode.c、fs/nfs/direct.c等少量地方使用了AIO。
版权声明:本文为博主原创文章,未经博主允许不得转载。