并发和竞态(Linux设备驱动程序)

来源:互联网 发布:iphone5s没有4g网络 编辑:程序博客网 时间:2024/05/19 11:16

设备中断是异步事件,也会导致代码的并发执行。

内核还提供了许多可延迟代码执行的机制,比如workqueue(工作队列)、tasklet(小任务)以及timer(定时器)等,这些机制使得代码可在任何时刻执行,而不管当前进程在做什么。


大部分竞态可通过使用内核的并发控制原语,并应用几个基本的原理来避免。

竞争通常作为对资源的共享访问结果而产生。

仔细编写的内核代码应该具有最少的共享。这种思想的最明显应用就是避免使用全局变量。

硬件资源本质上就是共享的,而软件资源经常对其他执行线程可用。


资源共享的硬规则:在单个执行线程之外共享硬件或软件资源的任何时候,因为另外一个线程可能产生对该资源的不一致观察,因此必须显式地管理对该资源的访问。

访问管理的常见技术成为“锁定”或者“互斥”——确定一次只有一个执行线程可操作共享资源。

当内核代码创建了一个可能和其他内核部分共享的对象时,该对象必须在还有其他组件引用自己时保持存在(并正常工作)。
在对象尚不能正确工作时,不能将其对内核可用,对这类对象的应用必须得到跟踪。


信号量和互斥体
用户请求 中断处理例程 其他异步上下文的访问
“进入休眠”是一个具有明确定义的术语。当一个Linux进程到达某个时间点,此时它不能进行任何处理时,它将进入休眠状态,这将把处理器让给其他执行线程直到将来它能够继续完成自己的处理为止。
随着我们对内核理解的深入,将遇到大量不能休眠的情况。
我们可以使用一种锁定机制,当进程在等待对临界区的访问时,此机制可让进程进入休眠状态。

在可能出现休眠的情况下,并不是所有的锁定机制都可用。



在拥有这个锁并休眠的情况下工作的锁定原语:信号量(semaphore)

正确使用锁定机制的关键是明确指定需要保护的资源,并确保每一个对这些资源的访问使用正确的锁定。

允许多个并发的读取者是可能的。
一个rwsem可允许一个写入者或无限多个读取者拥有该信号量。写入者具有更高的优先级,当某个给定写入者试图进入临界区时,在所有写入者完成其工作之前,不会允许读取者获得访问。

completion
内核编程中常见的一种模式是,在当前线程之外初始化某个活动,然后等待该活动的结束。
completion是一种轻量级的机制,它允许一个线程告诉另一线程某个工作已经完成。
void wait_for_completion(struct completion *c);
如果代码调用了wait_for_completion且没有人会完成该任务,则将产生一个不可杀的进程。
void complete(struct completion *c);
void complete_all(struct completion *c);
complete只会唤醒一个等待线程,而complete_all允许唤醒所有等待线程。
completion机制的典型使用时模块退出时的内核线程终止。在这种原型中,某些驱动程序的内部工作由一个内核线程在while(1)循环中完成。当内核准备清除该模块时,exit函数会告诉该线程退出并等待completion。


自旋锁
大多数锁通过称为“自旋锁(spinlock)”的机制实现。
自旋锁可在不能休眠的代码中使用,比如中断处理例程。
一个自旋锁是一个互斥设备,它只能有两个值:“锁定”和“解锁”。它通常实现为某个整数值中的单个位。希望获得某特定所得代码测试相关的位。如果锁可用,则“锁定”位被设置,而代码继续进入临界区;相反,如果锁被其他人获得,则代码进入忙循环并重复检查这个锁,直到该锁可用为止。这个循环就是自旋锁的“自旋”部分。
“测试并设置”的操作必须以原子方式完成。
当存在自旋锁时,等待执行忙循环的处理器做不了任何有用的工作。

自旋锁最初是为了在多处理器系统上使用而设计的。只要考虑到并发问题,单处理器工作站在运行可抢占内核时其行为就类似于SMP。
如果非抢占式的单处理器系统进入某个锁上的自旋状态,则会永远自旋下去。
非抢占式的单处理器系统上的自旋锁被优化为不做任何事情。
void spin_lock_init(spinlock_t *lock);
void spin_lock(spinlock_t *lock);
void spin_unlock(spinlock_t *lock);


使用自旋锁上一些必须遵守的规则:
任何拥有自旋锁的代码都必须是原子的,它不能休眠。
内核抢占的情况由自旋锁代码本身处理。任何时候,只要内核代码拥有自旋锁,在相关处理器上的抢占就会被禁止。

在中断处理例程中拥有锁是合法的。
我们需要在拥有自旋锁时禁止中断(仅在本地CPU上)。

自旋锁必须在可能的最短时间内拥有。

自旋锁函数
void spin_lock(spinlock_t *lock);
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
void spin_lock_irq(spinlock_t *lock);
void spin_lock_bh(spinlock_t *lock);
spin_lock_irqsave会在获得自旋锁之前禁止中断,而先前的中断状态保存在flags中。
如果能够确保没有任何其他代码禁止本地处理器的中断(能够确保在释放自旋锁时应该启用中断),则可以使用spin_lock_irq,而无需跟踪标志。
spin_lock_bh在获得锁之前禁止软件中断,但是会让中断保持打开。

如果一个自旋锁可以被运行在(硬件或软件)中断上下文中的代码获得,则必须使用某个禁止中断的spin_lock形式,因为使用其他的锁定函数迟早会导致系统死锁。
void spin_unlock(spinlock_t *lock);
void spin_unlock_irqsave(spinlock_t *lock, unsigned long flags);
void spin_unlock_irq(spinlock_t *lock);
void spin_unlock_bh(spinlock_t *lock);


非阻塞的自旋锁操作
int spin_trylock(spinlock_t *lock);
int spin_trylock_bh(spinlock_t *lock);


内核提供自旋锁的读取者/写入者形式,这种锁允许任意数量的读取者同时进入临界区,但写入者必须互斥访问。
读取者/写入者锁具有rwlock_t类型。


锁陷阱
恰当的锁定模式需要清晰和明确的规则。
当我们创建了一个可被并行访问的对象时,应该同时定义用来控制访问的锁。
不论是信号量还是自旋锁,都不允许锁拥有者第二次获得这个锁,如果试图这么做,系统将挂起。
提供给外部调用的函数必须显式地处理锁定。


锁的顺序规则
在必须获取多个锁时,应该始终以相同的顺序获得。



细粒度锁和粗粒度锁的对比
第一个支持多处理器系统的Linux内核是2.0,其中有且只有一个锁。(如果不小心使用了lock_kernel调用就会看到这个大的内核锁)
在2.2中,一个自旋锁控制对块I/O子系统的访问,而其他的自旋锁用于网络。现代的内核可包含数千个锁,每个锁保护一个小的资源。这种类型的细粒度锁具有良好的伸缩性,它允许每个处理器在执行特定任务时无需和其他处理器正在使用的锁竞争。
细粒度锁本身有其成本,更多的锁导致锁缺陷在内核中危险蔓延的机会大大增加。细粒度的锁将带来某种程度的复杂性,对内核的可维护性产生了很大的副作用。

作为通常的规则,我们应该在最初使用粗粒度的锁。
如果怀疑锁竞争导致性能下降,则可以使用lockmeter工具。这个补丁可度量内核花费在锁上的时间。


免锁算法
经常用于免锁的生产者/消费者任务的数据结构之一是循环缓冲区(circular buffer)。


原子变量
对于共享的资源可能恰好是一个简单的整数值。
内核提供了一种原子的整数类型,称为atomic_t,定义在<asm/atomic.h>中。
一个atomic_t变量在所有内核支持的架构上保存一个int值。


位操作
内核提供了一组可原子地修改和测试单个位的函数。
原子位操作非常快,只要底层硬件允许,这种操作可以使用单个机器指令来执行,并且不需要禁止中断。
这些函数依赖于具体的架构,因此在<asm/bitops.h>中声明。
void set_bit(nr, void *addr);
void clear_bit(nr, void *addr);
void change_bit(nr, void *addr);
void test_and_set_bit(nr, void *addr);
void test_and_clear_bit(nr, void *addr);
void test_and_change_bit(nr, void *addr);


seqlock
2.6内核包含有两个新的机制,可提供对共享资源的快速、免锁访问。
当要保护的资源很小、很简单、会频繁被访问而且写入访问很少发生且必须快速时,就可以使用seqlock。
seqlock会允许读取者对资源的自由访问,但需要读取者检查是否和写入者发生冲突,当这种冲突发生时,就需要重试对资源的访问。


如果在中断处理例程中使用seqlock,则应该使用IRQ安全的版本。
写入者必须在进入由seqlock保护的临界区时获得一个互斥锁。


读取-复制-更新
(read-copy-update,RCU)它针对经常发生读取而很少写入的情形。
被保护的资源应该通过指针访问,而对这些资源的引用必须仅由原子代码拥有。
在需要修改该数据结构时,写入线程首先复制,然后修改副本,之后用新的版本替代相关指针。当内核确信老的版本上没有其他引用时,就可释放老的版本。
在读取端,代码使用受RCU保护的数据结构时,必须将引用数据结构的代码包括在rcu_read_lock和rcu_read_unlock调用之间。

rcu_read_lock调用非常快,它会禁止内核抢占,但不会等待任何东西,用来检验读取“锁”的代码必须是原子的。在调用rcu_read_unlock之后,就不应该存在对受保护结构的任何引用。
0 0
原创粉丝点击