驱动程序中的并发与控制(一)
驱动程序中的并发与控制(二)
驱动程序中的并发与控制(三)
并发与竟态
并发指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态。
在Linux内核中,主要的竞态发生于如下几种情况。
-
对称多处理器(SMP)的多个CPU
SMP是一种紧耦合、共享存储的系统模型,其体系结构如下图所示,它的特点是多个CPU使用共同的系统总线,因此可访问共同的外设和储存器。
在SMP的情况下,两个核的竞态可能发生于CPU0的进程与CPU1的进程之间、CPU0的进程与CPU1的中断之间以及CPU0的中断与CPU1的中断之间。 -
单CPU内进程与抢占它的进程
Linux 2.6以后的内核支持内核抢占调度,一个进程在内核执行的时候可能耗完了自己的时间片,也可能被另一个高优先级进程打断,进程与抢占它的进程访问共享资源的情况类似于SMP的多个CPU。 -
中断(硬中断、软中断、Tasklet、底半部)与进程之间
中断可以打断正在执行的进程,如果中断服务程序访问进程正在访问的资源,则竞态也会发生。
上述并发的发生除了SMP是真正的并行以外,其他的都是单核上的“宏观并行,微观串行”,但其引发的实质问题和SMP相似。
解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。
访问共享资源的代码区域称为临界区,临界区需要被以某种互斥机制加以保护。 中断屏蔽、原子操作、自旋锁、信号量、互斥体等是Linux设备驱动中可采用的互斥途径。
中断屏蔽
在单CPU范围内避免竞态的一种简单而有效的方法是在进入临界区之前屏蔽系统的中断。
CPU一般都具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生。具体而言,中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也得以避免了。
中断屏蔽的使用方法为:
local_irq_disable() /* 屏蔽中断 */
... /* 临界区 */
local_irq_enable() /* 开中断 */
local_irq_enable宏用来打开本地处理器的中断,而local_irq_disable则正好相反,用来关闭处理器的中断。这两个宏的定义如下:
<include/linux/irqflags.h>
#define local_irq_enable() \
do { trace_hardirqs_on(); raw_local_irq_enable(); } while (0)
#define local_irq_disable() \
do { raw_local_irq_disable(); trace_hardirqs_off(); } while (0)
其中trace_hardirqs_on()和trace_hardirqs_off()用做调试,这里重点关注raw_local_irq_enable()和raw_local_irq_disable(),这两个宏的具体实现都依赖于处理器体系架构,不同处理器有不同的指令来启用或者关闭处理器响应外部中断的能力,比如在x86平台上,会最终利用sti和cli指令来分别设置和清除x86处理器中的FLAGS寄存器的IF标志,这样处理器就可以响应或者不响应外部的中断,ARM平台则使用CPSIE指令。
local_irq_enable与local_irq_disable还有一种变体,是local_irq_save与local_irq_restore宏。
这两个宏相对于local_irq__enable与local_irq_disable最大的不同在于,local_irq_save会在关闭中断前,将处理器当前的标志位保存在一个unsigned long flags中,在调用local_irq_restore的时候,再将保存的flags恢复到处理器的FLAGS寄存器中。这样做的目的是,防止在一个中断关闭的环境中因为调用local irq_disable与local_irq_enable将之前的中断响应状态破坏掉。
在单处理器系统中,使用local_irq_enable与local_irq_disable及其变体来对共享数据保护是种简单而有效的方法 。由于Linux的异步I/O、进程调度等很多重要操作都依赖于中断,中断对于内核的运行非常重要,在屏蔽中断期间所有的中断都无法得到处理,因此长时间屏蔽中断是很危险的,所以必须确保临界区的代码执行时间不能太长,否则将影响到系统的性能,有可能造成数据丢失乃至系统崩溃等后果。
原子变量
有时候需要保护的共享资源可能只是个简单的整型变量,即便如此对它的操作依然需要保证原子性,否则就会造成不可预料的结果,看一看下面这个例子:
int g_flag = 0; //全局变量
//task 1
void task_addflag()
{
g_flag++;
}
//task 2
void task_addflag()
{
g_flag++;
}
系统中的Task A和B运行之后,g_flag会是多少,2吗?答案是有可能!这是一个典型的对变量的非原子操作可能导致错误结果的例子。原因在于即便是简单如g_flag++这样的操作,在汇编指令级,也很可能产生如下代码:
//将g_flag的值从内存中读到EAX寄存器
"movl $g_flag, %eax"
//将EAX寄存器的值加1
"incl %eax"
//将EAX寄存器的值写回g_flag
"movl %eax, $g_flag"
如果Task A先被调度运行,在其执行完L1尚未执行L2时,Task B开始被调度执行,在其执行完整个代码后g_flag = 1,然后系统又调度Task A从L2处继续执行,因为此前已执行了L1 , 导致EAX=0,经L2后,EAX = 1,于是在L3执行完后,g_flag = 1。
在这个例子中当然可用spinlock来保证g_flag++操作的原子性,但是加锁操作导致的开销较大,用在这里总是有点浪费。此时可以考虑利用特定架构上的汇编指令来完成原子操作,比如上面的g_flag++,可以用类似“incl $g_flag”这样的汇编指令实现。显然这种原子操作在纯粹的C语言层面难以达成,必须借助汇编语言或者是嵌入到C中的汇编指令来实现。
针对这种特殊的原子操作,Linux源码中定义了一个类型为atomic_t的原子变量。atomic_t的具体定义为:
/* include/linux/types.h */
typedef struct {
int counter;
} atomic_t;
为此Linux系统中定义了一大堆以“atomic”打头的原子操作函数,这些函数的实现都依赖于特定的硬件平台。为了给读者一个具体的感受,下面挑出能解决上述g_flag++问题的atomic_inc函数来分析它在x86和ARM上的实现。
x86上的atomic_inc函数:
//在SMP系统中LOCK_PREFIX的定义为:
#define LOCK_PREFIX_HERE \
".pushsection .smp_locks,\"a\"\n" \
".balign 4\n" \
".long 671f - .\n" \
".popsection\n" \
"671:"
#define LOCK_PREFIX LOCK_PREFIX_HERE "\n\tlock; "
//在UP系统中为:
#define LOCK_PREFIX ""
static __always_inline void atomic_inc(atomic_t *v)
{
asm volatile(LOCK_PREFIX "incl %0"
: "+m" (v->counter));
}
x86上用一条带有“lock”前缀的inc指令来保证原子变量v加1操作的原子性,“ lock”前缀在x86上的作用是在执行inc指令时独占系统总线,这样即便系统总线上还有其他的master,在inc执行期间也无法修改v->counter的值。
ARM上的atomic_inc函数:
/* arch/arm/include/asm/atomic.h */
#define atomic_inc(v) atomic_add(1, (v))
atomic_add又是怎样实现的呢?用下面这个宏:
/* arch\arm\include\asm\atomic.h */
ATOMIC_OPS(add, +=, add)
//把这个宏展开
#define ATOMIC_OPS(op, c_op, asm_op) \
ATOMIC_OP(op, c_op, asm_op) \
ATOMIC_OP_RETURN(op, c_op, asm_op) \
ATOMIC_FETCH_OP(op, c_op, asm_op)
从上面的宏可以知道,一个ATOMIC_OPS定义了3个函数。比如“ ATOMIC_OPS(add, +=, add)”就定义了这三个函数:
atomic_add
atomic_add_return
atomic_atomic_fetch_add 或 atomic_fetch_add_relaxed
对于ARMv6以下的CPU系统,不支持 SMP。原子变量的操作简单粗暴:关中断。
/* arch\arm\include\asm\atomic.h */
#define ATOMIC_OP(op, c_op, asm_op)
static inline void atomic_##op(int i, atomic_t *v) \
{ \
unsigned long flags; \
\
raw_local_irq_save(flags); \
v->counter c_op i; \
raw_local_irq_restore(flags); \
}
对于ARMv6 及以上的 CPU,有一些特殊的汇编指令来实现原子操作,不再需要关中断,代码如下:
#define ATOMIC_OP(op, c_op, asm_op)
static inline void atomic_##op(int i, atomic_t *v) \
{ \
unsigned long tmp; \
int result; \
\
prefetchw(&v->counter); \
__asm__ __volatile__("@ atomic_" #op "\n" \
"1: ldrex %0, [%3]\n" \
" " #asm_op " %0, %0, %4\n" \
" strex %1, %0, [%3]\n" \
" teq %1, #0\n" \
" bne 1b" \
: "=&r" (result), "=&r" (tmp), "+Qo" (v->counter) \
: "r" (&v->counter), "Ir" (i) \
: "cc"); \
}
ARM使用Idrex和strex来保证add指令的原了性,ex表示exclude,意为独占地。这2条指令要配合使用,举例如下:
①读出:ldrex r0, [r1]
读取 r1 所指内存的数据,存入 r0;并且标记 r1 所指内存为“独占访问”。
如果有其他程序再次执行“ ldrex r0, [r1]”,一样会成功,一样会标记 r1 所指内存为“独占访问”。
②修改 r0 的值
③写入: strex r2, r0, [r1]
如果 r1 的“独占访问”标记还存在,则把 r0 的新值写入 r1 所指内存, 并且清除“独占访问”的标记,把 r2 设为 0 表示成功。
如果 r1 的“独占访问”标记不存在了,就不会更新内存, 并且把 r2 设为 1 表示失败。
在ARMv6及以上的架构中,原子操作不再需要关闭中断,关中断的花销太大了。