深度剖析Linux内核同步机制:实现高效可靠的并发编程

简介: 深度剖析Linux内核同步机制:实现高效可靠的并发编程

前言:非常早之前就接触过同步这个概念了,可是一直都非常模糊。没有深入地学习了解过,最近有时间了,就花时间研习了一下《linux内核标准教程》和《深入linux设备驱动程序内核机制》这两本书的相关章节。趁刚看完,就把相关的内容总结一下。为了弄清楚什么事同步机制,必需要弄明确下面三个问题:

  • 什么是相互排斥与同步?
  • 为什么须要同步机制?
  • Linux内核提供哪些方法用于实现相互排斥与同步的机制?

1、什么是相互排斥与同步?(通俗理解)

  • 相互排斥与同步机制是计算机系统中,用于控制进程对某些特定资源的訪问的机制。
  • 同步是指用于实现控制多个进程依照一定的规则或顺序訪问某些系统资源的机制。
  • 相互排斥是指用于实现控制某些系统资源在随意时刻仅仅能同意一个进程訪问的机制。相互排斥是同步机制中的一种特殊情况。
  • 同步机制是linux操作系统能够高效稳定执行的重要机制。

2、Linux为什么须要同步机制?

在操作系统引入了进程概念,进程成为调度实体后,系统就具备了并发运行多个进程的能力,但也导致了系统中各个进程之间的资源竞争和共享。另外,因为中断、异常机制的引入,以及内核态抢占都导致了这些内核运行路径(进程)以交错的方式运行。

对于这些交错路径运行的内核路径,如不採取必要的同步措施。将会对一些重要数据结构进行交错訪问和改动。从而导致这些数据结构状态的不一致,进而导致系统崩溃。

因此。为了确保系统高效稳定有序地运行,linux必需要採用同步机制。

3、Linux内核提供了哪些同步机制?

在学习linux内核同步机制之前。先要了解下面预备知识:(临界资源与并发源)

在linux系统中,我们把对共享的资源进行訪问的代码片段称为临界区。把导致出现多个进程对同一共享资源进行訪问的原因称为并发源。

Linux系统下并发的主要来源有:

  • 中断处理:比如,当进程在訪问某个临界资源的时候发生了中断。随后进入中断处理程序,假设在中断处理程序中。也訪问了该临界资源。尽管不是严格意义上的并发,可是也会造成了对该资源的竞态。
  • 内核态抢占:比如。当进程在訪问某个临界资源的时候发生内核态抢占,随后进入了高优先级的进程。假设该进程也訪问了同一临界资源,那么就会造成进程与进程之间的并发。
  • 多处理器的并发:多处理器系统上的进程与进程之间是严格意义上的并发,每一个处理器都能够独自调度执行一个进程。在同一时刻有多个进程在同一时候执行 。

如前所述可知:採用同步机制的目的就是避免多个进程并发并发訪问同一临界资源。

一、概述

近期深入的学习了 Linux 内核同步机制,将相关内容整理于此,既是一次梳理,也是一个分享,希望能帮助到读者一二。当然,所谓的深入也只是笔者现有的技术能力所能达到的程度而已。由于能力有限,有错误之处还请各位读者不吝指教,一起学习一起进步。

常用的 Linux 内核同步机制有原子操作、Per-CPU 变量、内存屏障、自旋锁、Mutex 锁、信号量和 RCU 等,后面几种锁实现会依赖于前三种基础同步机制。在正式开始分析具体的内核同步机制实现之前,需要先澄清一些基本概念。

二、基本概念

2.1 同步

既然是同步机制,那就首先要搞明白什么是同步。同步是指用于实现控制多个执行路径按照一定的规则或顺序访问某些系统资源的机制。所谓执行路径,就是在 CPU 上运行的代码流。我们知道,CPU 调度的最小单位是线程,可以是用户态线程,也可以是内核线程,甚至是中断服务程序。所以,执行路径在这里就包括用户态线程、内核线程和中断服务程序。执行路径、执行单元、控制路径等等,叫法不同,但本质都一样。那为什么需要同步机制呢?请继续往下看。

2.2 并发与竞态

并发是指两个以上的执行路径同时被执行,而并发的执行路径对共享资源(硬件资源和软件上的全局变量等)的访问则很容易导致竞态。例如,现在系统有一个 LED 灯可以由 APP 控制,APP1 控制灯亮一秒灭一秒,APP2 控制灯亮 500ms 灭 1500ms。如果 APP1 和 APP2 分别在 CPU1 和 CPU2 上并发运行,LED 灯的行为会是什么样的呢?很有可能 LED 灯的亮灭节奏都不会如这两个 APP 所愿,APP1 在关掉 LED 灯时,很有可能恰逢 APP2 正要打开 LED 灯。很明显,APP1 和 APP2 对 LED 灯这个资源产生了竞争关系。竞态是危险的,如果不加以约束,轻则只是程序运行结果不符合预期,重则系统崩溃。

在操作系统中,更复杂、更混乱的并发大量存在,而同步机制正是为了解决并发和竞态问题。同步机制通过保护临界区(访问共享资源的代码区域)达到对共享资源互斥访问的目的,所谓互斥访问,是指一个执行路径在访问共享资源时,另一个执行路径被禁止去访问。关于并发与竞态,有个生活例子很贴切。假如你和你的同事张小三都要上厕所,但是公司只有一个洗手间而且也只有一个坑。当张小三进入厕所关起门的那一刻起,你就无法进去了,只能在门外侯着。当小三哥出来后你才能进去解决你的问题。这里,公司厕所就是共享资源,你和张小三同时需要这个共享资源就是并发,你们对厕所的使用需求就构成了竞态,而厕所的门就是一种同步机制,他在用你就不能用了。更多内容请查阅宋宝华老师的《Linux 设备驱动开发详解》一书中第七章第一节,书中详细列举了竞态发生的场景,总结如下图。

2.3 中断与抢占

中断本身的概念很简单,本文不予解释。当然,这并不是说 Linux 内核的中断部分也很简单。事实上,Linux 内核的中断子系统也相当复杂,因为中断对于操作系统来说实在是太重要了。以后有机会,笔者计划开专题再来介绍。对于同步机制的代码分析来说,了解中断的概念即可,不需要深入分析内核的具体代码实现。抢占属于进程调度的概念,Linux 内核从 2.6 版本开始支持抢占调度。进程调度(管理)是 Linux 内核最核心的子系统之一,异常庞大,本文只简单介绍基本概念,对于同步机制的代码分析已然足够。通俗地说,抢占是指一个正愉快地运行在 CPU 上的 task(可以是用户态进程,也可以是内核线程) 被另一个 task(通常是更高优先级)夺去 CPU 执行权的故事。中断和抢占之间有着比较暧昧的关系,简单来说,抢占依赖中断。如果当前 CPU 禁止了本地中断,那么也意味着禁止了本 CPU 上的抢占。但反过来,禁掉抢占并不影响中断。Linux 内核中用 preempt_enable() 宏函数来开启本 CPU 的抢占,用 preempt_disable() 来禁掉本 CPU 的抢占。这里,“本 CPU” 这个描述其实不太准确,更严谨的说法是运行在当前 CPU 上的 task。preempt_enable() 和 preempt_disable() 的具体实现展开来介绍的话也可以单独成文了,笔者没有深究过,就不班门弄斧了,感兴趣的读者可以去 RTFSC。不管是用户态抢占还是内核态抢占,并不是什么代码位置都能发生,而是有抢占时机的,也就是所谓的抢占点。抢占时机如下:

  • 用户态抢占:1、从系统调用返回用户空间时;2、从中断(异常)处理程序返回用户空间时。
  • 内核态抢占:1、当一个中断处理程序退出,返回到内核态时;2、task 显式调用 schedule();3、task 发生阻塞(此时由调度器完成调度)。

2.4 编译乱序与编译屏障

编译器(compiler)的工作就是优化我们的代码以提高性能。这包括在不改变程序行为的情况下重新排列指令。因为 compiler 不知道什么样的代码需要线程安全(thread-safe),所以 compiler 假设我们的代码都是单线程执行(single-threaded),并且进行指令重排优化并保证是单线程安全的。因此,当你不需要 compiler 重新排序指令的时候,你需要显式告诉 compiler,我不需要重排。否则,它可不会听你的。本篇文章中,我们一起探究 compiler 关于指令重排的优化规则。

注:测试使用 aarch64-linux-gnu-gcc 版本:7.3.0

编译器指令重排(Compiler Instruction Reordering)

compiler 的主要工作就是将对人们可读的源码转化成机器语言,机器语言就是对 CPU 可读的代码。因此,compiler 可以在背后做些不为人知的事情。我们考虑下面的 C语言代码:

int a, b;
void foo(void)
{
    a = b + 1;
    b = 0;
}

使用 aarch64-linux-gnu-gcc 在不优化代码的情况下编译上述代码,使用 objdump 工具查看 foo() 反汇编结果:

<foo>:
    ...
    ldr w0, [x0]       //load b to w0
    add w1, w0, #0x1
    ...
    str w1, [x0]       //a = b + 1
    ...
    str wzr, [x0]      //b = 0

我们应该知道 Linux 默认编译优化选项是 -O2,因此我们采用 -O2 优化选项编译上述代码,并反汇编得到如下汇编结果:

<foo>:
    ...
    ldr w2, [x0]       //load b to w2
    str wzr, [x0]      //b = 0
    add w0, w2, #0x1
    str w0, [x1]       //a = b + 1

比较优化和不优化的结果,我们可以发现:在不优化的情况下,a 和 b 的写入内存顺序符合代码顺序(program order);但是 -O2 优化后,a 和 b 的写入顺序和 program order 是相反的。-O2 优化后的代码转换成 C 语言可以看作如下形式:

int a, b;
void foo(void)
{
    register int reg = b;
    b = 0;
    a = reg + 1;
}

这就是 compiler reordering(编译器重排)。为什么可以这么做呢?对于单线程来说,a 和 b 的写入顺序,compiler 认为没有任何问题。并且最终的结果也是正确的(a == 1 && b == 0)。这种 compiler reordering 在大部分情况下是没有问题的。但是在某些情况下可能会引入问题。例如我们使用一个全局变量 flag 标记共享数据 data 是否就绪。由于 compiler reordering,可能会引入问题。考虑下面的代码(无锁编程):

int flag, data;
void write_data(int value)
{
    data = value;
    flag = 1;
}

如果 compiler 产生的汇编代码是 flag 比 data 先写入内存,那么,即使是单核系统上,我们也会有问题。在 flag 置 1 之后,data 写 45 之前,系统发生抢占。另一个进程发现 flag 已经置 1,认为 data 的数据已经准备就绪。但是实际上读取 data 的值并不是 45。为什么 compiler 还会这么操作呢?因为,compiler 并不知道 data 和 flag 之间有严格的依赖关系。这种逻辑关系是我们人为强加的。我们如何避免这种优化呢?

显式编译器屏障(Explicit Compiler Barriers)

为了解决上述变量之间存在依赖关系导致 compiler 错误优化。compiler 为我们提供了编译器屏障(compiler barriers),可用来告诉 compiler 不要 reorder。我们继续使用上面的 foo() 函数作为演示实验,在代码之间插入 compiler barriers。

#define barrier() __asm__ __volatile__("": : :"memory")
int a, b;
void foo(void)
{
    a = b + 1;
    barrier();
    b = 0;
}

barrier() 就是 compiler 提供的屏障,作用是告诉 compiler 内存中的值已经改变,之前对内存的缓存(缓存到寄存器)都需要抛弃,barrier() 之后的内存操作需要重新从内存 load,而不能使用之前寄存器缓存的值。并且可以防止 compiler 优化 barrier() 前后的内存访问顺序。barrier() 就像是代码中的一道不可逾越的屏障,barrier() 前的 load/store 操作不能跑到 barrier() 后面;同样,barrier() 后面的 load/store 操作不能在 barrier() 之前。依然使用 -O2 优化选项编译上述代码,反汇编得到如下结果:

<foo>:
    ...
    ldr w2, [x0]       //load b to w2
    add w2, w2, #0x1
    str w2, [x1]       //a = a + 1
    str wzr, [x0]      //b = 0
    ...

我们可以看到插入 compiler barriers 之后,a 和 b 的写入顺序和 program order 一致。因此,当我们的代码中需要严格的内存顺序,就需要考虑 compiler barriers。

隐式编译器屏障(Implied Compiler Barriers)

除了显示的插入 compiler barriers 之外,还有别的方法阻止 compiler reordering。例如 CPU barriers 指令,同样会阻止 compiler reordering。后续我们再考虑 CPU barriers。除此以外,当某个函数内部包含 compiler barriers 时,该函数也会充当 compiler barriers 的作用。即使这个函数被 inline,也是这样。例如上面插入 barrier() 的 foo() 函数,当其他函数调用 foo() 时,foo() 就相当于 compiler barriers。考虑下面的代码:

int a, b, c;
void fun(void)
{
    c = 2;
    barrier();
}
void foo(void)
{
    a = b + 1;
    fun(); /* fun() call acts as compiler barriers */
    b = 0;
}

fun() 函数包含 barrier(),因此 foo() 函数中 fun() 调用也表现出 compiler barriers 的作用,同样可以保证 a 和 b 的写入顺序。如果 fun() 函数不包含 barrier(),结果又会怎么样呢?实际上,大多数的函数调用都表现出 compiler barriers 的作用。但是,这不包含 inline 的函数。因此,fun() 如果被 inline 进 foo(),那么 fun() 就不具有 compiler barriers 的作用。如果被调用的函数是一个外部函数,其副作用会比 compiler barriers 还要强。因为 compiler 不知道函数的副作用是什么。它必须忘记它对内存所作的任何假设,即使这些假设对该函数可能是可见的。我们看一下下面的代码片段,printf() 一定是一个外部的函数。

int a, b;
void foo(void)
{
    a = 5;
    printf("smcdef");
    b = a;
}

同样使用 -O2 优化选项编译代码,objdump 反汇编得到如下结果:

<foo>:
    ...
    mov w2, #0x5              //#5
    str w2, [x19]             //a = 5
    bl 640 <__printf_chk@plt> //printf()
    ldr w1, [x19]             //reload a to w1
    ...
    str w1, [x0]              //b = a

compiler 不能假设 printf() 不会使用或者修改 a 变量。因此在调用 printf() 之前会将 a 写 5,以保证 printf() 可能会用到新值。在 printf() 调用之后,重新从内存中 load a 的值,然后赋值给变量 b。重新 load a 的原因是 compiler 也不知道 printf() 会不会修改 a 的值。

因此,我们可以看到即使存在 compiler reordering,但是还是有很多限制。当我们需要考虑 compiler barriers 时,一定要显示的插入 barrier(),而不是依靠函数调用附加的隐式 compiler barriers。因为,谁也无法保证调用的函数不会被 compiler 优化成 inline 方式。

barrier() 除了防止编译乱序,还能做什么

barriers() 作用除了防止 compiler reordering 之外,还有什么妙用吗?我们考虑下面的代码片段:

int run = 1;
void foo(void)
{
    while (run)
        ;
}

run 是个全局变量,foo() 在一个进程中执行,一直循环。我们期望的结果是 foo() 一直等到其他进程修改 run 的值为 0 才退出循环。实际 compiler 编译的代码和我们会达到我们预期的结果吗?我们看一下汇编代码:

0000000000000748 <foo>:
748: 90000080 adrp x0, 10000
74c: f947e800 ldr x0, [x0, #4048]
750: b9400000 ldr w0, [x0]            //load run to w0
754: d503201f nop
758: 35000000 cbnz w0, 758 <foo+0x10> //if (w0) while (1);
75c: d65f03c0 ret

汇编代码可以转换成如下的 C 语言形式:

int run = 1;
void foo(void)
{
    register int reg = run;
    if (reg)
        while (1)
            ;
}

compiler 首先将 run 加载到一个寄存器 reg 中,然后判断 reg 是否满足循环条件,如果满足就一直循环。但是循环过程中,寄存器 reg 的值并没有变化。因此,即使其他进程修改 run 的值为 0,也不能使 foo() 退出循环。很明显,这不是我们想要的结果。我们继续看一下加入 barrier() 后的结果:

0000000000000748 <foo>:
748: 90000080 adrp x0, 10000
74c: f947e800 ldr x0, [x0, #4048]
750: b9400001 ldr w1, [x0]            //load run to w0
754: 34000061 cbz w1, 760 <foo+0x18>
758: b9400001 ldr w1, [x0]            //load run to w0
75c: 35ffffe1 cbnz w1, 758 <foo+0x10> //if (w0) goto 758
760: d65f03c0 ret

可以看到加入 barrier() 后的结果真是我们想要的。每一次循环都会从内存中重新 load run 的值。因此,当有其他进程修改 run 的值为 0 的时候,foo() 可以正常退出循环。为什么加入 barrier() 后的汇编代码就是正确的呢?因为 barrier() 作用是告诉 compiler 内存中的值已经变化,后面的操作都需要重新从内存 load,而不能使用寄存器缓存的值。因此,这里的 run 变量会从内存重新 load,然后判断循环条件。这样,其他进程修改 run 变量,foo() 就可以看得见了。

在 Linux kernel 中,提供了 cpu_relax() 函数,该函数在 ARM64 平台定义如下:

static inline void cpu_relax(void)
{
    asm volatile("yield" ::: "memory");
}

我们可以看出,cpu_relax() 是在 barrier() 的基础上又插入一条汇编指令 yield。在 kernel 中,我们经常会看到一些类似上面举例的 while 循环,循环条件是个全局变量。为了避免上述所说问题,我们就会在循环中插入 cpu_relax() 调用。

int run = 1;
void foo(void)
{
    while (run)
        cpu_relax();
}

当然也可以使用 Linux 提供的 READ_ONCE()。例如,下面的修改也同样可以达到我们预期的效果。

int run = 1;
void foo(void)
{
    while (READ_ONCE(run)) /* similar to while (*(volatile int *)&run) */
        ;
}

当然你也可以修改 run 的定义为 volatile int run就会得到如下代码。同样可以达到预期目的。

volatile int run = 1;
void foo(void)
{
    while (run);
}

2.5 执行乱序与内存屏障

《Linux 设备驱动开发详解》一书中第七章第二节对编译乱序和执行乱序都有简略的介绍,通俗易懂,不可不读。不管是编译乱序还是执行乱序,都是为了提升 CPU 的性能。执行乱序是处理器运行时的行为,和 CPU 内部设计架构有关。而对于从事在 Linux 内核的程序员来说,要真正的理解透执行乱序所带来的软件方面的影响,首先需要搞清楚 cache 的概念。

三、原子操作

所谓原子操作,就是该操作绝不会在执行完毕前被任何其他任务或事件打断,也就说,它的最小的执行单位,不可能有比它更小的执行单位,因此这里的原子实际是使用了物理学里的物质微粒的概念。

原子操作需要硬件的支持,因此是架构相关的,其API和原子类型的定义都定义在内核源码树的include/asm/atomic.h文件中,它们都使用汇编语言实现,因为C语言并不能实现这样的操作。

原子操作主要用于实现资源计数,很多引用计数(refcnt)就是通过原子操作实现的。原子类型定义如下:

typedef struct { 
volatile int counter; 
} atomic_t;

volatile修饰字段告诉gcc不要对该类型的数据做优化处理,对它的访问都是对内存的访问,而不是对寄存器的访问。

原子操作API包括:

  • tomic_read(atomic_t * v);该函数对原子类型的变量进行原子读操作,它返回原子类型的变量v的值。
  • atomic_set(atomic_t * v, int i);该函数设置原子类型的变量v的值为i。
  • void atomic_add(int i, atomic_t *v);该函数给原子类型的变量v增加值i。
  • atomic_sub(int i, atomic_t *v);该函数从原子类型的变量v中减去i。
  • int atomic_sub_and_test(int i, atomic_t *v);该函数从原子类型的变量v中减去i,并判断结果是否为0,如果为0,返回真,否则返回假。
  • void atomic_inc(atomic_t *v);该函数对原子类型变量v原子地增加1。
  • void atomic_dec(atomic_t *v);该函数对原子类型的变量v原子地减1。
  • int atomic_dec_and_test(atomic_t *v);该函数对原子类型的变量v原子地减1,并判断结果是否为0,如果为0,返回真,否则返回假。
  • int atomic_inc_and_test(atomic_t *v);该函数对原子类型的变量v原子地增加1,并判断结果是否为0,如果为0,返回真,否则返回假。
  • int atomic_add_negative(int i, atomic_t *v);该函数对原子类型的变量v原子地增加I,并判断结果是否为负数,如果是,返回真,否则返回假。
  • int atomic_add_return(int i, atomic_t *v);该函数对原子类型的变量v原子地增加i,并且返回指向v的指针。
  • int atomic_sub_return(int i, atomic_t *v);该函数从原子类型的变量v中减去i,并且返回指向v的指针。
  • int atomic_inc_return(atomic_t * v);该函数对原子类型的变量v原子地增加1并且返回指向v的指针。
  • int atomic_dec_return(atomic_t * v);该函数对原子类型的变量v原子地减1并且返回指向v的指针。

原子操作通常用于实现资源的引用计数,在TCP/IP协议栈的IP碎片处理中,就使用了引用计数,碎片队列结构struct ipq描述了一个IP碎片,字段refcnt就是引用计数器,它的类型为atomic_t,当创建IP碎片时(在函数ip_frag_create中),使用atomic_set函数把它设置为1,当引用该IP碎片时,就使用函数atomic_inc把引用计数加1。

当不需要引用该IP碎片时,就使用函数ipq_put来释放该IP碎片,ipq_put使用函数atomic_dec_and_test把引用计数减1并判断引用计数是否为0,如果是就释放IP碎片。函数ipq_kill把IP碎片从ipq队列中删除,并把该删除的IP碎片的引用计数减1(通过使用函数atomic_dec实现)。

四、信号量(semaphore)

Linux内核的信号量在概念和原理上与用户态的System V的IPC机制信号量是一样的,但是它绝不可能在内核之外使用,因此它与System V的IPC机制信号量毫不相干。

信号量在创建时需要设置一个初始值,表示同时可以有几个任务可以访问该信号量保护的共享资源,初始值为1就变成互斥锁(Mutex),即同时只能有一个任务可以访问信号量保护的共享资源。

一个任务要想访问共享资源,首先必须得到信号量,获取信号量的操作将把信号量的值减1,若当前信号量的值为负数,表明无法获得信号量,该任务必须挂起在该信号量的等待队列等待该信号量可用;若当前信号量的值为非负数,表示可以获得信号量,因而可以立刻访问被该信号量保护的共享资源。

当任务访问完被信号量保护的共享资源后,必须释放信号量,释放信号量通过把信号量的值加1实现,如果信号量的值为非正数,表明有任务等待当前信号量,因此它也唤醒所有等待该信号量的任务。

信号量的API有:

  • DECLARE_MUTEX(name)该宏声明一个信号量name并初始化它的值为0,即声明一个互斥锁。
  • DECLARE_MUTEX_LOCKED(name)该宏声明一个互斥锁name,但把它的初始值设置为0,即锁在创建时就处在已锁状态。因此对于这种锁,一般是先释放后获得。
  • void sema_init (struct semaphore *sem, int val);该函用于数初始化设置信号量的初值,它设置信号量sem的值为val。
  • void init_MUTEX (struct semaphore *sem);该函数用于初始化一个互斥锁,即它把信号量sem的值设置为1。
  • void init_MUTEX_LOCKED (struct semaphore *sem);该函数也用于初始化一个互斥锁,但它把信号量sem的值设置为0,即一开始就处在已锁状态。
  • void down(struct semaphore * sem);该函数用于获得信号量sem,它会导致睡眠,因此不能在中断上下文(包括IRQ上下文和softirq上下文)使用该函数。该函数将把sem的值减1,如果信号量sem的值非负,就直接返回,否则调用者将被挂起,直到别的任务释放该信号量才能继续运行。
  • int down_interruptible(struct semaphore * sem);该函数功能与down类似,不同之处为,down不会被信号(signal)打断,但down_interruptible能被信号打断,因此该函数有返回值来区分是正常返回还是被信号中断,如果返回0,表示获得信号量正常返回,如果被信号打断,返回-EINTR。
  • int down_trylock(struct semaphore * sem);该函数试着获得信号量sem,如果能够立刻获得,它就获得该信号量并返回0,否则,表示不能获得信号量sem,返回值为非0值。因此,它不会导致调用者睡眠,可以在中断上下文使用。
  • void up(struct semaphore * sem);该函数释放信号量sem,即把sem的值加1,如果sem的值为非正数,表明有任务等待该信号量,因此唤醒这些等待者。

信号量在绝大部分情况下作为互斥锁使用,下面以console驱动系统为例说明信号量的使用。

在内核源码树的kernel/printk.c中,使用宏DECLARE_MUTEX声明了一个互斥锁console_sem,它用于保护console驱动列表console_drivers以及同步对整个console驱动系统的访问。

其中定义了函数acquire_console_sem来获得互斥锁console_sem,定义了release_console_sem来释放互斥锁console_sem,定义了函数try_acquire_console_sem来尽力得到互斥锁console_sem。这三个函数实际上是分别对函数down,up和down_trylock的简单包装。

需要访问console_drivers驱动列表时就需要使用acquire_console_sem来保护console_drivers列表,当访问完该列表后,就调用release_console_sem释放信号量console_sem。

函数console_unblank,console_device,console_stop,console_start,register_console和unregister_console都需要访问console_drivers,因此它们都使用函数对acquire_console_sem和release_console_sem来对console_drivers进行保护。

五、读写信号量(rw_semaphore)

写信号量对访问者进行了细分,或者为读者,或者为写者,读者在保持读写信号量期间只能对该读写信号量保护的共享资源进行读访问,如果一个任务除了需要读,可能还需要写,那么它必须被归类为写者,它在对共享资源访问之前必须先获得写者身份,写者在发现自己不需要写访问的情况下可以降级为读者。读写信号量同时拥有的读者数不受限制,也就说可以有任意多个读者同时拥有一个读写信号量。

如果一个读写信号量当前没有被写者拥有并且也没有写者等待读者释放信号量,那么任何读者都可以成功获得该读写信号量;否则,读者必须被挂起直到写者释放该信号量。如果一个读写信号量当前没有被读者或写者拥有并且也没有写者等待该信号量,那么一个写者可以成功获得该读写信号量,否则写者将被挂起,直到没有任何访问者。因此,写者是排他性的,独占性的。

读写信号量有两种实现,一种是通用的,不依赖于硬件架构,因此,增加新的架构不需要重新实现它,但缺点是性能低,获得和释放读写信号量的开销大;另一种是架构相关的,因此性能高,获取和释放读写信号量的开销小,但增加新的架构需要重新实现。在内核配置时,可以通过选项去控制使用哪一种实现。

读写信号量的相关API有:

  • DECLARE_RWSEM(name)该宏声明一个读写信号量name并对其进行初始化。
  • void init_rwsem(struct rw_semaphore *sem);该函数对读写信号量sem进行初始化。
  • void down_read(struct rw_semaphore *sem);读者调用该函数来得到读写信号量sem。该函数会导致调用者睡眠,因此只能在进程上下文使用。
  • int down_read_trylock(struct rw_semaphore *sem);该函数类似于down_read,只是它不会导致调用者睡眠。它尽力得到读写信号量sem,如果能够立即得到,它就得到该读写信号量,并且返回1,否则表示不能立刻得到该信号量,返回0。因此,它也可以在中断上下文使用。
  • void down_write(struct rw_semaphore *sem);写者使用该函数来得到读写信号量sem,它也会导致调用者睡眠,因此只能在进程上下文使用。
  • int down_write_trylock(struct rw_semaphore *sem);该函数类似于down_write,只是它不会导致调用者睡眠。该函数尽力得到读写信号量,如果能够立刻获得,就获得该读写信号量并且返回1,否则表示无法立刻获得,返回0。它可以在中断上下文使用。
  • void up_read(struct rw_semaphore *sem);读者使用该函数释放读写信号量sem。它与down_read或down_read_trylock配对使用。如果down_read_trylock返回0,不需要调用up_read来释放读写信号量,因为根本就没有获得信号量。
  • void up_write(struct rw_semaphore *sem);写者调用该函数释放信号量sem。它与down_write或down_write_trylock配对使用。如果down_write_trylock返回0,不需要调用up_write,因为返回0表示没有获得该读写信号量。
  • void downgrade_write(struct rw_semaphore *sem);该函数用于把写者降级为读者,这有时是必要的。因为写者是排他性的,因此在写者保持读写信号量期间,任何读者或写者都将无法访问该读写信号量保护的共享资源,对于那些当前条件下不需要写访问的写者,降级为读者将,使得等待访问的读者能够立刻访问,从而增加了并发性,提高了效率。对于那些当前条件下不需要写访问的写者,降级为读者将,使得等待访问的读者能够立刻访问,从而增加了并发性,提高了效率。读写信号量适于在读多写少的情况下使用,在linux内核中对进程的内存映像描述结构的访问就使用了读写信号量进行保护。

在Linux中,每一个进程都用一个类型为task_t或struct task_struct的结构来描述,该结构的类型为struct mm_struct的字段mm描述了进程的内存映像,特别是mm_struct结构的mmap字段维护了整个进程的内存块列表,该列表将在进程生存期间被大量地遍利或修改。结构的mmap字段维护了整个进程的内存块列表,该列表将在进程生存期间被大量地遍利或修改。

因此mm_struct结构就有一个字段mmap_sem来对mmap的访问进行保护,mmap_sem就是一个读写信号量,在proc文件系统里有很多进程内存使用情况的接口,通过它们能够查看某一进程的内存使用情况,命令free、ps和top都是通过proc来得到内存使用信息的,proc接口就使用down_read和up_read来读取进程的mmap信息。

当进程动态地分配或释放内存时,需要修改mmap来反映分配或释放后的内存映像,因此动态内存分配或释放操作需要以写者身份获得读写信号量mmap_sem来对mmap进行更新。系统调用brk和munmap就使用了down_write和up_write来保护对mmap的访问。

六、自旋锁(spinlock)

自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。

由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的,自旋锁的效率远高于互斥锁。

信号量和读写信号量适合于保持时间较长的情况,它们会导致调用者睡眠,因此只能在进程上下文使用(_trylock的变种能够在中断上下文使用),而自旋锁适合于保持时间非常短的情况,它可以在任何上下文使用。

如果被保护的共享资源只在进程上下文访问,使用信号量保护该共享资源非常合适,如果对共巷资源的访问时间非常短,自旋锁也可以。但是如果被保护的共享资源需要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必须使用自旋锁。

自旋锁保持期间是抢占失效的,而信号量和读写信号量保持期间是可以被抢占的。自旋锁只有在内核可抢占或SMP的情况下才真正需要,在单CPU且不可抢占的内核下,自旋锁的所有操作都是空操作。

跟互斥锁一样,一个执行单元要想访问被自旋锁保护的共享资源,必须先得到锁,在访问完共享资源后,必须释放锁。如果在获取自旋锁时,没有任何执行单元保持该锁,那么将立即得到锁;如果在获取自旋锁时锁已经有保持者,那么获取锁操作将自旋在那里,直到该自旋锁的保持者释放了锁。

无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。

自旋锁的API有:

  • spin_lock_init(x)该宏用于初始化自旋锁x。自旋锁在真正使用前必须先初始化。该宏用于动态初始化。
  • DEFINE_SPINLOCK(x)该宏声明一个自旋锁x并初始化它。该宏在2.6.11中第一次被定义,在先前的内核中并没有该宏。
  • SPIN_LOCK_UNLOCKED该宏用于静态初始化一个自旋锁。
  • DEFINE_SPINLOCK(x)等同于spinlock_t x = SPIN_LOCK_UNLOCKEDspin_is_locked(x)该宏用于判断自旋锁x是否已经被某执行单元保持(即被锁),如果是,返回真,否则返回假。
  • spin_unlock_wait(x)该宏用于等待自旋锁x变得没有被任何执行单元保持,如果没有任何执行单元保持该自旋锁,该宏立即返回,否则将循环在那里,直到该自旋锁被保持者释放。
  • spin_trylock(lock)该宏尽力获得自旋锁lock,如果能立即获得锁,它获得锁并返回真,否则不能立即获得锁,立即返回假。它不会自旋等待lock被释放。
  • spin_lock(lock)该宏用于获得自旋锁lock,如果能够立即获得锁,它就马上返回,否则,它将自旋在那里,直到该自旋锁的保持者释放,这时,它获得锁并返回。总之,只有它获得锁才返回。
  • spin_lock_irqsave(lock, flags)该宏获得自旋锁的同时把标志寄存器的值保存到变量flags中并失效本地中断。
  • spin_lock_irq(lock)该宏类似于spin_lock_irqsave,只是该宏不保存标志寄存器的值。
  • spin_lock_bh(lock)该宏在得到自旋锁的同时失效本地软中断。
  • spin_unlock(lock)该宏释放自旋锁lock,它与spin_trylock或spin_lock配对使用。如果spin_trylock返回假,表明没有获得自旋锁,因此不必使用spin_unlock释放。
  • spin_unlock_irqrestore(lock, flags)该宏释放自旋锁lock的同时,也恢复标志寄存器的值为变量flags保存的值。它与spin_lock_irqsave配对使用。
  • spin_unlock_irq(lock)该宏释放自旋锁lock的同时,也使能本地中断。它与spin_lock_irq配对应用。
  • spin_unlock(lock)该宏释放自旋锁lock,它与spin_trylock或spin_lock配对使用。如果spin_trylock返回假,表明没有获得自旋锁,因此不必使用spin_unlock释放。
  • spin_unlock_irqrestore(lock, flags)该宏释放自旋锁lock的同时,也恢复标志寄存器的值为变量flags保存的值。它与spin_lock_irqsave配对使用。
  • spin_unlock_irq(lock)该宏释放自旋锁lock的同时,也使能本地中断。它与spin_lock_irq配对应用。
  • spin_unlock_bh(lock)该宏释放自旋锁lock的同时,也使能本地的软中断。它与spin_lock_bh配对使用。
  • spin_trylock_irqsave(lock, flags) 该宏如果获得自旋锁lock,它也将保存标志寄存器的值到变量flags中,并且失效本地中断,如果没有获得锁,它什么也不做。因此如果能够立即获得锁,它等同于spin_lock_irqsave,如果不能获得锁,它等同于spin_trylock。如果该宏获得自旋锁lock,那需要使用spin_unlock_irqrestore来释放。
  • spin_unlock_bh(lock)该宏释放自旋锁lock的同时,也使能本地的软中断。它与spin_lock_bh配对使用。
  • spin_trylock_irqsave(lock, flags) 该宏如果获得自旋锁lock,它也将保存标志寄存器的值到变量flags中,并且失效本地中断,如果没有获得锁,它什么也不做。因此如果能够立即获得锁,它等同于spin_lock_irqsave,如果不能获得锁,它等同于spin_trylock。如果该宏获得自旋锁lock,那需要使用spin_unlock_irqrestore来释放。
  • spin_can_lock(lock)该宏用于判断自旋锁lock是否能够被锁,它实际是spin_is_locked取反。如果lock没有被锁,它返回真,否则,返回假。该宏在2.6.11中第一次被定义,在先前的内核中并没有该宏。

获得自旋锁和释放自旋锁有好几个版本,因此让读者知道在什么样的情况下使用什么版本的获得和释放锁的宏是非常必要的。

如果被保护的共享资源只在进程上下文访问和软中断上下文访问,那么当在进程上下文访问共享资源时,可能被软中断打断,从而可能进入软中断上下文来对被保护的共享资源访问,因此对于这种情况,对共享资源的访问必须使用spin_lock_bh和spin_unlock_bh来保护。

当然使用spin_lock_irq和spin_unlock_irq以及spin_lock_irqsave和spin_unlock_irqrestore也可以,它们失效了本地硬中断,失效硬中断隐式地也失效了软中断。但是使用spin_lock_bh和spin_unlock_bh是最恰当的,它比其他两个快。

如果被保护的共享资源只在进程上下文和tasklet或timer上下文访问,那么应该使用与上面情况相同的获得和释放锁的宏,因为tasklet和timer是用软中断实现的。

如果被保护的共享资源只在一个tasklet或timer上下文访问,那么不需要任何自旋锁保护,因为同一个tasklet或timer只能在一个CPU上运行,即使是在SMP环境下也是如此。实际上tasklet在调用tasklet_schedule标记其需要被调度时已经把该tasklet绑定到当前CPU,因此同一个tasklet决不可能同时在其他CPU上运行。

timer也是在其被使用add_timer添加到timer队列中时已经被帮定到当前CPU,所以同一个timer绝不可能运行在其他CPU上。当然同一个tasklet有两个实例同时运行在同一个CPU就更不可能了。

如果被保护的共享资源只在两个或多个tasklet或timer上下文访问,那么对共享资源的访问仅需要用spin_lock和spin_unlock来保护,不必使用_bh版本,因为当tasklet或timer运行时,不可能有其他tasklet或timer在当前CPU上运行。

如果被保护的共享资源只在一个软中断(tasklet和timer除外)上下文访问,那么这个共享资源需要用spin_lock和spin_unlock来保护,因为同样的软中断可以同时在不同的CPU上运行。

如果被保护的共享资源在两个或多个软中断上下文访问,那么这个共享资源当然更需要用spin_lock和spin_unlock来保护,不同的软中断能够同时在不同的CPU上运行。

如果被保护的共享资源在软中断(包括tasklet和timer)或进程上下文和硬中断上下文访问,那么在软中断或进程上下文访问期间,可能被硬中断打断,从而进入硬中断上下文对共享资源进行访问,因此,在进程或软中断上下文需要使用spin_lock_irq和spin_unlock_irq来保护对共享资源的访问。

而在中断处理句柄中使用什么版本,需依情况而定,如果只有一个中断处理句柄访问该共享资源,那么在中断处理句柄中仅需要spin_lock和spin_unlock来保护对共享资源的访问就可以了。

因为在执行中断处理句柄期间,不可能被同一CPU上的软中断或进程打断。但是如果有不同的中断处理句柄访问该共享资源,那么需要在中断处理句柄中使用spin_lock_irq和spin_unlock_irq来保护对共享资源的访问。

在使用spin_lock_irq和spin_unlock_irq的情况下,完全可以用spin_lock_irqsave和spin_unlock_irqrestore取代,那具体应该使用哪一个也需要依情况而定,如果可以确信在对共享资源访问前中断是使能的,那么使用spin_lock_irq更好一些。

因为它比spin_lock_irqsave要快一些,但是如果你不能确定是否中断使能,那么使用spin_lock_irqsave和spin_unlock_irqrestore更好,因为它将恢复访问共享资源前的中断标志而不是直接使能中断。

当然,有些情况下需要在访问共享资源时必须中断失效,而访问完后必须中断使能,这样的情形使用spin_lock_irq和spin_unlock_irq最好。

需要特别提醒读者,spin_lock用于阻止在不同CPU上的执行单元对共享资源的同时访问以及不同进程上下文互相抢占导致的对共享资源的非同步访问,而中断失效和软中断失效却是为了阻止在同一CPU上软中断或中断对共享资源的非同步访问。


相关文章
|
2天前
|
缓存 运维 算法
深入理解Linux内核的虚拟内存管理
【5月更文挑战第6天】 在现代操作系统中,尤其是类Unix系统如Linux中,虚拟内存管理是一项核心功能,它不仅支持了多任务环境,还提供了内存保护和抽象。本文将深入探讨Linux操作系统的虚拟内存子系统,包括分页机制、虚拟地址空间布局、页面置换算法以及内存分配策略。通过对这些概念的剖析,我们旨在为读者揭示Linux如何有效地管理和优化物理内存资源,并确保系统的稳定运行与高效性能。
|
2天前
|
Linux 调度 开发者
探索Linux内核调度:公平与效率的平衡艺术
【5月更文挑战第6天】 随着多核处理器的普及,操作系统的进程调度策略对系统性能的影响愈加显著。Linux作为广泛应用的开源操作系统,其内核调度器的设计哲学和实现细节一直是系统研究领域的热点。本文将深入分析Linux内核调度器的工作原理,探讨如何在保证公平性和效率之间取得平衡,并考察最新的调度器CFS(Completely Fair Scheduler)如何适应现代硬件架构的需求。
|
7天前
|
运维 监控 Linux
提升系统稳定性:Linux内核参数调优实战
【5月更文挑战第1天】 在运维领域,保障服务器的高效稳定运行是核心任务之一。Linux操作系统因其开源、可靠和灵活的特点被广泛应用于服务器中。本文将深入探讨通过调整Linux内核参数来优化系统性能,提升服务器的稳定性和响应能力。文章首先介绍了内核参数调优的必要性和基本原则,然后详细阐述了调优过程中的关键步骤,包括如何监控当前系统状态,确定性能瓶颈,选择合适的参数进行调优,以及调优后的测试与验证。最后,文中提供了一些常见问题的解决策略和调优的最佳实践。
32 5
|
8天前
|
算法 大数据 Linux
深入理解Linux内核的进程调度机制
【4月更文挑战第30天】操作系统的核心职能之一是有效地管理和调度进程,确保系统资源的合理分配和高效利用。在众多操作系统中,Linux因其开源和高度可定制的特点,在进程调度机制上展现出独特优势。本文将深入探讨Linux内核中的进程调度器——完全公平调度器(CFS),分析其设计理念、实现原理及面临的挑战,并探索未来可能的改进方向。
|
8天前
|
算法 Linux 调度
探索Linux内核:进程调度的奥秘
【4月更文挑战第30天】 在多任务操作系统中,进程调度是核心功能之一,它决定了处理器资源的分配。本文深入分析了Linux操作系统的进程调度机制,从调度器的基本原理到复杂的调度策略,以及它们如何影响系统性能和用户体验。通过剖析进程优先级、时间片分配以及实时性要求等方面,揭示了Linux如何在众多运行着的进程中做出快速而公平的决策,确保系统的高效与稳定运行。
|
8天前
|
算法 安全 Linux
深度解析:Linux内核内存管理机制
【4月更文挑战第30天】 在操作系统领域,内存管理是核心功能之一,尤其对于多任务操作系统来说更是如此。本文将深入探讨Linux操作系统的内核内存管理机制,包括物理内存的分配与回收、虚拟内存的映射以及页面替换算法等关键技术。通过对这些技术的详细剖析,我们不仅能够理解操作系统如何高效地利用有限的硬件资源,还能领会到系统设计中的性能与复杂度之间的权衡。
|
9天前
|
弹性计算 网络协议 Shell
自动优化Linux 内核参数
【4月更文挑战第29天】
8 1
|
9天前
|
弹性计算 网络协议 Linux
自动优化 Linux 内核参数
【4月更文挑战第28天】
20 0
|
14天前
|
缓存 Linux
linux系统缓存机制
linux系统缓存机制
|
14天前
|
算法 Linux Shell
【linux进程(二)】如何创建子进程?--fork函数深度剖析
【linux进程(二)】如何创建子进程?--fork函数深度剖析
http://www.vxiaotou.com