标签:信号 调用 network sys 构建 www tasklet 全球 系统
在学习 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)。
能够在本文后面的 參考资料 部分学到很多其它关于 SMP 的知识。
临界段概念是为解决竞态条件问题而产生的。
一个临界段 是一段不同意多路訪问的受保护的代码。
这段代码能够操纵共享数据或共享服务(比如硬件外围设备)。
临界段操作时坚持相互排斥锁(mutual exclusion)原则(当一个线程处于临界段中时,其它全部线程都不能进入临界段)。
临界段中须要解决的一个问题是死锁条件。考虑两个独立的临界段,各自保护不同的资源。每一个资源拥有一个锁,在本例中称为 A 和 B。如果有两个线程须要訪问这些资源,线程 X 获取了锁 A,线程 Y 获取了锁 B。
当这些锁都被持有时。每一个线程都试图占有其它线程当前持有的锁(线程 X 想要锁 B。线程 Y 想要锁 A)。
这时候线程就被死锁了。由于它们都持有一个锁并且还想要其它锁。一个简单的解决方式就是总是按同样次序获取锁。从而使当中一个线程得以完毕。还须要其它解决方式检測这样的情形。表 1 定义了此处用到的一些重要的并发术语。
术语 | 定义 |
---|---|
竞态条件 | 两个或很多其它线程同一时候操作资源时将会导致不一致的结果。 |
临界段 | 用于协调对共享资源的訪问的代码段。 |
相互排斥锁 | 确保对共享资源进行排他訪问的软件特性。 |
死锁 | 由两个或很多其它进程和资源锁导致的一种特殊情形。将会减少进程的工作效率。 |
|
假设您了解了一些基本理论并且明确了须要解决的问题。接下来将学习 Linux 支持并发和相互排斥锁的各种方法。在曾经,相互排斥锁是通过禁用中断来提供的,可是这样的形式的锁定效率比較低(如今在内核中仍然存在这样的使用方法)。
这样的方法也不能进行扩展,并且不能保证其它处理器上的相互排斥锁。
在下面关于锁定机制的讨论中,我们首先看一下原子运算符,它能够保护简单变量(计数器和位掩码(bitmask))。
然后介绍简单的自旋锁和读/写锁,它们构成了一个 SMP 架构的忙等待锁(busy-wait lock)覆盖。最后。我们讨论构建在原子 API 上的内核相互排斥锁。
|
Linux 中最简单的同步方法就是原子操作。原子 意味着临界段被包括在 API 函数中。不须要额外的锁定,由于 API 函数已经包括了锁定。由于 C 不能实现原子操作,因此 Linux 依靠底层架构来提供这项功能。各种底层架构存在非常大差异,因此原子函数的实现方法也各不同样。一些方法全然通过汇编语言来实现,而还有一些方法依靠 c 语言而且使用 local_irq_save
和 local_irq_restore
禁用中断。
|
当须要保护的数据很easy时。比如一个计数器,原子运算符是种理想的方法。虽然原理简单,原子 API 提供了很多针对不同情形的运算符。以下是一个使用此 API 的演示样例。
要声明一个原子变量(atomic variable),首先声明一个 atomic_t
类型的变量。
这个结构包括了单个 int
元素。接下来,需确保您的原子变量使用 ATOMIC_INIT
符号常量进行了初始化。 在清单 1 的情形中,原子计数器被设置为 0。也能够使用 atomic_set function
在执行时对原子变量进行初始化。
atomic_t my_counter ATOMIC_INIT(0);
... or ...
atomic_set( &my_counter, 0 );
|
原子 API 支持一个涵盖很多用例的富函数集。能够使用 atomic_read
读取原子变量中的内容,也能够使用 atomic_add
为一个变量加入指定值。最经常使用的操作是使用 atomic_inc
使变量递增。
也可用减号运算符,它的作用与相加和递增操作相反。
清单 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所看到的。
if (atomic_sub_and_test( 1, &my_counter )) { // my_counter is zero } if (atomic_dec_and_test( &my_counter )) { // my_counter is zero } if (atomic_inc_and_test( &my_counter )) { // my_counter is zero } if (atomic_add_negative( 1, &my_counter )) { // my_counter is less than zero } val = atomic_add_return( 1, &my_counter )); val = atomic_sub_return( 1, &my_counter )); |
假设您的架构支持 64 位长类型(BITS_PER_LONG
是 64 的),那么能够使用 long_t atomic
操作。能够在 linux/include/asm-generic/atomic.h 中查看可用的长操作(long operation)。
原子 API 还支持位掩码(bitmask)操作。跟前面提到的算术操作不一样,它仅仅包括设置和清除操作。很多驱动程序使用这些原子操作,特别是 SCSI。位掩码原子操作的使用跟算术操作存在细微的区别。由于当中仅仅有两个可用的操作(设置掩码和清除掩码)。使用这些操作前,须要提供一个值和将要进行操作的位掩码,如清单 4 所看到的。
unsigned long my_bitmask; atomic_clear_mask( 0, &my_bitmask ); atomic_set_mask( (1<<24), &my_bitmask ); |
|
自旋锁是使用忙等待锁来确保相互排斥锁的一种特殊方法。假设锁可用。则获取锁,运行相互排斥锁动作,然后释放锁。
假设锁不可用,线程将忙等待该锁。直到其可用为止。忙等待看起来效率低下,但它实际上比将线程休眠然后当锁可用时将其唤醒要快得多。
自旋锁仅仅在 SMP 系统中才实用。可是由于您的代码终于将会在 SMP 系统上执行。将它们加入到 UP 系统是个明智的做法。
自旋锁有两种可用的形式:全然锁(full lock)和读写锁。 首先看一下全然锁。
首先通过一个简单的声明创建一个新的自旋锁。这能够通过调用 spin_lock_init
进行初始化。清单 5 中显示的每一个变量都会实现同样的结果。
spinlock_t my_spinlock = SPIN_LOCK_UNLOCKED; ... or ... DEFINE_SPINLOCK( my_spinlock ); ... or ... spin_lock_init( &my_spinlock ); |
定义了自旋锁之后,就能够使用大量的锁定变量了。
每一个变量用于不同的上下文。
清单 6 中显示了 spin_lock
和 spin_unlock
变量。这是一个最简单的变量,它不会运行中断禁用,可是包括所有的内存壁垒(memory barrier)。
这个变量假定中断处理程序和该锁之间没有交互。
spin_lock( &my_spinlock ); // critical section spin_unlock( &my_spinlock ); |
接下来是 irqsave
和 irqrestore
对,如清单 7 所看到的。
spin_lock_irqsave
函数须要自旋锁,而且在本地处理器(在 SMP 情形中)上禁用中断。spin_unlock_irqrestore
函数释放自旋锁,而且(通过 flags
參数)恢复中断。
spin_lock_irqsave( &my_spinlock, flags ); // critical section spin_unlock_irqrestore( &my_spinlock, flags ); |
spin_lock_irqsave
/spin_unlock_irqrestore
的一个不太安全的变体是 spin_lock_irq
/spin_unlock_irq
。 我建议不要使用此变体。由于它会如果中断状态。
最后,假设内核线程通过 bottom half 方式共享数据,那么能够使用自旋锁的还有一个变体。
bottom half 方法能够将设备驱动程序中的工作延迟到中断处理后执行。这样的自旋锁禁用了本地 CPU 上的软中断。这能够阻止 softirq、tasklet 和 bottom half 在本地 CPU 上执行。这个变体如清单 8 所看到的。
spin_lock_bh( &my_spinlock ); // critical section spin_unlock_bh( &my_spinlock ); |
|
在很多情形下,对数据的訪问是由大量的读和少量的写操作来完毕的(读取数据比写入数据更常见)。读/写锁的创建就是为了支持这样的模型。这个模型有趣的地方在于同意多个线程同一时候訪问同样数据。但同一时刻仅仅同意一个线程写入数据。
假设运行写操作的线程持有此锁,则临界段不能由其它线程读取。假设一个运行读操作的线程持有此锁,那么多个读线程都能够进入临界段。
清单 9 演示了这个模型。
rwlock_t my_rwlock; rwlock_init( &my_rwlock ); write_lock( &my_rwlock ); // critical section -- can read and write write_unlock( &my_rwlock ); read_lock( &my_rwlock ); // critical section -- can read only read_unlock( &my_rwlock ); |
依据对锁的需求,还针对 bottom half 和中断请求(IRQ)对读/写自旋锁进行了改动。显然,假设您使用的是原版的读/写锁,那么依照标准自旋锁的使用方法使用这个自旋锁。而不区分读线程和写线程。
|
在内核中能够使用相互排斥锁来实现信号量行为。内核相互排斥锁是在原子 API 之上实现的。但这对于内核用户是不可见的。相互排斥锁非常easy。可是有一些规则必须牢记。同一时间仅仅能有一个任务持有相互排斥锁。而且仅仅有这个任务能够对相互排斥锁进行解锁。相互排斥锁不能进行递归锁定或解锁。而且相互排斥锁可能不能用于交互上下文。可是相互排斥锁比当前的内核信号量选项更快,而且更加紧凑,因此假设它们满足您的需求。那么它们将是您明智的选择。
能够通过 DEFINE_MUTEX
宏使用一个操作创建和初始化相互排斥锁。这将创建一个新的相互排斥锁并初始化其结构。能够在 ./linux/include/linux/mutex.h 中查看该实现。
DEFINE_MUTEX( my_mutex );
|
相互排斥锁 API 提供了 5 个函数:当中 3 个用于锁定。一个用于解锁。还有一个用于測试相互排斥锁。首先看一下锁定函数。
在须要马上锁定以及希望在相互排斥锁不可用时掌握控制的情形下。能够使用第一个函数 mutex_trylock
。该函数如清单 10 所看到的。
mutex_trylock
获得相互排斥锁
ret = mutex_trylock( &my_mutex );
if (ret != 0) {
// Got the lock!
} else {
// Did not get the lock
}
|
假设想等待这个锁。能够调用 mutex_lock
。这个调用在相互排斥锁可用时返回,否则,在相互排斥锁锁可用之前它将休眠。
不管在哪种情形中。当控制被返回时,调用者将持有相互排斥锁。最后,当调用者休眠时使用 mutex_lock_interruptible
。
在这样的情况下,该函数可能返回 -EINTR
。
清单 11 中显示了这两种调用。
mutex_lock( &my_mutex ); // Lock is now held by the caller. if (mutex_lock_interruptible( &my_mutex ) != 0) { // Interrupted by a signal, no mutex held } |
当一个相互排斥锁被锁定后,它必须被解锁。这是由 mutex_unlock
函数来完毕的。这个函数不能从中断上下文调用。
最后。能够通过调用 mutex_is_locked
检查相互排斥锁的状态。
这个调用实际上编译成一个内联函数。假设相互排斥锁被持有(锁定),那么就会返回 1;否则。返回 0。清单 12 演示了这些函数。
mutex_is_locked
測试相互排斥锁锁 mutex_unlock( &my_mutex ); if (mutex_is_locked( &my_mutex ) == 0) { // Mutex is unlocked } |
相互排斥锁 API 存在着自身的局限性,由于它是基于原子 API 的。
可是其效率比較高,假设能满足你的须要,还是能够使用的。
|
最后看一下大内核锁(BLK)。它在内核中的用途越来越小,可是仍然有一些保留下来的使用方法。BKL 使多处理器 Linux 成为可能。可是细粒度(finer-grained)锁正在慢慢代替 BKL。BKL 通过 lock_kernel
和 unlock_kernel
函数提供。要获得很多其它信息。请查看 ./linux/lib/kernel_lock.c。
|
|
Linux 性能非凡,其锁定方法也一样。
原子锁不仅提供了一种锁定机制。同一时候也提供了算术或 bitwise 操作。
自旋锁提供了一种锁定机制(主要应用于 SMP),并且读/写自旋锁同意多个读线程且仅有一个写线程获得给定的锁。
最后。相互排斥锁是一种新的锁定机制,提供了一种构建在原子之上的简单 API。无论你须要什么,Linux 都会提供一种锁定方案保护您的数据。
M. Tim Jones 是一名嵌入式软件project师,他是 GNU/Linux Application Programming、AI Application Programming 以及 BSD Sockets Programming from a Multilanguage Perspective 等书的作者。 他的project背景很广泛。从同步宇宙飞船的内核开发到嵌入式架构设计,再到网络协议的开发。Tim 是位于科罗拉多州 Longmont 的 Emulex Corp. 的一名顾问project师。 |