内核锁技术
适当的锁机制可能很困难,真的很难。不当的锁机制可能导致随机崩溃和其他奇怪现象。设计不良的锁机制可能导致代码难以阅读、性能低下,并让你的内核开发同事感到难堪。在本文中,我将解释为什么内核代码需要锁机制,提供正确内核锁语义的一般规则,然后概述 Linux 内核中的各种锁原语。
围绕锁机制的根本问题是在内核中的某些代码路径中需要提供同步。这些代码路径,称为临界区,需要某种程度的并发或重入保护,以及相对于其他事件的正确排序。没有适当锁机制的典型结果称为竞争条件。意识到即使是简单的 i++,如果 i 是共享的,也是危险的!考虑一下一个处理器读取 i,然后另一个处理器读取 i,然后它们都递增它,然后它们都将 i 写回内存的情况。如果 i 最初是 2,它现在应该是 4,但实际上它将是 3!
这并不是说唯一的锁问题来自 SMP(对称多处理)。中断处理程序会产生锁问题,新的可抢占内核以及任何可能阻塞(进入睡眠状态)的代码也是如此。在这些情况中,只有 SMP 被认为是真正的并发,即只有 SMP 才能使两件事在完全相同的时间实际发生。其他情况——中断处理程序、可抢占内核和阻塞方法——提供伪并发,因为代码实际上不是并发执行的,但单独的代码可能会破坏彼此的数据。
这些临界区需要锁机制。Linux 内核提供了一系列锁原语,开发人员可以使用它们来编写安全高效的代码。
无论您是否拥有 SMP 机器,使用您的代码的人都可能拥有。此外,通常不接受未正确处理锁问题的代码进入 Linux 内核。最后,对于可抢占内核,即使是 UP(单处理器)系统也需要适当的锁机制。因此,请不要忘记:您必须实现锁机制。
值得庆幸的是,Linus 做出了出色的设计决策,保持 SMP 和 UP 内核的区分。这允许某些锁在 UP 内核中根本不存在。CONFIG_SMP 和 CONFIG_PREEMPT 的不同组合编译到不同的锁支持中。然而,这对开发人员来说无关紧要:适当地锁定一切,所有情况都将得到覆盖。
我们最初介绍原子操作符有两个原因。首先,它们是内核同步方法中最简单的,因此最容易理解和使用。其次,复杂的锁原语是建立在它们之上的。从这个意义上说,它们是内核锁的构建块。原子操作符是像加法和减法这样的操作,它们在一个不可中断的操作中执行。考虑一下之前 i++ 的例子。如果我们可以在一个不可中断的操作中读取 i、递增它并将其写回内存,那么上面讨论的竞争条件就不会成为问题。原子操作符提供这些不可中断的操作。存在两种类型:操作整数的方法和操作位的方法。整数运算的工作方式如下
atomic_t v; atomic_set(&v, 5); /* v = 5 (atomically) */ atomic_add(3, &v); /* v = v + 3 (atomically) */ atomic_dec(&v); /* v = v - 1 (atomically) */ printf("This will print 7: %d\n", atomic_read(&v));
它们很简单。但是,在使用原子操作时,有一些小的注意事项要记住。首先,显然您不能将 atomic_t 传递给原子操作符之外的任何东西。同样,您不能将 atomic_t 之外的任何东西传递给原子操作符。最后,由于某些架构的限制,不要期望 atomic_t 拥有超过 24 个可用位。请参阅“函数参考”侧边栏,了解所有原子整数运算的列表。
下一组原子方法是那些操作单个位的方法。它们比整数方法更简单,因为它们适用于标准 C 数据类型。例如,考虑 void set_bit(int nr, void *addr)。此函数将原子地将 addr 指向的数据的“第 nr”位设置为 1。原子位操作符也列在“函数参考”侧边栏中。
对于比上面这些简单的例子更复杂的情况,需要更完整的锁解决方案。内核中最常见的锁原语是自旋锁,定义在 include/asm/spinlock.h 和 include/linux/spinlock.h 中。自旋锁是一个非常简单的单持有者锁。如果进程尝试获取自旋锁但它不可用,则进程将继续尝试(自旋),直到它可以获取锁。这种简单性创建了一个小而快速的锁。自旋锁的基本用法是
spinlock_t mr_lock = SPIN_LOCK_UNLOCKED; unsigned long flags; spin_lock_irqsave(&mr_lock, flags); /* critical section ... */ spin_unlock_irqrestore(&mr_lock, flags);
使用 spin_lock_irqsave() 将在本地禁用中断,并在 SMP 上提供自旋锁。这涵盖了中断和 SMP 并发问题。通过调用 spin_unlock_irqrestore(),中断将恢复到获取锁时的状态。对于 UP 内核,上面的代码编译为与以下代码相同
unsigned long flags; save_flags(flags); cli(); /* critical section ... */ restore_flags(flags);这将提供所需的中断并发保护,而无需不必要的 SMP 保护。自旋锁的另一个变体是 spin_lock_irq()。此变体无条件地禁用和重新启用中断,方式与 cli() 和 sti() 相同。例如
spinlock_t mr_lock = SPIN_LOCK_UNLOCKED; spin_lock_irq(&mr_lock); /* critical section ... */ spin_unlock_irq(&mr_lock);仅当您知道在获取锁之前中断尚未被禁用时,此代码才是安全的。随着内核规模的增长,内核代码路径变得越来越难以预测,建议您不要使用此版本,除非您真的知道自己在做什么。
以上所有自旋锁都假定您正在保护的数据在中断处理程序和正常的内核代码中都被访问。如果您知道您的数据对于用户上下文内核代码是唯一的(例如,系统调用),您可以使用基本的 spin_lock() 和 spin_unlock() 方法,它们获取和释放指定的锁,而无需与中断进行任何交互。
自旋锁的最后一个变体是 spin_lock_bh(),它实现了标准自旋锁,并禁用了软中断。当您的代码在软中断外部使用,也在软中断内部使用时,这是必需的。相应的解锁函数自然是 spin_unlock_bh()。
请注意,Linux 中的自旋锁不是递归的,这与其他操作系统中的自旋锁可能不同。大多数人认为这是一个明智的设计决策,因为递归自旋锁会鼓励编写糟糕的代码。然而,这确实意味着您必须小心不要重新获取您已经持有的自旋锁,否则您将死锁。
自旋锁应该用于在锁持有时间不长的情况下锁定数据——回想一下,等待进程将自旋,无所事事,等待锁。(有关被认为是长时间的指南,请参阅“规则”侧边栏。)值得庆幸的是,自旋锁可以在任何地方使用。但是,您不能在持有自旋锁时执行任何会导致睡眠的操作。例如,在持有自旋锁时,永远不要调用任何访问用户内存的函数、带有 GFP_KERNEL 标志的 kmalloc()、任何信号量函数或任何调度函数。您已被警告。
如果您需要一个可以安全地长时间持有、可以安全地睡眠或能够允许多个进程同时执行并发的锁,Linux 提供了信号量。
Linux 中的信号量是睡眠锁。因为它们会导致任务在争用时睡眠,而不是自旋,所以它们用于锁持有时间可能很长的情况。相反,由于它们具有使任务进入睡眠状态然后唤醒它的开销,因此不应在锁持有时间短的情况下使用它们。但是,由于它们会睡眠,因此可以用于同步用户上下文,而自旋锁则不能。换句话说,在持有信号量时阻塞是安全的。
在 Linux 中,信号量由一个结构 struct semaphore 表示,该结构定义在 include/asm/semaphore.h 中。该结构包含一个指向等待队列的指针和一个使用计数。等待队列是阻塞在信号量上的进程列表。使用计数是允许的并发持有者的数量。如果它是负数,则信号量不可用,并且使用计数的绝对值是等待队列中阻塞的进程数。使用计数在运行时通过 sema_init() 初始化,通常为 1(在这种情况下,信号量称为互斥锁)。
信号量通过两种方法操作:down(历史上为 P)和 up(历史上为 V)。前者尝试获取信号量,如果失败则阻塞。后者释放信号量,唤醒沿途阻塞的任何任务。
信号量在 Linux 中的使用很简单。要尝试获取信号量,请调用 down_interruptible() 函数。此函数递减信号量的使用计数。如果新值小于零,则调用进程将添加到等待队列并被阻塞。如果新值大于或等于零,则进程获取信号量,调用返回 0。如果在阻塞时收到信号,则调用返回 -EINTR,并且未获取信号量。
用于释放信号量的 up() 函数递增使用计数。如果新值大于或等于零,则等待队列上的一个或多个任务将被唤醒
struct semaphore mr_sem; sema_init(&mr_sem, 1); /* usage count is 1 */ if (down_interruptible(&mr_sem)) /* semaphore not acquired; received a signal ... */ /* critical region (semaphore acquired) ... */ up(&mr_sem);
Linux 内核还提供了 down() 函数,该函数的不同之处在于它使调用任务进入不可中断的睡眠状态。进程在不可中断的睡眠状态下收到的信号将被忽略。通常,开发人员希望使用 down_interruptible()。最后,Linux 提供了 down_trylock() 函数,该函数尝试获取给定的信号量。如果调用失败,down_trylock() 将返回非零值而不是阻塞。
除了标准的自旋锁和信号量实现之外,Linux 内核还提供了读写变体,将锁的使用分为两组:读取和写入。由于通常允许多个线程并发读取数据是安全的,只要没有任何东西修改数据,读写锁就允许多个并发读取器,但只允许单个写入器(没有并发读取器)。如果您的数据访问自然地分为清晰的读取和写入模式,特别是读取量大于写入量,则通常首选读写锁。
读写自旋锁称为 rwlock,其使用方式与标准自旋锁类似,但reader/writer锁机制除外
rwlock_t mr_rwlock = RW_LOCK_UNLOCKED; read_lock(&mr_rwlock); /* critical section (read only) ... */ read_unlock(&mr_rwlock); write_lock(&mr_rwlock); /* critical section (read and write) ... */ write_unlock(&mr_rwlock);
同样,读写信号量称为 rw_semaphore,其使用方法与标准信号量相同,加上显式的读写锁机制
struct rw_semaphore mr_rwsem; init_rwsem(&mr_rwsem); down_read(&mr_rwsem); /* critical region (read only) ... */ up_read(&mr_rwsem); down_write(&mr_rwsem); /* critical region (read and write) ... */ up_write(&mr_rwsem);在适当的情况下使用读写锁是一种可观的优化。但是请注意,与其他实现不同,读取器锁不能自动升级到写入器变体。因此,在持有读取器访问权限时尝试获取独占访问权限将导致死锁。通常,如果您知道最终需要写入,请从一开始就获取锁的写入器变体。否则,您将需要释放读取器锁并重新获取作为写入器的锁。如果编写和读取代码之间的区别像这样模糊不清,则可能表明读写锁不是最佳选择。
大读取器锁 (brlocks) 在 include/linux/brlock.h 中定义,是读写锁的一种特殊形式。大读取器锁由 Red Hat 的 Ingo Molnar 设计,提供了一种自旋锁,该自旋锁对于读取的获取速度非常快,但对于写入的获取速度却非常慢。因此,它们非常适合读取器多而写入器少的情况。
虽然 brlocks 的行为与 rwlocks 的行为不同,但它们的用法是相同的,唯一的例外是 brlocks 在 brlock_indices 中预定义(请参阅 brlock.h)
br_read_lock(BR_MR_LOCK); /* critical region (read only) ... */ br_read_unlock(BR_MR_LOCK);
brlocks 的使用目前仅限于少数特殊情况。由于独占写入访问的巨大代价,它可能应该保持这种状态。
Linux 包含一个全局内核锁 kernel_flag,它最初在内核 2.0 中作为唯一的 SMP 锁引入。在 2.2 和 2.4 期间,大部分工作都用于从内核中删除全局锁,并用更细粒度的局部锁代替它。如今,全局锁的使用已降至最低限度。但是,它仍然存在,开发人员需要意识到它。
全局内核锁称为大内核锁或 BKL。它是一个递归的自旋锁;因此,连续两次请求它不会使进程死锁(就像自旋锁那样)。此外,进程可以在持有 BKL 时睡眠甚至进入调度程序。当持有 BKL 的进程进入调度程序时,锁被释放,以便其他进程可以获取它。BKL 的这些属性有助于在 2.0 内核系列中轻松引入 SMP。然而,今天,它们应该为不使用该锁提供充分的理由。
大内核锁的使用很简单。调用 lock_kernel() 获取锁,调用 unlock_kernel() 释放锁。如果锁被持有,例程 kernel_locked() 将返回非零值,否则返回零。例如
lock_kernel(); /* critical region ... */ unlock_kernel();
从 2.5 开发内核(以及带有可用补丁的 2.4)开始,Linux 内核是完全可抢占的。此功能允许进程被更高优先级的进程抢占,即使当前进程在内核中运行也是如此。可抢占内核会产生许多 SMP 的同步问题。值得庆幸的是,内核抢占由 SMP 锁同步,因此通过编写 SMP 安全的代码可以自动解决大多数问题。但是,引入了一些新的锁问题。例如,锁可能无法保护每个 CPU 的数据,因为它被隐式锁定(它是安全的,因为它对于每个 CPU 都是唯一的),但在内核抢占中需要它。
对于这些情况,引入了 preempt_disable() 和相应的 preempt_enable()。这些方法是可嵌套的,这样对于每个n preempt_disable() 调用,在第n个 preempt_enable() 调用之前,抢占将不会被重新启用。请参阅“函数参考”侧边栏,了解与抢占相关的完整控件列表。
Linux 内核中的 SMP 可靠性和可扩展性都在快速提高。自从在 2.0 内核中引入 SMP 以来,每个连续的内核修订版都通过实现新的锁原语,并通过修订锁定规则和消除高争用区域中的全局锁来提供更智能的锁定语义,从而在前一个版本的基础上进行了改进。这种趋势在 2.5 内核中仍在继续。未来肯定会拥有更好的性能。
内核开发人员应该尽自己的一份力量,编写实现智能、合理、适当锁定的代码,并着眼于可扩展性和可靠性。
