分享

20. 内核同步

 mzsm 2015-03-10

20. 内核同步

由于对同一资源的访问代码可能从多个路径触发并得到执行(比如:SMP,内核线程,中断处理程序,软中断处理函数,系统调用代表的用户程序在调度中体现出的并发访问等),而在访问该资源的代码执行过程中被打断,并在打断和重新执行期间,执行了其他路径引发的同一资源访问的代码,将造成意料之外的结果。

为了更好地理解内核代码是如何执行的,我们把内核看做必须满足以下几种请求的侍者:一种请求来自普通的顾客,另一种请求来自数量有限但是拥有VIP等级资格的顾客,还有一种请求来自店老板。对不同的请求,侍者采用如下的策略:

  • 侍者为普通顾客依次服务,或者空闲。
  • VIP顾客提出请求时,如果侍者正空闲,则侍者开始为其服务。如果正在为普通顾客服务,那么侍者停止服务,开始为VIP顾客服务。
  • 如果侍者正在为VIP顾客服务,另一个更高级一级的VIP客户发出请求,那么中断VIP顾客的服务,然后为高级VIP客户服务,服务完毕后再为原VIP顾客服务。
  • 如果侍者正在为顾客服务,并且不管是普通顾客,还是VIP级很高的客户,当老板命令侍者停止它的服务,而执行新的服务任务,侍者此时在完成老板的任务后,可能暂时并不理会原来的顾客而去为新选中的顾客服务。

侍者提供的服务对应于CPU处于内核态时所执行的代码。如果CPU在用户态执行,则认为侍者处于空闲状态。

普通顾客的请求则相当于用户态进程发出的系统调用或异常。VIP顾客的请求相当于中断,不同等级的VIP顾客相当于不同优先级的中断。老板的请求则相当于内核抢占。

20.1. 内核抢占

内核抢占和内核的调度策略相关。进程是具有优先级的,这跟调度策略有关:Linux中进程的优先级是动态的,调度程序更总进程的行为,并周期性的调整它们的优先级。通常进程可以分为三类:

  • 交互式进程:用户的思考时间要长于操作的间隔时间,但是一旦操作就必须立即相应,比如Shell,编辑程序,多媒体应用等。
  • 批处理进程:这些进程几乎不与用户交互,经常在后台运行。因此该进程不必很快的相应,因此受到调度程序的慢待。比如:编译程序,数据库引擎和科学计算。
  • 实时进程:这类进程有很强的调度需求,不可被低优先级的进程阻塞,响应时间必须很短并且稳定在一个小的范围内。这类程序有音视频应用,机器人控制和传感器的信息收集程序等。

Linux2.6内核开始支持内核抢占,这基于以下的考虑:假设当前有两个进程在运行:一个文本编辑程序和一个编译程序——正在占用CPU。用户停留在文件编辑程序的状态,当其按动键盘的一瞬间将触发中断,内核必须马上唤醒文本编辑进程,否则用户将认为系统的状态不稳定。

如果进程进入TASK_RUNNING状态,内核检查它的动态优先级是否大于当前正在运行的进程的优先级。如果是,current的执行被中断,并调用调度程序选择另一个进程运行(通常是刚刚进入TASK_RUNNING状态的进程)。另一种情况是当前进程的时间片到期,并且它自认为自己已经处理完自己需要处理的任务,无需继续占用CPU了,此时当前进程thread_info结构体中的TIF_NEED_RESCHED标记被设置,以便在下一次中断(很大概率是系统时钟中断)ISR处理结束时被调度程序调用。

继续回到上面的例子,内核跟踪文本编辑器的行为,并确认它是一个交互进程,此时它的优先级将高于当前的编译器进程优先级,因此,编辑进程的TIF_NEED_RESCHED标志将被设置,如此强迫内核处理完中断时激活调度程序,它将选择编辑进程并执行进程切换。因此,编辑进程可以很快相应用户的操作,并且由于被设置了TIF_NEED_RESCHED标志,可以在响应完毕后马上被编译进程抢占。

TIF_NEED_RESCHED标志作用意味着当前进程自愿放弃CPU,这和内核是否支持抢占无关;另一种情况是当前进程并不打算在时间片到期前放弃CPU,而更高优先级的进程由于某些原因被唤醒,比如中断。如果内核是抢占式的,高优先级进程将替换原进程。如果内核不是抢占式的,那么除非当前进程执行完系统调用或异常处理并在恢复到用户态时才有可能因触发调度程序而被新进程抢占,否则进程切换不会发生(即便发生了中断,并在ISR处理结束时触发了调度程序,也不会因为优先级高而抢占当前还未消耗完其时间片的进程)。

使能内核抢占的目的是为了减少用户态进程的分派延迟(Dispatch latency),即从进程变为可执行状态到它实际开始运行之间的时间间隔。

当然并不是低优先级的进程在任何时候都是可被抢占的:

  • 内核正在执行中断服务例程。
  • 可延迟函数被禁止(当内核正在执行软中断或tasklet时经常如此)。
  • 通过抢占计数器设置为正数而显示地禁用内核抢占。

当被current_thread_info宏引用的thread_info描述符中的preempt_count成员大于0是,当前进程就禁止了内核抢占。该成员是一个32位的int类型,但是同时表达了三个不同的计数器:

  • b[7:0] 抢占计数器。记录显式禁用本地CPU内核抢占的次数,值等于0表示允许内核抢占。范围为0~255。
  • b[15:8] 软中断计数器:表示可延迟函数被禁用的程度,范围为0~255。
  • b[27:16] 硬中断计数器:表示在本地CPU上中断处理程序的嵌套数(irq_enter和irq_exit分别对它进行递加和递减)。
  • b[28] PREEMPT_ACTIVE标志。它和调度有关。

针对这几个字段,内核提供了以下的宏方便对它们的获取操作:

include/linux/hardirq.h
#define hardirq_count()	(preempt_count() & HARDIRQ_MASK)
#define softirq_count()	(preempt_count() & SOFTIRQ_MASK)
#define irq_count()	(preempt_count() & (HARDIRQ_MASK | SOFTIRQ_MASK))

以下的宏用于判断是否位于硬中断,软中断或者两者之中。

#define in_irq()		(hardirq_count())
#define in_softirq()		(softirq_count())
#define in_interrupt()		(irq_count())

综上所述,只有当内核正在执行系统调用和异常处理,且内核抢占没有被显示地禁用时,才可能抢占内核。

内核定义了一系列宏来处理preempt_count字段的抢占计数器b[7:0]。

表 43. 处理器抢占计数器字段宏

说明
preempt_count 在thread_info中选择preempt_count成员
preempt_disable 使抢占计数器的值加1。
preempt_enable 使抢占计数器的值减1。
preempt_enable_no_resched 使抢占计数器的值减1。
preempt_enable 使抢占计数器的值减1,并在thead_info描述符的TIF_NEED_RESCHED标志
被置1的情况下,调用preempt_schedule。
get_cpu 与preempt_disable相似,但要返回本地CPU的数量。
put_cpu 与preempt_enable相同。
put_cpu_no_resched 与preempt_enable_no_resched相同。


内核必须配置CONFIG_PREEMPT,才启用内核抢占。否则上面的宏均被定义为空。

include/linux/preempt.h
#define add_preempt_count(val)	do { preempt_count() += (val); } while (0)
#define sub_preempt_count(val)	do { preempt_count() -= (val); } while (0)

#define inc_preempt_count() add_preempt_count(1)
#define dec_preempt_count() sub_preempt_count(1)

#define preempt_count()	(current_thread_info()->preempt_count)

preempt_count对preempt_count成员进行引用,而inc_preempt_count和dec_preempt_count分别对其递增和递减。

#define preempt_disable() do { 	inc_preempt_count(); 	barrier(); } while (0)

#define preempt_enable_no_resched() do { 	barrier(); 	dec_preempt_count(); } while (0)

preempt_disable通过inc_preempt_count实现递增preempt_count,preempt_enable_no_resched通过dec_preempt_count递减,显然这里的重点在于barrier宏,它告诉编译器不要改变C语言对应的汇编语言的顺序,所以CPU不会乱序执行,这保证了对preempt_count的操作不会因为编译器优化而发生提前或者延后,也即调用preempt_disable之后的代码一定是在preempt_count增加1后执行,反之亦然。

#define preempt_enable() do { 	preempt_enable_no_resched(); 	barrier(); 	preempt_check_resched(); } while (0)

preempt_enable是对preempt_enable_no_resched和preempt_check_resched的封装,中间插入了内存屏障。

kernel/sched.c
asmlinkage void __sched preempt_schedule(void)
{
	struct thread_info *ti = current_thread_info();

	if (likely(ti->preempt_count || irqs_disabled()))
		return;

	do {
		add_preempt_count(PREEMPT_ACTIVE);
		schedule();
		sub_preempt_count(PREEMPT_ACTIVE);

		barrier();
	} while (unlikely(test_thread_flag(TIF_NEED_RESCHED)));
}

#define preempt_check_resched() do { 	if (unlikely(test_thread_flag(TIF_NEED_RESCHED))) 		preempt_schedule(); } while (0)

preempt_schedule用于抢占调度,当前它只被preempt_check_resched调用。对于preempt_schedule的调用,应该始终通过preempt_check_resched,而非其自身。另外应该保持这部分代码被约束在可控的范围,而避免不必要的扩散。

  • preempt_schedule首先判断当前进程preempt_count值是否为正值,如果是,则说明该进程当前禁止抢占,不可被调度。
  • 然后通过irqs_disabled判断当前是否处于中断处理中,如果是则不可被抢占。
  • 置PREEMPT_ACTIVE标记,调用schedule实施调度。
  • 如果调度失败,且进程含有TIF_NEED_RESCHED标记,则持续调度,直至成功。

一些额外的宏并用在SMP系统处理中。

#define get_cpu()		({ preempt_disable(); smp_processor_id(); })
#define put_cpu()		preempt_enable()
#define put_cpu_no_resched()	preempt_enable_no_resched()

20.2. 内存屏障

内存屏障保证高级语言,比如C语言的编译器在优化生成的代码时能够保证内存屏障前后的代码不会乱序,而导致违背本来的程序意图。内存屏障由一个名为barrier()的宏定义:

include/linux/compiler-gcc.h
#define barrier() __asm__ __volatile__("": : :"memory")

要彻底理解barrier()的作用,需要首先理解内嵌汇编。字符串"memory"向GCC声明:在此之前的C语言对应的汇编语言和此之后的汇编语言在优化时不要放在一起考虑。一个实际的例子如下所示:

#define barrier() __asm__ __volatile__("": : :"memory")
int g_test = 0;
int main()
{
        int *tmp = &g_test;
        *tmp = 100;
     // barrier();
        if(*tmp == 100)
                return 0;

        return 1;
}

编译命令如下,为了得到间接的代码,参数中加上了-O2优化选项。

arm-linux-gcc test.c -o test -O2

首先编译没有内存屏障宏的代码,并反汇编得到main函数对应的汇编指令:

00008334 <main>:
    8334:       e59f300c        ldr     r3, [pc, #12]   ; 8348 <main+0x14>
    8338:       e3a02064        mov     r2, #100        ; 0x64
    833c:       e3a00000        mov     r0, #0  ; 0x0
    8340:       e5832000        str     r2, [r3]
    8344:       e12fff1e        bx      lr
    8348:       000104fc        .word   0x000104fc

这里找不到*tmp == 100对应的汇编指令,显然编译器认为这句话是多余的,因为从*tmp = 100这句话开始,*tmp的值没有被任何语句改变过,所以它尝试了优化。接下来打开barrier()。

00008334 <main>:
    8334:       e59f3014        ldr     r3, [pc, #20]   ; 8350 <main+0x1c>
    8338:       e3a02064        mov     r2, #100        ; 0x64
    833c:       e5832000        str     r2, [r3]
    8340:       e5930000        ldr     r0, [r3]
    8344:       e0500002        subs    r0, r0, r2
    8348:       13a00001        movne   r0, #1  ; 0x1
    834c:       e12fff1e        bx      lr
    8350:       00010504        .word   0x00010504

可以看到subs和movne指令,所以确实执行了比较操作。考虑何时需要这种需求呢?一个典型的示例就是内核抢占,在可被抢占前后的代码是必须严格顺序执行的,不然禁止抢占所保护的操作将丧失本来的意义。

20.3. 临界区控制

临界区是一段代码,在任何内核控制路径进入临界区后必须全部执行完这段代码,而不被打断。如何确定系统调用,异常处理程序,可延迟函数和内核线程中的临界区是是首要任务。一旦临界区被确定,就必须对其采用适当的保护措施,以确保在任何时刻只有一个内核控制路径处于临界区。

例如,假设两个不同的中断处理程序要访问同一个包含了几个相关变量的数据结构,比如一个缓冲区和一个表示缓冲区大小的类型变量。所有影响该数据结构的语句都必须放入一个单独的临界区。如果是单CPU系统,可以采取访问共享数据结构时关闭中断的方式来实现临界区,因为只有在开中断的情况下,才可能发生内核控制路径的嵌套。

另外,如果相同的数据结构仅被系统调用服务例程所访问,而且系统中只有一个CPU,就可以非常简单地通过在访问共享数据结构时禁用内核抢占功能来实现临界区。

但是如果该共享数据即可能被某一中断ISR访问,又可能被系统调用访问呢?事实上内核约束了这种情况的产生,它们不会操作同一数据结构,而要么是原数据结构,要么是副本。

在SMP系统中,情况要复杂得多,由于多个CPU可能同时执行内核路径,因此内核开发者不能假设只要禁用内核抢占功能,而且中断,异常和软中断处理程序都没有访问过该数据结构,才能保证这个数据结构能够安全地被访问。内核提供了各种不同的同步技术。

什么时候同步是不必要的?基于以下内核约束,它使得内核同步相对简单了:

  • 操作只存在于同一中断ISR中,那么禁中断即可。
  • 中断ISR,软中断和tasklet既不可以被抢占也不能被阻塞,所以它们不可能长时间处于挂起状态。在最坏情况下,它们的执行将有轻微的延迟,因为在其执行的过程中可能发生其他的中断(内核控制路径的嵌套执行)。
  • 执行中断处理的内核控制路径不能被执行可延迟函数或系统调用服务例程的内核控制路径中断。
  • 软中断和tasklet不能在一个给定的CPU上交错执行。
  • 同一个tasklet不可能同时在几个CPU上执行。

基于以上这些内核编码的约束限制,内核同步在多数时候不那么紧迫:

  • ISR和tasklet不必编写成可重入的函数。
  • 仅被软中断和tasklet访问的每CPU变量不需要同步。
  • 仅被一种tasklet访问的数据结构不需要同步。

20.4. 同步技术

"适用范围"一栏表示该同步技术是适用于系统中的所有CPU还是单个CPU。例如,本地中断的禁止只适用于一个CPU(系统中的其他CPU不受影响);相反,原子操作影响系统中的所有CPU(当 访问同一个数据结构时,几个CPU上的原子操作不能交错)。

表 44. 内核使用的同步技术

技术 说明 使用范围
每CPU变量 在CPU指尖赋值数据结构 所有CPU
原子操作 对一个计数器原子地"读-修改-写"的指令 所有CPU
内存屏障 避免指令重新排序 本地CPU或所有CPU
自旋锁 加锁时忙等 所有CPU
信号量 加锁时阻塞等待(睡眠) 所有CPU
顺序锁 基于访问计数器的锁 所有CPU
本地中断的禁止 禁止单个CPU上的中断处理 本地CPU
本地软中断的禁止 禁止单个CPU上的可延迟函数处理 本地CPU
RCU 通过指针而不是锁来访问共享数据结构 所有CPU

20.4.1. 每CPU变量

最好的同步技术就是把设计无需同步的内核放在首位。事实上每种显式的同步技术都有不容忽视的性能开销。

最简单也是最重要的同步技术包括把内核变量声明为每CPU变量(per-cpu variable)。每CPU变量主要是数据结构的数组,系统的每个CPU对应数组的一个元素。

一个CPU不应该访问其他CPU对应的数据元素,另外它可以随意度或者修改它自己的元素而不用担心出现竞争条件,因为它是唯一有资格这么做的CPU。但是,也意味着每CPU变量基本上只能在特殊情况下使用,也就是当它确定在系统的CPU上的数据在逻辑上是独立的时候。

每CPU的数组元素在注册中被排列以使每个数据结构存放在硬件告诉缓存的不同行,因此,对每CPU数据的并发不会导致告诉缓存行的窃用和失效。

虽然每CPU变量为来自不同CPU的并发访问提供保护,但对来自异步函数(中断处理程序和可延迟函数)的访问不提供保护,在这种情况下需要另外的同步技术。

无论是在单处理器还是SMP系统中,内核抢占都可能使每CPU变量产生竞争条件。总的原则是内核控制路径应该在禁用抢占的情况下访问每CPU变量。

#ifdef CONFIG_SMP
#define DEFINE_PER_CPU(type, name)                                              __attribute__((__section__(".data.percpu")))                            PER_CPU_ATTRIBUTES __typeof__(type) per_cpu__##name
#else
#define DEFINE_PER_CPU(type, name)           PER_CPU_ATTRIBUTES __typeof__(type) per_cpu__##name
......        

从DEFINE_PER_CPU对每CPU变量进行定义,对于单CPU系统来说,就是简单的一个变量per_cpu__##name,但是对于SMP系统来说,却需要链接脚本的帮助:它在编译期被放在.data.percpu段中。

arch/arm/kernel/vmlinux.lds.S
.init : {
......
    . = ALIGN(4096);
    __per_cpu_start = .;
            *(.data.percpu)
            *(.data.percpu.shared_aligned)
    __per_cpu_end = .;
......
}

这说明__per_cpu_start和__per_cpu_end标识.data.percpu段的开头和结尾。并且,整个.data.percpu这个section都在__init_begin和__init_end之间,也就是说,该section所占内存会在系统启动后释放掉,那么系统如何为每个CPU保留这些私有数据的?

在start_kernel中调用setup_per_cpu_areas。本质上只有定义了CONFIG_SMP,并且没有定义CONFIG_HAVE_SETUP_PER_CPU_AREA才会使用内核字节定义的每CPU变量初始化函数。如果定义了CONFIG_SMP,且定义了CONFIG_HAVE_SETUP_PER_CPU_AREA,那么该函数必须在对应架构的代码中定义,比如x86。

#ifndef CONFIG_HAVE_SETUP_PER_CPU_AREA
unsigned long __per_cpu_offset[NR_CPUS] __read_mostly;

EXPORT_SYMBOL(__per_cpu_offset);

static void __init setup_per_cpu_areas(void)
{
    unsigned long size, i;
    char *ptr;
    unsigned long nr_possible_cpus = num_possible_cpus();

    /* Copy section for each CPU (we discard the original) */
    size = ALIGN(PERCPU_ENOUGH_ROOM, PAGE_SIZE);
    ptr = alloc_bootmem_pages(size * nr_possible_cpus);

    for_each_possible_cpu(i) {
            __per_cpu_offset[i] = ptr - __per_cpu_start;
            memcpy(ptr, __per_cpu_start, __per_cpu_end - __per_cpu_start);
            ptr += size;
    }
}
#endif /* CONFIG_HAVE_SETUP_PER_CPU_AREA */

在该函数中,为每个CPU分配一段内存,并将.data.percpu中的数据拷贝到其中,每个CPU各有一份,其中CPU n对应的专有数据区的首地址为__per_cpu_offset[n]。这样,前述相应于__per_cpu_start的偏移量per_cpu__runqueues就变成了相应于 __per_cpu_offset[n]的偏移量,这样.data.percpu这个段在系统初始化后就可以释放了。

为每CPU变量提供的函数和宏:

  • DEFINE_PER_CPU(type, name) 静态分配一个每CPU数组,数组名为name,结构类型为type。在单CPU系统上,就是per_cpu__##name变量,而对于SMP来说它是一个维数为CPU个数的数组,代表该数组的起始地址。
  • per_cpu(name, cpu) 为CPU选择一个每CPU数组元素,CPU由参数cpu指定,数组名为name。
  • __get_cpu_var(name) 选择每CPU数组name的本地CPU元素
  • get_cpu_var(name) 先禁用内核抢占,然后在每CPU数组name中,为本地CPU选择元素。
  • put_cpu_var(name) 启用内核抢占(不使用name)。
  • alloc_percpu(type) 动态分配type类型数据结构的每CPU数组,并返回它的地址。
  • free_percpu(pointer) 释放被动态分配的每CPU数组,pointer指示其地址。
  • per_cpu_ptr(pointer, cpu) 返回每CPU数组中与参数cpu对应的CPU元素地址,参数pointer给出数组地址。

#define per_cpu_var(var) per_cpu__##var

以上的大多数宏都是基于per_cpu_var宏的扩展,以下代码对应SMP系统时的定义:

#ifndef SHIFT_PERCPU_PTR
#define SHIFT_PERCPU_PTR(__p, __offset)	RELOC_HIDE((__p), (__offset))
#endif

#ifndef __per_cpu_offset
extern unsigned long __per_cpu_offset[NR_CPUS];
#define per_cpu_offset(x) (__per_cpu_offset[x])
#endif

#define per_cpu(var, cpu) 	(*SHIFT_PERCPU_PTR(&per_cpu_var(var), per_cpu_offset(cpu)))
#define __get_cpu_var(var) 	(*SHIFT_PERCPU_PTR(&per_cpu_var(var), my_cpu_offset))
#define __raw_get_cpu_var(var) 	(*SHIFT_PERCPU_PTR(&per_cpu_var(var), __my_cpu_offset))

SHIFT_PERCPU_PTR调用编译器提供的偏移宏RELOC_HIDE实现从数组起始地址到当前CPU对应的元素的偏移,per_cpu_offset则引用调整后的偏移数组。对应动态分配的每CPU变量来说,SMP系统上分配时,会将size乘以CPU的个数,并将大小圆整到cache_line_size。

每CPU变量对SMP系统至关重要,它保证了CPU间的数据访问的隔离,在单CPU系统上,它总是以单个独立的变量存在的。

20.4.2. 原子操作

若干汇编语言指令序列具有"读——修改——写"的特性,它们访问存储器单元两次,第一次读原值,第二次写新值。假设运行在两个CPU上的里那个个内核控制路径试图通过执行非原子操作来同时"读——修改——写"同一存储单元。首先,两个CPU都试图读同一单元,但是存储器仲裁器(对访问RAM芯片的操作进行串行化的硬件电路)插手,只允许其中的一个访问而让另一个延迟。然而,当第一个读操作完成后,延迟的CPU从那个存储器单元正好读到同一个(旧)值。然后,两个CPU都试图向那个存储器单元写一新值,总线存储器访问在一次被存储器仲裁器串行化,最后,两个写操作都成功。但是,全局的结果是不对的,因为两个CPU写入了同一(新)值。因此,两个交错的"读——修改——写"操作成了一个单独的操作。

避免"读——修改——写"指令引起的竞争条件的最容易的办法,就是确保这样的操作在芯片级是原子的。任何一个这样的操作都必须以单个指令执行,中间不能中断,且避免其它的CPU访问同一存储器单元。这些很小的原子操作(atomic operations)可以建立在其他更灵活机制的基础之上以创建临界区。

回顾一下80x86的指令:

  • 进行零次或一次对齐内存访问的汇编指令是原子的,但是对非对齐内存的访问通常不是原子的。
  • 如果在读操作之后,写操作之前没有其他处理器占用内存总线,那么从内存中读取数据,更新数据并把更新后的数据写回内存中的这些"读——修改——写"汇编语言指令(如inc或dec)是原子的。当然,在单处理器系统中,永远不会发生内存总线窃用的情况。
  • 操作码前缀是lock字节(0xf0)的"读——修改——写"汇编语言指令即使在多处理器系统中也是原子的。对于ARM来说,这些指令带有ex后缀,它们只在ARMv6和更高版本的指令集中才被提供,所以ARMv6之前的指令集是不支持SMP的。当控制单元检测到这些指令中的锁定字段时,就"锁定"内存总线,直到这条指令执行完成为止。因此,当加锁的指令执行时,其他CPU不能访问这个内存单元。
  • 操作码前缀是一个rep字节的汇编指令不是原子的,这条指令强行让控制单元多次重复执行相同的指令。控制单元在执行新的循环之前要检查挂起的中断。

在编写C代码程序是,不能保证编译器会为a = a + 1或者a++这样的操作使用一个原子指令。因此,Linux内核提供了一个专门的atomic_t类型(一个原子访问计时器)和一些对应的函数和宏。这个函数和宏作用于atomic_t类型的变量,并可以当做原子的汇编语言指令来使用。

arch/arm/include/asm/atomic.h
static inline void atomic_set(atomic_t *v, int i)
{
        unsigned long tmp;

        __asm__ __volatile__("@ atomic_set\n"
"1:     ldrex   %0, [%1]\n"
"       strex   %0, %2, [%1]\n"
"       teq     %0, #0\n"
"       bne     1b"
        : "=&r" (tmp)
        : "r" (&v->counter), "r" (i)
        : "cc");
}

可以通过锁总线指令在一个指令中完成,比如x86。对于ARM来说,从ARMv6指令集开始引入了两个锁总线访问的指令ldrex和strex。它们必须成对出现:ldrex在读取数据之前会锁定总线,这个操作被称为MarkExclusiveGlobal,而在strex中才会执行ClearExclusiveGlobal用来解锁总线,这其中的操作都是原子的。但这样做就可以避免被中断打断吗?不能,这只是保证了总线访问的锁定,但是并没有禁中断,中断总是在一个指令执行完毕后被检查,所以如果在ldrex中发生了中断,并且中断ISR尝试了对v的操作,那么这个循环就可能会执行多次,并且中断对v的操作将丢失,所以中断处理中不应该调用用于原子操作的宏函数。最新的ARM指令集支持monitor标记机制,它不会锁总线,此时原子操作包含如下四个步骤:

  • step 1.ldrex r0,[addr] ; 从addr中读取值,并借助monitor在对应的地址上做一个tag
  • step 2. some operation ;对读出的值做一些操作
  • step 3. strex r1,r0,[addr] ;将处理后的r0写回[addr],r1指示此次写操作是否成功,而此次写操作能够成功的一个条件是,在step 1的ldrex中标记的tag仍然存在.
  • step 4. teq r1,#0;bne 1b ;如果step 3写失败,返回step 1.

在linux中,在所有中断的入口都会调用clrex来清除掉monitor标记的这个tag,那么如果step 1和step 3之间有中断发生,在中断处理完成返回之后step 3会失败(因为step1中的tag已经被清除),然后又会进入step 1重新读[addr]中的值。也就是无论如何都不会发生“交错读”这种现象。因为每次strex失败之后都会重新再读一次[addr]的值。类似于atomic_set,Linux定义了一些列的宏和函数:

表 45. Linux中的原子操作

函数 说明
atomic_read(v) 返回*v
atomic_set(v, i) 设置*v为i
atomic_add(i, v) 给*v加i
atomic_sub(i, v) 从*v减i
atomic_sub_and_test(i, v)  
atomic_inc(v) *v加1
atomic_dec(v) *v减1
atomic_dec_and_test(v) *v减1,如果为0,返回1,否则返回0
atomic_inc_and_test(v) *v加1,如果为0,返回1,否则返回0
atomic_add_negative(i, v) *v加i,结果为负责返回1,否则返回0
atomic_inc/dec_return(v) 加/减1后返回新值
atomic_add/sub_return(i, v) 加/减i后返回新值


内核通过汇编混合编程实现了核心函数atomic_set,atomic_add_return等函数,然后对它们进行宏扩展:

#define atomic_add(i, v)        (void) atomic_add_return(i, v)
#define atomic_inc(v)           (void) atomic_add_return(1, v)
#define atomic_sub(i, v)        (void) atomic_sub_return(i, v)
#define atomic_dec(v)           (void) atomic_sub_return(1, v)

#define atomic_inc_and_test(v)  (atomic_add_return(1, v) == 0)
#define atomic_dec_and_test(v)  (atomic_sub_return(1, v) == 0)
#define atomic_inc_return(v)    (atomic_add_return(1, v))
#define atomic_dec_return(v)    (atomic_sub_return(1, v))
#define atomic_sub_and_test(i, v) (atomic_sub_return(i, v) == 0)

#define atomic_add_negative(i,v) (atomic_add_return(i, v) < 0)

注意到这些含有test后缀的宏,比较运算并不在原子操作中进行,也即比较时,这个值可能已经被其他CPU更新。另一类原子函数用作位掩码操作。

表 46. Linux中的原子位处理函数

函数 说明
test_bit(nr, addr) 返回*addr的第nr位的值
set_bit(nr, addr) 设置*addr的第nr位
clear_bit(nr, addr) 清*addr的第nr位
change_bit(nr, addr) 转换*addr的第nr位
test_and_set_bit(nr, addr) 设置*addr的第nr位,并返回原值
test_and_clear_bit(nr, addr) 清*addr的第nr位,并返回原值
test_and_change_bit(nr, addr) 转换*addr的第nr位,并返回原值
atomic_clear_mask(mask, addr) 清mask指定的*addr的所有位
atomic_set_mask(mask, addr) 设置mask指定的*addr的所有位


x86中提供了完善针对位的原子操作指令,但是当前ARM的非SMP系统的bit实现除atomic_clear_mask/atomic_set_mask外只是考虑了中断的干扰因素,并没有使用strex和ldrex指令,所以如果需要支持SMP,需要修改这些函数的实现。

20.4.3. 自旋锁

一种广泛应用的同步技术是加锁(locking)。当内核控制路径必须访问共享数据结构或进入临界区时,就需要为自己获取一把"锁"。由锁机制保护的资源非常类似于限制于房间内的资源,当某人进入房间时,就把门锁上。如果内核控制路径希望访问资源,就试图获取要是"打开门"。当且仅当资源空闲时,它才能成功。然后,只要它还想使用这个资源你,门就依然锁着。当内核控制路径释放了锁时,门就打开,另一个内核控制路径就可以进入房间。

自旋锁(spin lock)是用来在多处理器环境中工作的一种特殊的锁。如果内核控制路径发现自旋锁"开着",就获取锁并继续自己的执行。相反,如果内核控制路径发现锁运行在另一个CPU上的啮合控制路径"锁着",就在周围"旋转",反复执行一条紧凑的循环指令,直到锁被释放。

自旋锁的循环指令表示"忙等"。即使等待的内核控制路径无事可做,它也在CPU上保持运行。不错,自旋锁通常非常方便,因为很多内核资源只锁1毫秒的时间片段;所以说,spin_lock的开销还是比进程调度(context switch)少得多。

一般来说,由自旋锁所保护的每个临界区都是禁止内核抢占的。在单处理器系统上,这种锁本身并不起锁的作用,自旋锁仅仅禁止或启用内核抢占。请注意,在自旋锁忙等期间,内核抢占还是有效的,因此,等待自旋锁释放的所有进程有可能被更高优先级的进程替代。

对spinlock操作的定义与体系结构息息相关,对于SMP来说,必须要易于该体系的汇编指令来实现总线锁定,而对单CPU系统来说,将简单的多,它建立在内核抢占之上,如果没有使能CONFIG_PREEMPT,那么自旋锁什么也不做。spinlock头文件的注释有详细说明。对于SMP系统来说相关的头文件如下:

include/linux/spinlock.h

*  asm/spinlock_types.h: contains the raw_spinlock_t/raw_rwlock_t and the
*                        initializers*
*  asm/spinlock.h:       contains the __raw_spin_*()/etc. lowlevel
*                        implementations, mostly inline assembly code
*  linux/spinlock_api_smp.h:
*                        contains the prototypes for the _spin_*() APIs.

typedef struct {
        volatile unsigned int lock;
} raw_spinlock_t;

  • asm/spinlock_types.h 包含raw_spinlock_t/raw_rwlock_t的定义和初始化。
  • asm/spinlock.h 定义了汇编代码实现的__raw_spin_*()系列函数。
  • linux/spinlock_api_smp.h 封装了SMP上的_spin_*()系列函数。

对于单CPU的处理相当简单,此时根本就不会编译kernel/spinlock.c文件,相关的头文件如下:

 *  linux/spinlock_type_up.h: contains the generic, simplified UP spinlock type.
 *                           (which is an empty structure on non-debug builds)
 *  linux/spinlock_up.h:  contains the __raw_spin_*()/etc. version of UP
 *                        builds. (which are NOPs on non-debug, non-preempt
 *                        builds)
 *  linux/spinlock_api_up.h:  builds the _spin_*() APIs.
 
typedef struct { } raw_spinlock_t;

  • linux/spinlock_type_up.h 包含了通用,简化的单CPU系统上的spinlock_t类型,显然
  • asm/spinlock_up.h 定义了基于内核抢占的__raw_spin_*()系列函数。
  • linux/spinlock_api_up.h 封装了单系统上的_spin_*()系列函数。

linux/spinlock_types.h根据单系统和SMP的头文件,定义了spinlock_t的通用类型:

#if defined(CONFIG_SMP)
# include <asm/spinlock_types.h>
#else
# include <linux/spinlock_types_up.h>
#endif

typedef struct {
	raw_spinlock_t raw_lock;
#ifdef CONFIG_GENERIC_LOCKBREAK
	unsigned int break_lock;
#endif
} spinlock_t;

在Linux的SMP系统中中,每个自旋锁都用spinlock_t结构表示,其中包含两个字段:

  • raw_lock 该字段表示自旋锁的状态:值为1表示"未加锁"。而任何赋值和0都表示"加锁"状态。
  • break_lock 表示进程正在忙等自旋锁(只在内核支持SMP和内核抢占的情况下使用该标志)。

linux/spinlock.h 封装了最终对外使用的spin_*()应用函数:

include/linux/spinlock.h
#define spin_lock(lock)			_spin_lock(lock)

include/linux/spinlock_api_up.h
#define __LOCK(lock)   do { preempt_disable(); __acquire(lock); (void)(lock); } while (0)
#define _spin_lock(lock)			__LOCK(lock)

这里很清楚的可以看到只是禁用内核抢占而已,而(void)(lock)只是防止编译器提示变量未使用,这里并没有使用任何真正的spinlock。而对于SMP系统来说,则相对复杂:

kernel/spinlock.c
void __lockfunc _spin_lock(spinlock_t *lock)
{
	preempt_disable();
	spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
	LOCK_CONTENDED(lock, _raw_spin_trylock, _raw_spin_lock);
}

注意这里同单系统一样首先禁止内核抢占,然后LOCK_CONTENDED将调用体系结构相关的函数_raw_spin_lock,对于ARM来说,它的实现如下:

include/linux/spinlock.h
# define _raw_spin_lock(lock)		__raw_spin_lock(&(lock)->raw_lock)

arch/arm/include/asm/spinlock.h
#define __raw_spin_lock_flags(lock, flags) __raw_spin_lock(lock)
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
        unsigned long tmp;

        __asm__ __volatile__(
"1:     ldrex   %0, [%1]\n"
"       teq     %0, #0\n"
#ifdef CONFIG_CPU_32v6K
"       wfene\n"
#endif
"       strexeq %0, %2, [%1]\n"
"       teqeq   %0, #0\n"
"       bne     1b"
        : "=&r" (tmp)
        : "r" (&lock->lock), "r" (1)
        : "cc");

        smp_mb();
}

这里就如原子操作一样尝试在锁总线的情况下来进行循环等待,在strexeq,teqeq和bne另一个CPU将会释放锁,也即将增加lock的值,这在下一次ldrex处理中将原子的获取该锁。但是这并不能保证中断不会打断该spinlock,所以依然会进行中断处理,并且在中断处理结束时,由于禁止内核抢占而跳过调度处理(参考irq_svc的处理)。

表 47. 自旋锁宏

说明
spin_lock_init() 把自旋锁置为1(未锁)
spin_lock()  
spin_unlock() 把自旋锁置为1(未锁)
spin_unlock_wait() 等待,直到自旋锁变为1(未锁)
spin_is_locked() 如果自旋锁被置为1(未锁),返回0;否则,返回1。
spin_trylock() 把自旋锁置为0(锁上),如果原来锁的值为1,否则,返回0。
   


以上的操作均针对SMP系统,如果是单CPU系统,那么根本不会操作lock成员,而只是对内核抢占的使能或者取消。如果单系统没有使能内核抢占,那spinlock就什么也不会做了。

kernel/spinlock.c
void __lockfunc _spin_unlock(spinlock_t *lock)
{
	spin_release(&lock->dep_map, 1, _RET_IP_);
	_raw_spin_unlock(lock);
	preempt_enable();
}

SMP上的解锁过程,首先通过汇编代码_raw_spin_unlock释放锁,然后使能内核抢占,注意它们的顺序。

20.4.4. 读写自旋锁

读写自旋锁的引入是为了增加内核的并发能力。只要没有内核控制路径对数据结构进行修改,读写自旋锁就允许多个内核控制路径同时读同一数据结构。如果一个内核控制路径相对这个结构进行写操作,那么它首先获取读写锁的写锁,写锁授权独占访问这个资源。当然你,允许对数据结构并发读可以提高系统性能。

每个读写自旋锁都是一个rwlock_t结构,与spinlock类似,它在单系统和SMP上定义也不同:

include/linux/spinlock_types_up.h
typedef struct {
	/* no debug version on UP */
} raw_rwlock_t;

arch/arm/include/asm/spinlock_types.h
typedef struct {
        volatile unsigned int lock;
} raw_rwlock_t;

SMP系统上,lock字段是一个32位的字段,分为两个不同的部分:

  • 24为计数器,表示对受保护的数据结构并发地进行读操作的内核控制路径的数目。这个计数器的二进制补码存放在b[23:0]位。
  • "未锁"标志字段,当没有内核控制路径在读或写时设置该位,否则清0。这个"未锁"标志存放在lock字段的b[24]。

注意,如果自旋锁为空(设置了"未锁"标志且无读者),那么lock字段的值为0x01000000;如果写者已经获得自旋锁("未锁"标志清0且无读者),那么lock字段的值为0;如果一个、两个或多个进程因为读获取了自旋锁,那么lock字段的值为0x00ffffff,0x00fffffe等("未锁"标志清0,读者个数的二进制补码在0~23位上)。


20.5. Sandbox

表 48. Memory Hierarchy

 
   

<figure><title>内核RAM布局</title><graphic fileref="images/mdio/p.gif"/></figure>
100=1 100=1 表 15 “Memory Hierarchy” < > [19] 强调


[19] 到底位于哪里呢?

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多