2024年3月9日发(作者:)

Linux 同步方法剖析 内核原子,自旋锁和互斥锁

你也许接触过并发(concurrency)、临界段(critical section)和锁定,不过怎么在内核中使用这

些概念呢?本文讨论了 2.6 版内核中可用的锁定机制,包括原子运算符(atomic operator)、自旋锁

(spinlock)、读/写锁(reader/writer lock)和内核信号量(kernel semaphore)。 本文还探讨了每种

机制最适合应用到哪些地方,以构建安全高效的内核代码。

本文讨论了 Linux 内核中可用的大量同步或锁定机制。这些机制为 2.6.23 版内核的许多可用方法提供了

应用程式接口(API)。不过在深入学习 API 之前,首先需要明白将要解决的问题。

并发和锁定

当存在并发特性时,必须使用同步方法。当在同一时间段出现两个或更多进程并且这些进程彼此交互

(例如,共享相同的资源)时,就存在并发 现象。

在单处理器(uniprocessor,UP)主机上可能发生并发,在这种主机中多个线程共享同一个 CPU 并且抢占

(preemption)创建竞态条件。抢占 通过临时中断一个线程以执行另一个线程的方式来实现 CPU 共享。

竞态条件 发生在两个或更多线程操纵一个共享数据项时,其结果取决于执行的时间。在多处理器(MP)计

算机中也存在并发,其中每个处理器中共享相同数据的线程同时执 行。注意在 MP 情况下存在真正的并行

(parallelism),因为线程是同时执行的。而在 UP 情形中,并行是通过抢占创建的。两种模式中实现并

发都较为困难。

Linux 内核在两种模式中都支持并发。内核本身是动态的,而且有许多创建竞态条件的方法。Linux 内核

也支持多处理(multiprocessing),称为对称多处理(SMP)。

临界段概念是为解决竞态条件问题而产生的。一个临界段 是一段不允许多路访问的受保护的代码。

这段代码能操纵共享数据或共享服务(例如硬件外围设备)。临界段操作时坚持互斥锁(mutual exclusion)

原则(当一个线程处于临界段中时,其他所有线程都不能进入临界段)。

临界段中需要解决的一个问题是死锁条件。考虑两个独立的临界段,各自保护不同的资源。每个资源拥有

一个锁,在本例中称为 A 和 B。假设有两个线程需要访问这些资源,线程 X 获取了锁 A,线程 Y 获取了

锁 B。当这些锁都被持有时,每个线程都试图占有其他线程当前持有的锁(线程 X 想要锁 B,线程 Y 想

要锁 A)。这时候线程就被死锁了,因为他们都持有一个锁而且还想要其他锁。一个简单的解决方案就是总

是按相同次序获取锁,从而使其中一个线程得以完成。还需要 其他解决方案检测这种情形。表 1 定义了

此处用到的一些重要的并发术语。

表 1. 并发中的重要定义

术语

竞态条件

定义

两个或更多线程同时操作资源时将会导致不一致

的结果

临界段

互斥锁

死锁

用于协调对共享资源的访问的代码段

确保对共享资源进行排他访问的软件特性

由两个或更多进程和资源锁导致的一种特别情形,

将会降低进程的工作效率

Linux 同步方法

如果你了解了一些基本理论并且明白了需要解决的问题,接下来将学习 Linux 支持并发和互斥锁的各种方

法。在以前,互斥锁是通过禁用中断来提供的,不过这种形式的锁定效率比较低(目前在内核中仍然存在

这种用法)。这种方法也不能进 行扩展,而且不能确保其他处理器上的互斥锁。

在以下关于锁定机制的讨论中,我们首先看一下原子运算符,他能保护简单变量(计数器和位掩码

(bitmask))。然后介绍简单的自旋锁和读/写锁,他们 构成了一个 SMP 架构的忙等待锁(busy-wait lock)

覆盖。最后,我们讨论构建在原子 API 上的内核互斥锁。

原子操作

Linux 中最简单的同步方法就是原子操作。原子 意味着临界段被包含在 API 函数中。不必额外的锁定,

因为 API 函数已包含了锁定。由于 C 不能实现原子操作,因此 Linux 依靠底层架构来提供这项功能。各

种底层架构存在非常大差异,因此原子函数的实现方法也各不相同。一些方法完全通过汇编语言来实现,

而另一些方法依靠 c 语言并且使用 local_irq_save 和 local_irq_restore 禁用中断。

旧的锁定方法

在内核中实现锁定的一种不太好的方法是通过禁用本地 CPU 的硬中断。这些函数均可用并且仍得到

使用(有时用于原子运算符),但我们并不推荐使用。local_irq_save 例程禁用中断,而 local_irq_restore

恢复以前启用过的中断。这些例程都是可重入的(reentrant),也就是说他们能在其他例程上下文中被调

用。

当需要保护的数据非常简单时,例如一个计数器,原子运算符是种最佳的方法。尽管原理简单,原子 API 提

供了许多针对不同情形的运算符。下面是个使用此 API 的示例。

要声明一个原子变量(atomic variable),首先声明一个 atomic_t 类型的变量。这个结构包含了单个 int

元素。接下来,需确保你的原子变量使用 ATOMIC_INIT 符号常量进行了初始化。 在清单 1 的情形中,原

子计数器被设置为 0。也能使用 atomic_set function 在运行时对原子变量进行初始化。

清单 1. 创建和初始化原子变量

atomic_t my_counter ATOMIC_INIT(0);

... or ...

atomic_set( &my_counter, 0 );

原子 API 支持一个涵盖许多用例的富函数集。能使用 atomic_read 读取原子变量中的内容,也能使用

atomic_add 为一个变量添加指定值。最常用的操作是使用 atomic_inc 使变量递增。也可用减号运算符,

他的作用和相加和递增操作相反。清单 2. 演示了这些函数。

清单 2. 简单的算术原子函数

val = atomic_read( &my_counter );

atomic_add( 1, &my_counter );

atomic_inc( &my_counter );

atomic_sub( 1, &my_counter );

atomic_dec( &my_counter );

该 API 也支持许多其他常用用例,包括 operate-and-test 例程。这些例程允许对原子变量进行操纵和测

试(作为一个原子操作来执行)。一个叫做 atomic_add_negative 的特别函数被添加到原子变量中,然后

当结果值为负数时返回真(true)。这被内核中一些依赖于架构的信号量函数使用。

许多函数都不返回变量的值,但两个函数除外。他们会返回结果值( atomic_add_return 和

atomic_sub_return),如清单 3所示。

清单 3. Operate-and-test 原子函数

if (atomic_sub_and_test( 1, &my_counter )) {

// my_counter is zero

}