天天看点

kernel并发控制:自旋锁、互斥体、中断屏蔽

1. 中断屏蔽(关中断)

在单 CPU 范围内避免竞态的一种简单方法是在进入临界区之前屏蔽系统的中断。

CPU 一般都具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生。具体而言,中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于 Linux 内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也就得以避免了。

由于 Linux 系统的异步 I/O、进程调度等很多重要操作都依赖于中断,中断对于内核的运行非常重要,在屏蔽中断期间所有的中断都无法得到处理,因此长时间屏蔽中断是很危险的,有可能造成数据丢失甚至系统崩溃。这就要求在屏蔽了中断之后,当前的内核执行路径应当尽快地执行完临界区的代码。

local_irq_disable()和 local_irq_enable()都只能禁止和使能本 CPU 内的中断, 因此,并不能解决 SMP 多 CPU 引发的竞态。因此,单独使用中断屏蔽通常不是一种值得推荐的避免竞态的方法,它适宜与自旋锁联合使用。

如果只是想禁止中断的底半部,应使用 local_bh_disable(), 使能底半部应该调用 local_bh_enable()。

2. 自旋锁

 自旋锁(spin lock)是一个典型的对临界资源的互斥手段,它的名称来源于它的特性。为了获得一个自旋锁,在某CPU上运行的代码需先执行一个原子操作,该操作测试并设置(test-and-set)某个内存变量,由于它是原子操作,所以在该操作完成之前其它CPU不可能访问这个内存变量。如果测试结果表明锁已经空闲,则程序获得这个自旋锁并继续执行。如果测试结果表明锁仍被占用,程序将在一个小的循环内重复这个“测试并设置(test-and-set)”操作,即开始“自旋”。最后,锁的所有者通过重置该变量释放这个自旋锁,于是,某个等待的test-and-set操作向其调用者报告锁已释放。

理解自旋锁最简单的方法是把它作为一个变量看待,这个变量把一个临界区或者标记为“我当前在另一个CPU上运行,请稍等一会”,或者标记为“我当前不在运行,可以被使用”。如果1号CPU首先进入该例程,它就获取该自旋锁;当2号CPU试图进入同一个例程时,该自旋锁告诉它自己已为1号CPU所持有,需等到1号CPU释放自己后才能进入。

自旋锁主要针对SMP或单CPU且内核可抢占的情况,对于单CPU且内核不可抢占的系统自旋锁退化为空操作。

尽管自旋锁可以保证临界区不受别的CPU和本CPU的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候还可能受到中断和底半部影响,此时应该使用 自旋锁的衍生操作。

驱动工程师应谨慎使用自旋锁, 而且在使用中还要特别注意如下几个问题。

1) 自旋锁实际上是忙等锁, 当锁不可用时, CPU一直循环执行“测试并设置”该锁直到可用而取得该锁, CPU在等待自旋锁时不做任何有用的工作, 仅仅是等待。 因此, 只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。 当临界区很大, 或有共享设备的时候, 需要较长时间占用锁, 使用自旋锁会降低系统的性能。

2) 自旋锁可能导致系统死锁。 引发这个问题最常见的情况是递归使用一个自旋锁, 即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁, 则该CPU将死锁。

3) 在自旋锁锁定期间不能调用可能引起进程调度的函数。 如果进程获得自旋锁之后再阻塞, 如调用copy_from_user() 、 copy_to_user() 、 kmalloc() 和msleep() 等函数, 则可能导致内核的崩溃。

4) 在单核情况下编程的时候, 也应该认为自己的CPU是多核的, 驱动特别强调跨平台的概念。 比如, 在单CPU的情况下, 若中断和进程可能访问同一临界区, 进程里调用spin_lock_irqsave() 是安全的, 在中断里其实不调用spin_lock() 也没有问题, 因为spin_lock_irqsave() 可以保证这个CPU的中断服务程序不可能执行。 但是, 若CPU变成多核, spin_lock_irqsave() 不能屏蔽另外一个核的中断, 所以另外一个核就可能造成并发问题。 因此, 无论如何, 我们在中断服务程序里也应该调用spin_lock() 。

3. 互斥体

互斥体实现了“互相排斥”(mutual exclusion)同步的简单形式(所以名为互斥体(mutex))。互斥体禁止多个线程同时进入受保护的代码“临界区”(critical section)。因此,在任意时刻,只有一个线程被允许进入这样的代码保护区。

任何线程在进入临界区之前,必须获取(acquire)与此区域相关联的互斥体的所有权。如果已有另一线程拥有了临界区的互斥体,其他线程就不能再进入其中。这些线程必须等待,直到当前的属主线程释放(release)该互斥体。

新的Linux内核倾向于直接使用互斥体,而不是信号量作为互斥。

4. 自旋锁和互斥体的区别

自旋锁和互斥锁都是解决互斥问题的基本手段,这两种所的区别:

1)互斥锁和自旋锁属于不同层次的互斥手段,前者的实现依赖于后者,在互斥体本身的实现上,为了保证互斥体结构存取的原子性,需要自旋锁来互斥,因此自旋锁属于更底层的操作。

2)互斥锁是进程级别的,用于对各进程之间对资源的互斥,虽然也在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺资源的,如果竞争失败,会发生进程上下文的切换,当前进程进入睡眠状态,CPU将运行于其他进程。由于进程上下文切换开销比较大,因此进程占用资源时间较长时用互斥锁才是比较好的选择。

3)当要保护的临界区访问时间很短时,用自旋锁是非常方便的,因为它可以节省上下文切换的开销。但是CPU如果得不到自旋锁会忙等执行临界区解锁为止,所以要求锁不能再临界区长时间停留。

由此, 可以总结出自旋锁和互斥体选用的3项原则。

1) 当锁不能被获取到时, 使用互斥体的开销是进程上下文切换时间, 使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定) 。 若临界区比较小, 宜使用自旋锁, 若临界区很大, 应使用互斥体。

2) 互斥体所保护的临界区可包含可能引起阻塞(或睡眠)的代码, 而自旋锁则绝对要避免用来保护包含这样代码的临界区。 

3) 互斥体存在于进程上下文, 因此, 如果被保护的共享资源需要在中断或软中断情况下使用, 则在互斥体和自旋锁之间只能选择自旋锁。 当然, 如果一定要使用互斥体, 则只能通过mutex_trylock() 方式进行, 不能获取就立即返回以避免阻塞。

Linux内核中解决并发控制的最常用方法是自旋锁与信号量(绝大多数时候作为互斥体使用)。

1)自旋锁与信号量"类似而不类",类似说的是它们功能上的相似性,"不类"指代它们在本质和实现机理上完全不一样,不属于一类。

2)自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环查看是否该自旋锁的保持者已经释放了锁,"自旋"就是"在原地打转"。而信号量则引起调用者睡眠,它把进程从运行队列上拖出去,除非获得锁。这就是它们的"不类"。

3)但是,无论是信号量,还是自旋锁,在任何时刻,最多只能有一个保持者,即在任何时刻最多只能有一个执行单元获得锁。这就是它们的"类似"。

4)鉴于自旋锁与信号量的上述特点,一般而言:

  • 自旋锁适合于保持时间非常短的情况,它可以在任何上下文使用;信号量适合于保持时间较长的情况,且只能在进程上下文使用。
  • 如果被保护的共享资源只在进程上下文访问,则可以以信号量来保护该共享资源,如果对共享资源的访问时间非常短,自旋锁也是好的选择。
  • 但是,如果被保护的共享资源需要在中断上下文访问(包括底半部、软中断),就必须使用自旋锁。

继续阅读