Linux内核同步机制:解锁并发编程的奥秘
在当今的数字时代,多核处理器早已成为计算机系统的标配,从我们日常办公的电脑,到数据中心里庞大的服务器集群,它们无处不在。这一硬件层面的发展,使得计算机系统能够同时处理多个任务,极大地提升了计算效率。就如同繁忙的交通枢纽,多车道并行,车辆往来穿梭,看似混乱却又有序运
然而,在 Linux 内核这个 “交通指挥中心” 里,当多个进程或线程如同川流不息的车辆,试图同时访问共享资源时,问题就出现了。想象一下,两条道路上的车辆都想同时通过一个狭窄的路口,如果没有合理的交通规则,必然会导致拥堵甚至碰撞。在 Linux 内核中,这些共享资源就如同这个狭窄路口,而进程和线程的并发访问如果缺乏有效的管理,就会引发数据不一致、程序崩溃等严重问题。这不仅会影响系统的稳定性,还可能导致关键业务的中断,造成不可估量的损失。
那么,Linux 内核是如何在复杂的并发环境中,确保共享资源的安全访问,维持系统的高效稳定运行的呢?答案就在于其精心设计的同步机制。它就像一套精密的交通指挥系统,通过各种规则和信号,引导着进程和线程这些 “车辆” 有序地通过共享资源这个 “路口”。接下来,就让我们一同深入 Linux 内核同步机制的世界,探寻其中的奥秘,解锁并发编程的关键技巧,为构建更稳定、高效的系统奠定坚实基础。
常用的 Linux 内核同步机制有原子操作、Per-CPU 变量、内存屏障、自旋锁、Mutex 锁、信号量和 RCU 等,后面几种锁实现会依赖于前三种基础同步机制。在正式开始分析具体的内核同步机制实现之前,需要先澄清一些基本概念。
一、基本概念
1.1 同步机制
既然是同步机制,那就首先要搞明白什么是同步。同步是指用于实现控制多个执行路径按照一定的规则或顺序访问某些系统资源的机制。所谓执行路径,就是在 CPU 上运行的代码流。我们知道,CPU 调度的最小单位是线程,可以是用户态线程,也可以是内核线程,甚至是中断服务程序。所以,执行路径在这里就包括用户态线程、内核线程和中断服务程序。执行路径、执行单元、控制路径等等,叫法不同,但本质都一样。那为什么需要同步机制呢?请继续往下看。
1.2 并发与竞态
并发是指两个以上的执行路径同时被执行,而并发的执行路径对共享资源(硬件资源和软件上的全局变量等)的访问则很容易导致竞态。例如,现在系统有一个 LED 灯可以由 APP 控制,APP1 控制灯亮一秒灭一秒,APP2 控制灯亮 500ms 灭 1500ms。如果 APP1 和 APP2 分别在 CPU1 和 CPU2 上并发运行,LED 灯的行为会是什么样的呢?很有可能 LED 灯的亮灭节奏都不会如这两个 APP 所愿,APP1 在关掉 LED 灯时,很有可能恰逢 APP2 正要打开 LED 灯。很明显,APP1 和 APP2 对 LED 灯这个资源产生了竞争关系。竞态是危险的,如果不加以约束,轻则只是程序运行结果不符合预期,重则系统崩溃。
在操作系统中,更复杂、更混乱的并发大量存在,而同步机制正是为了解决并发和竞态问题。同步机制通过保护临界区(访问共享资源的代码区域)达到对共享资源互斥访问的目的,所谓互斥访问,是指一个执行路径在访问共享资源时,另一个执行路径被禁止去访问。关于并发与竞态,有个生活例子很贴切。假如你和你的同事张小三都要上厕所,但是公司只有一个洗手间而且也只有一个坑。当张小三进入厕所关起门的那一刻起,你就无法进去了,只能在门外侯着。
当小三哥出来后你才能进去解决你的问题。这里,公司厕所就是共享资源,你和张小三同时需要这个共享资源就是并发,你们对厕所的使用需求就构成了竞态,而厕所的门就是一种同步机制,他在用你就不能用了
总结如下图:
图片
1.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 发生阻塞(此时由调度器完成调度)。
1.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语言代码:
使用 aarch64-linux-gnu-gcc 在不优化代码的情况下编译上述代码,使用 objdump 工具查看 foo() 反汇编结果
我们应该知道 Linux 默认编译优化选项是 -O2,因此我们采用 -O2 优化选项编译上述代码,并反汇编得到如下汇编结果:
比较优化和不优化的结果,我们可以发现:在不优化的情况下,a 和 b 的写入内存顺序符合代码顺序(program order);但是 -O2 优化后,a 和 b 的写入顺序和 program order 是相反的。-O2 优化后的代码转换成 C 语言可以看作如下形式:
这就是 compiler reordering(编译器重排)。为什么可以这么做呢?对于单线程来说,a 和 b 的写入顺序,compiler 认为没有任何问题。并且最终的结果也是正确的(a == 1 && b == 0)。这种 compiler reordering 在大部分情况下是没有问题的。但是在某些情况下可能会引入问题。例如我们使用一个全局变量 flag 标记共享数据 data 是否就绪。由于 compiler reordering,可能会引入问题。考虑下面的代码(无锁编程):
如果 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。
barrier() 就是 compiler 提供的屏障,作用是告诉 compiler 内存中的值已经改变,之前对内存的缓存(缓存到寄存器)都需要抛弃,barrier() 之后的内存操作需要重新从内存 load,而不能使用之前寄存器缓存的值。并且可以防止 compiler 优化 barrier() 前后的内存访问顺序。barrier() 就像是代码中的一道不可逾越的屏障,barrier() 前的 load/store 操作不能跑到 barrier() 后面;同样,barrier() 后面的 load/store 操作不能在 barrier() 之前。依然使用 -O2 优化选项编译上述代码,反汇编得到如下结果:
我们可以看到插入 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。考虑下面的代码:
fun() 函数包含 barrier(),因此 foo() 函数中 fun() 调用也表现出 compiler barriers 的作用,同样可以保证 a 和 b 的写入顺序。如果 fun() 函数不包含 barrier(),结果又会怎么样呢?实际上,大多数的函数调用都表现出 compiler barriers 的作用。但是,这不包含 inline 的函数。因此,fun() 如果被 inline 进 foo(),那么 fun() 就不具有 compiler barriers 的作用。
如果被调用的函数是一个外部函数,其副作用会比 compiler barriers 还要强。因为 compiler 不知道函数的副作用是什么。它必须忘记它对内存所作的任何假设,即使这些假设对该函数可能是可见的。我们看一下下面的代码片段,printf() 一定是一个外部的函数。
同样使用 -O2 优化选项编译代码,objdump 反汇编得到如下结果:
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 之外,还有什么妙用吗?我们考虑下面的代码片段:
run 是个全局变量,foo() 在一个进程中执行,一直循环。我们期望的结果是 foo() 一直等到其他进程修改 run 的值为 0 才退出循环。实际 compiler 编译的代码和我们会达到我们预期的结果吗?我们看一下汇编代码:
汇编代码可以转换成如下的 C 语言形式:
compiler 首先将 run 加载到一个寄存器 reg 中,然后判断 reg 是否满足循环条件,如果满足就一直循环。但是循环过程中,寄存器 reg 的值并没有变化。因此,即使其他进程修改 run 的值为 0,也不能使 foo() 退出循环。很明显,这不是我们想要的结果。我们继续看一下加入 barrier() 后的结果:
可以看到加入 barrier() 后的结果真是我们想要的。每一次循环都会从内存中重新 load run 的值。因此,当有其他进程修改 run 的值为 0 的时候,foo() 可以正常退出循环。为什么加入 barrier() 后的汇编代码就是正确的呢?因为 barrier() 作用是告诉 compiler 内存中的值已经变化,后面的操作都需要重新从内存 load,而不能使用寄存器缓存的值。因此,这里的 run 变量会从内存重新 load,然后判断循环条件。这样,其他进程修改 run 变量,foo() 就可以看得见了。
在 Linux kernel 中,提供了 cpu_relax() 函数,该函数在 ARM64 平台定义如下:
我们可以看出,cpu_relax() 是在 barrier() 的基础上又插入一条汇编指令 yield。在 kernel 中,我们经常会看到一些类似上面举例的 while 循环,循环条件是个全局变量。为了避免上述所说问题,我们就会在循环中插入 cpu_relax() 调用。
当然也可以使用 Linux 提供的 READ_ONCE()。例如,下面的修改也同样可以达到我们预期的效果。
当然你也可以修改 run 的定义为 volatile int run,就会得到如下代码。同样可以达到预期目的。
二、同步机制的起源
在深入探讨 Linux 内核同步机制之前,我们先来理解一下并发(Concurrency)与竞态(Race Condition)的概念,因为它们是同步机制存在的根本原因。
2.1 并发的多种形式
并发,简单来说,就是指多个执行单元同时、并行地被执行 。在 Linux 系统中,并发主要有以下几种场景:
SMP 多 CPU:对称多处理器(SMP)是一种紧耦合、共享存储的系统模型,多个 CPU 使用共同的系统总线,可以访问共同的外设和存储器 。在这种情况下,两个 CPU 之间的进程、中断都有并发的可能性。例如,CPU0 上的进程 A 和 CPU1 上的进程 B 可能同时访问共享内存中的同一数据。
单 CPU 内进程与抢占进程:在单个 CPU 中,虽然同一时刻只能有一个进程在运行,但进程的执行可能会被打断。比如,一个进程在执行过程中,可能会因为时间片耗尽,或者被另一个高优先级的进程抢占。当高优先级进程与被打断的进程共同访问共享资源时,就可能产生竞态。比如进程 A 正在访问一个全局变量,还没来得及修改完,就被进程 B 抢占,进程 B 也对这个全局变量进行访问和修改,就可能导致数据混乱。
中断与进程:中断可以打断正在执行的进程 。如果中断服务程序也访问进程正在访问的共享资源,就很容易产生竞态。比如,进程正在向串口发送数据,这时一个中断发生,中断服务程序也尝试向串口发送数据,就会导致串口数据发送错误。
2.2 竞态带来的问题
当多个并发执行单元访问共享资源时,竞态就可能出现。竞态会导致程序出现不可预测的行为,比如数据不一致、程序崩溃等 。我们来看一个简单的例子,假设有两个进程 P1 和 P2,它们都要对一个共享变量 count 进行加 1 操作。代码可能如下:
如果这两个进程并发执行,正常情况下,count 最终的值应该是 2。但由于竞态的存在,可能会出现以下情况:
进程 P1 读取 count 的值,此时 temp 为 0。进程 P2 读取 count 的值,此时 temp 也为 0,因为 P1 还没有将修改后的值写回 count。进程 P1 对 temp加 1,然后将 temp 的值写回 count,此时 count 为 1。进程P2对temp加1(此时 temp 还是 0),然后将temp的值写回 count,此时 count 还是 1,而不是 2。这就是竞态导致的数据错误。在实际的 Linux 内核中,共享资源可能是硬件设备、全局变量、文件系统等,竞态带来的问题会更加复杂和严重,可能导致系统不稳定、数据丢失等问题 。因此,为了保证系统的正确性和稳定性,Linux 内核需要一套有效的同步机制来解决竞态问题。
三、常见同步机制解析
为了解决并发与竞态问题,Linux 内核提供了多种同步机制 ,每种机制都有其独特的工作原理和适用场景。下面我们来详细了解一下这些同步机制。
3.1 自旋锁(Spinlocks)
自旋锁是一种比较简单的同步机制 。当一个线程尝试获取自旋锁时,如果锁已经被其他线程持有,那么该线程不会进入阻塞状态,而是在原地不断地循环检查锁是否可用,这个过程就叫做 “自旋” 。就好像你去餐厅吃饭,发现你喜欢的那桌还被别人占着,你又特别想坐那桌,于是你就站在旁边一直盯着,等那桌人吃完离开,你马上就能坐过去,这个一直盯着等待的过程就类似自旋。
自旋锁适用于锁持有时间非常短的场景 ,因为它避免了线程上下文切换的开销。在多处理器系统中,当一个线程在自旋等待锁时,其他处理器核心可以继续执行其他任务,不会因为线程阻塞而导致 CPU 资源浪费 。比如在一些对共享硬件资源的短时间访问场景中,自旋锁就非常适用。假设多个线程需要访问共享的网卡设备寄存器,对寄存器的操作通常非常快,使用自旋锁可以让线程快速获取锁并完成操作,避免了线程上下文切换带来的开销。
自旋锁也有其局限性。如果锁持有时间较长,线程会一直自旋,不断消耗 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上软中断或中断对共享资源的非同步访问。
3.2 互斥锁(Mutexes)
互斥锁,也叫互斥量 ,是一种用于实现线程间互斥访问的同步机制 。它的工作原理是,当一个线程获取到互斥锁后,其他线程如果也尝试获取该锁,就会被阻塞,直到持有锁的线程释放锁 。这就好比一个公共卫生间,一次只能允许一个人使用,当有人进入卫生间并锁上门后,其他人就只能在外面排队等待,直到里面的人出来打开门,外面的人才有机会进去使用。
与自旋锁不同,互斥锁适用于那些可能会阻塞很长时间的场景 。当线程获取不到锁时,它会被操作系统挂起,让出 CPU 资源,不会像自旋锁那样一直占用 CPU 进行无效的等待 。在涉及大量计算或者 IO 操作的代码段中,使用互斥锁可以避免 CPU 资源的浪费。比如在数据库操作中,一个线程需要长时间占用数据库连接执行复杂的查询或者事务操作,这时使用互斥锁来保护数据库连接资源,其他线程在获取不到锁时会被阻塞,直到当前线程完成数据库操作并释放锁,这样可以有效地管理资源,提高系统的整体性能。
3.3 读写锁(Read-Write Locks)
读写锁是一种特殊的同步机制,它允许多个线程同时进行读操作,但只允许一个线程进行写操作 。当有线程正在进行写操作时,其他线程无论是读操作还是写操作都将被阻塞,直到写操作完成并释放锁 。这就像图书馆的一本热门书籍,很多人可以同时阅读这本书,但如果有人要对这本书进行修改(比如添加批注或者修正错误),就必须先独占这本书,其他人在修改期间不能阅读也不能修改,直到修改完成。
读写锁的优势在于它能显著提高并发性能,特别是在读取频繁而写入较少的场景中 。在一个在线商城系统中,商品信息的展示(读操作)非常频繁,而商品信息的更新(写操作)相对较少。使用读写锁,多个用户可以同时读取商品信息,而当商家需要更新商品信息时,只需要获取写锁,保证写操作的原子性和数据一致性,这样可以大大提高系统的并发处理能力,提升用户体验。
读写信号量的相关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的访问。
3.4 信号量(Semaphores)
信号量是一个整数值,它可以用来控制对共享资源的访问 。信号量主要有两个作用:一是实现互斥,二是控制并发访问的数量 。信号量内部维护一个计数器,当线程请求访问共享资源时,会尝试获取信号量,如果计数器大于 0,则线程可以获取信号量并继续执行,同时计数器减一;如果计数器为 0,则线程会被阻塞,直到有其他线程释放信号量,使得计数器增加 。这就像一个停车场,停车场有一定数量的停车位(信号量的初始值),每辆车进入停车场(线程请求资源)时,会占用一个停车位,停车位数量减一,如果停车位满了(计数器为 0),新的车辆就只能在外面等待,直到有车辆离开停车场(线程释放资源),停车位数量增加,等待的车辆才有机会进入。
在限制线程访问文件资源数量的场景中,信号量就非常有用 。假设一个系统中,同时只允许5个线程对某个文件进行读写操作,我们可以创建一个初始值为5的信号量 。每个线程在访问文件前,先获取信号量,如果获取成功则可以访问文件,同时信号量的计数器减一;当线程完成文件访问后,释放信号量,计数器加一。这样就可以有效地控制同时访问文件的线程数量,避免资源的过度竞争和冲突 。
信号量的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进行保护。
3.5 原子操作(Atomic Operations)
原子操作是指那些不可被中断的操作 ,即它们的执行是一个完整的、不可分割的单元,不会被其他任务或事件打断 。在多线程编程中,原子操作可以保证对共享资源的访问是线程安全的,避免了竞态条件的发生 。例如,在实现资源计数和引用计数方面,原子操作就发挥着重要作用 。
假设有一个共享资源,多个线程可能会对其引用计数进行增加或减少操作,如果这些操作不是原子的,就可能会出现竞态条件,导致引用计数错误。而使用原子操作,就可以确保每次对引用计数的修改都是原子的,不会受到其他线程的干扰,从而保证了资源计数的准确性和一致性 。在 C 语言中,可以使用atomic库来实现原子操作 ,比如atomic_fetch_add函数可以原子地对一个变量进行加法操作 。原子类型定义如下:
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实现)。
四、同步机制的选择与应用场景
在Linux内核的实际应用中,选择合适的同步机制至关重要,这就如同在不同的路况下选择合适的交通工具一样 。不同的同步机制适用于不同的场景,我们需要根据具体的需求和条件来做出决策。
自旋锁由于其自旋等待的特性,适合用于临界区执行时间非常短且竞争不激烈的场景 。在多核处理器系统中,当线程对共享资源的访问时间极短,如对一些硬件寄存器的快速读写操作,使用自旋锁可以避免线程上下文切换的开销,提高系统的响应速度 。因为线程在自旋等待时,虽然会占用 CPU 资源,但由于临界区执行时间短,很快就能获取锁并完成操作,相比于线程上下文切换的开销,这种自旋等待的成本是可以接受的。如果临界区执行时间较长,线程长时间自旋会浪费大量的 CPU 资源,导致系统性能下降,所以自旋锁不适合长时间持有锁的场景 。
互斥锁则适用于临界区可能会阻塞很长时间的场景 。当涉及到大量的计算、IO 操作或者需要等待外部资源时,使用互斥锁可以让线程在获取不到锁时进入阻塞状态,让出 CPU 资源给其他线程,避免 CPU 资源的浪费 。在一个网络服务器中,当线程需要从网络中读取大量数据或者向数据库写入数据时,这些操作通常会花费较长的时间,此时使用互斥锁来保护相关的资源,能够有效地管理线程的执行顺序,保证系统的稳定性 。因为在这种情况下,线程上下文切换的开销相对较小,而让线程阻塞等待可以避免 CPU 资源被无效占用,提高系统的整体效率 。
读写锁适用于读取频繁而写入较少的场景 。在一个实时监控系统中,大量的线程可能需要频繁读取监控数据,但只有少数线程会偶尔更新这些数据 。使用读写锁,多个读线程可以同时获取读锁,并发地读取数据,而写线程在需要更新数据时,获取写锁,独占资源进行写入操作,这样可以大大提高系统的并发性能 。因为读操作不会修改数据,所以多个读线程同时进行读操作不会产生数据冲突,而写操作则需要保证原子性和数据一致性,读写锁正好满足了这种需求 。
信号量则常用于控制对共享资源的访问数量 。在一个文件服务器中,为了避免过多的线程同时访问同一个文件导致文件系统负载过高,我们可以使用信号量来限制同时访问文件的线程数量 。通过设置信号量的初始值为允许同时访问的最大线程数,每个线程在访问文件前先获取信号量,访问完成后释放信号量,这样就可以有效地控制对文件资源的访问,保证系统的稳定性 。因为信号量的计数器可以精确地控制并发访问的数量,避免资源的过度竞争和冲突 。
五、实际案例分析
5.1 TCP 连接管理
在 Linux 内核的网络协议栈中,同步机制起着关键的作用 。以 TCP 协议的连接管理为例,当多个线程同时处理 TCP 连接的建立、断开和数据传输时,就需要使用同步机制来保证数据的一致性和操作的正确性 。在处理 TCP 连接请求时,可能会有多个线程同时接收到连接请求,这时候就需要使用自旋锁来快速地对共享的连接队列进行操作,确保每个连接请求都能被正确处理,避免出现重复处理或者数据混乱的情况 。
由于连接请求的处理通常非常快,使用自旋锁可以避免线程上下文切换的开销,提高系统的性能 。而在进行 TCP 数据传输时,由于数据传输可能会受到网络延迟等因素的影响,需要较长的时间,这时候就会使用互斥锁来保护数据缓冲区等共享资源,确保数据的正确读写 。因为在数据传输过程中,线程可能需要等待网络响应,使用互斥锁可以让线程在等待时进入阻塞状态,让出 CPU 资源,提高系统的整体效率 。
我们将创建一个简单的TCP连接请求处理程序,使用自旋锁保护共享的连接队列,代码实现示例:
5.2 文件读写操作
在文件系统中,同步机制也不可或缺 。以文件的读写操作为例,当多个进程同时对一个文件进行读写时,就需要使用合适的同步机制 。对于文件的读取操作,由于读取操作不会修改文件内容,多个进程可以同时进行读取,这时候可以使用读写锁的读锁来提高并发性能 。而当有进程需要对文件进行写入操作时,为了保证数据的一致性,就需要获取读写锁的写锁,独占文件进行写入 。在文件系统的元数据管理中,如文件的创建、删除和目录的遍历等操作,由于这些操作涉及到对文件系统关键数据结构的修改,需要保证原子性和一致性,通常会使用互斥锁来保护相关的操作 。因为这些操作可能会涉及到复杂的文件系统操作和磁盘 IO,使用互斥锁可以有效地管理线程的执行顺序,避免出现数据不一致的情况 。
接下来是一个简化版的文件读写操作示例,使用互斥锁和读写锁来确保线程安全,代码实现示例:
通过这以上两个简单示例,可以看到在Linux内核中如何应用不同的同步机制来管理资源竞争,以提高性能和数据一致性。