锁类型及其规则 【ChatGPT】
锁类型及其规则
介绍
内核提供了各种锁原语,可以分为三类:
- 睡眠锁
- CPU 本地锁
- 自旋锁
本文概念上描述了这些锁类型,并提供了它们的嵌套规则,包括在 PREEMPT_RT 下的使用规则。
锁类别
睡眠锁
睡眠锁只能在可抢占的任务上下文中获取。
尽管实现允许在其他上下文中使用 try_lock(),但需要仔细评估 unlock() 和 try_lock() 的安全性。此外,还需要评估这些原语的调试版本。简而言之,在没有其他选择的情况下,不要在其他上下文中获取睡眠锁。
睡眠锁类型包括:
- 互斥锁(mutex)
- 实时互斥锁(rt_mutex)
- 信号量(semaphore)
- 读写信号量(rw_semaphore)
- 读写互斥锁(ww_mutex)
- percpu 读写信号量(percpu_rw_semaphore)
在 PREEMPT_RT 内核上,这些锁类型会转换为睡眠锁:
- local_lock
- spinlock_t
- rwlock_t
CPU 本地锁
- 本地锁(local_lock)
在非 PREEMPT_RT 内核上,本地锁函数是对抢占和中断禁用原语的包装。与其他锁机制相反,禁用抢占或中断是纯粹的 CPU 本地并发控制机制,不适用于 CPU 间并发控制。
自旋锁
- 原始自旋锁(raw_spinlock_t)
- 位自旋锁(bit spinlocks)
在非 PREEMPT_RT 内核上,这些锁类型也是自旋锁:
- spinlock_t
- rwlock_t
自旋锁隐式禁用抢占,lock / unlock 函数可能带有后缀,以应用更多的保护:
- _bh():禁用 / 启用底半部(软中断)
- _irq():禁用 / 启用中断
- _irqsave/restore():保存和禁用 / 恢复中断禁用状态
拥有者语义
除了信号量以外的上述锁类型都有严格的拥有者语义:
- 获取锁的上下文(任务)必须释放它。
读写信号量具有允许非所有者释放读者的特殊接口。
rtmutex
RT 互斥锁是支持优先级继承(PI)的互斥锁。
由于抢占和中断禁用区域的存在,非 PREEMPT_RT 内核对 PI 有限制。
即使在 PREEMPT_RT 内核上,PI 显然也不能抢占禁用抢占或中断禁用代码区域。相反,PREEMPT_RT 内核会在可抢占的任务上下文中执行大多数这样的代码区域,特别是中断处理程序和软中断。这种转换允许通过 RT 互斥锁来实现 spinlock_t 和 rwlock_t。
信号量(semaphore)
信号量是一种计数信号量实现。
信号量通常用于串行化和等待,但新的用例应该使用单独的串行化和等待机制,例如互斥锁和完成。
信号量(semaphores )和 PREEMPT_RT
PREEMPT_RT 不会改变信号量的实现,因为计数信号量没有所有者的概念,因此 PREEMPT_RT 无法为信号量提供优先级继承。毕竟,未知的所有者无法提升优先级。因此,在信号量上的阻塞可能导致优先级反转。
读写信号量(rw_semaphore)
读写信号量是一种多读单写锁机制。
在非 PREEMPT_RT 内核上,实现是公平的,从而防止写者饥饿。
rw_semaphore 默认符合严格的所有者语义,但存在专用接口,允许读者进行非所有者释放。这些接口与内核配置无关。
rw_semaphore 和 PREEMPT_RT
PREEMPT_RT 内核将 rw_semaphore 映射到基于 rt_mutex 的单独实现,从而改变了公平性:
- 因为 rw_semaphore 写者无法将其优先级授予多个读者,因此抢占的低优先级读者将继续持有其锁,从而使得即使高优先级写者也会饥饿。相反,因为读者可以将其优先级授予写者,抢占的低优先级写者将在释放锁之前提升其优先级,从而防止该写者饥饿。
本地锁 (local_lock)
本地锁为由禁用抢占或中断保护的临界区提供了一个命名范围。
在非 PREEMPT_RT 内核上,本地锁操作映射到抢占和中断禁用和启用原语:
- local_lock(&llock):preempt_disable()
- local_unlock(&llock):preempt_enable()
- local_lock_irq(&llock):local_irq_disable()
- local_unlock_irq(&llock):local_irq_enable()
- local_lock_irqsave(&llock):local_irq_save()
- local_unlock_irqrestore(&llock):local_irq_restore()
本地锁的命名范围相对于常规原语具有两个优势:
- 锁名称允许静态分析,并且也是对保护范围的清晰文档化,而常规原语是无范围且不透明的。
- 如果启用了 lockdep,则本地锁会获得一个锁映射,允许验证保护的正确性。这可以检测到例如从中断或软中断上下文调用使用 preempt_disable() 作为保护机制的函数的情况。除此之外,lockdep_assert_held(&llock) 与任何其他锁原语一样工作。
local_lock和 PREEMPT_RT
PREEMPT_RT 内核将本地锁映射到每 CPU 的 spinlock_t,从而改变了语义:
- 所有 spinlock_t 的变化也适用于本地锁。
local_lock
的使用
local_lock
应该在禁用抢占或中断是适当的并发控制形式,以保护非PREEMPT_RT内核上的每个CPU数据结构的情况下使用。
在PREEMPT_RT内核上,local_lock
不适合用于防止抢占或中断,因为PREEMPT_RT具有特定的spinlock_t
语义。
raw_spinlock_t 和 spinlock_t
raw_spinlock_t
raw_spinlock_t 是所有内核中都严格的自旋锁实现。仅在真正的关键核心代码、低级中断处理和需要禁用抢占或中断以安全访问硬件状态的地方使用 raw_spinlock_t。有时也可以在临界区非常小的情况下使用,从而避免 RT 互斥锁的开销。
spinlock_t
spinlock_t 的语义随 PREEMPT_RT 的状态而改变。
在非 PREEMPT_RT 内核上,spinlock_t 被映射到 raw_spinlock_t,并且具有完全相同的语义。
spinlock_t 和 PREEMPT_RT
在 PREEMPT_RT 内核上,spinlock_t 被映射到基于 rt_mutex 的单独实现,从而改变了语义:
- 抢占未被禁用。
- 与 CPU 的中断禁用状态无关的 spin_lock / spin_unlock 操作的硬中断相关后缀(_irq、
_irqsave
/_irqrestore
)不会影响。 - 与软中断相关的后缀(_bh())仍会禁用软中断处理程序。
- 非 PREEMPT_RT 内核通过禁用抢占来实现此效果。
- PREEMPT_RT 内核使用每 CPU 的锁进行串行化,保持抢占启用。该锁禁用软中断处理程序,并且还防止由于任务抢占而导致的重入。
PREEMPT_RT 内核保留所有其他 spinlock_t 的语义:
-
持有 spinlock_t 的任务不会迁移。非 PREEMPT_RT 内核通过禁用抢占来避免迁移。而 PREEMPT_RT 内核则禁用迁移,确保即使任务被抢占,指向每 CPU 变量的指针仍然有效。
-
任务状态在获取 spinlock 时得到保留,确保任务状态规则适用于所有内核配置。非 PREEMPT_RT 内核保持任务状态不变。但是,如果任务在获取期间阻塞,PREEMPT_RT 必须更改任务状态。因此,在阻塞之前保存当前任务状态,并且相应的锁唤醒会将其恢复,如下所示:
task->state = TASK_INTERRUPTIBLE lock() block() task->saved_state = task->state task->state = TASK_UNINTERRUPTIBLE schedule() lock wakeup task->state = task->saved_state
其他类型的唤醒通常会无条件地将任务状态设置为 RUNNING,但这里不起作用,因为任务必须保持阻塞,直到锁可用。因此,当非锁唤醒尝试唤醒等待自旋锁的任务时,它会将保存的状态设置为 RUNNING。然后,当锁获取完成时,锁唤醒会将任务状态设置为保存的状态,即 RUNNING:
task->state = TASK_INTERRUPTIBLE lock() block() task->saved_state = task->state task->state = TASK_UNINTERRUPTIBLE schedule() non lock wakeup task->saved_state = TASK_RUNNING lock wakeup task->state = task->saved_state
这确保了真正的唤醒不会丢失。
rwlock_t
rwlock_t 是一种多读单写锁机制。
非 PREEMPT_RT 内核将 rwlock_t 实现为自旋锁,并且适用于 spinlock_t 的后缀规则。实现是公平的,从而防止写者饥饿。
rwlock_t 和 PREEMPT_RT
PREEMPT_RT 内核将 rwlock_t 映射到基于 rt_mutex 的单独实现,从而改变了语义:
- 所有 spinlock_t 的变化也适用于 rwlock_t。
- 因为 rwlock_t 写者无法将其优先级授予多个读者,因此抢占的低优先级读者将继续持有其锁,从而使得即使高优先级写者也会饥饿。相反,因为读者可以将其优先级授予写者,抢占的低优先级写者将在释放锁之前提升其优先级,从而防止该写者饥饿。
PREEMPT_RT注意事项
RT上的local_lock
在PREEMPT_RT内核上,将local_lock映射到spinlock_t会产生一些影响。例如,在非PREEMPT_RT内核上,以下代码序列按预期工作:
local_lock_irq(&local_lock);
raw_spin_lock(&lock);
并且与以下代码序列完全等效:
raw_spin_lock_irq(&lock);
但在PREEMPT_RT内核上,这个代码序列会出错,因为local_lock_irq()被映射到一个每个CPU的spinlock_t,既不禁用中断也不禁用抢占。以下代码序列在PREEMPT_RT和非PREEMPT_RT内核上都能完美正确地工作:
local_lock_irq(&local_lock);
spin_lock(&lock);
local锁的另一个注意事项是每个local_lock都有特定的保护范围。因此,以下替换是错误的:
func1()
{
local_irq_save(flags); -> local_lock_irqsave(&local_lock_1, flags);
func3();
local_irq_restore(flags); -> local_unlock_irqrestore(&local_lock_1, flags);
}
func2()
{
local_irq_save(flags); -> local_lock_irqsave(&local_lock_2, flags);
func3();
local_irq_restore(flags); -> local_unlock_irqrestore(&local_lock_2, flags);
}
func3()
{
lockdep_assert_irqs_disabled();
access_protected_data();
}
在非PREEMPT_RT内核上,这个工作是正确的,但在PREEMPT_RT内核上,local_lock_1和local_lock_2是不同的,不能串行化对func3()的调用者。而且,在PREEMPT_RT内核上,lockdep断言将触发,因为local_lock_irqsave()由于spinlock_t的PREEMPT_RT特定语义而不禁用中断。正确的替换是:
func1()
{
local_irq_save(flags); -> local_lock_irqsave(&local_lock, flags);
func3();
local_irq_restore(flags); -> local_unlock_irqrestore(&local_lock, flags);
}
func2()
{
local_irq_save(flags); -> local_lock_irqsave(&local_lock, flags);
func3();
local_irq_restore(flags); -> local_unlock_irqrestore(&local_lock, flags);
}
func3()
{
lockdep_assert_held(&local_lock);
access_protected_data();
}
spinlock_t和rwlock_t
在PREEMPT_RT内核上,spinlock_t和rwlock_t的语义变化有一些影响。例如,在非PREEMPT_RT内核上,以下代码序列按预期工作:
local_irq_disable();
spin_lock(&lock);
并且与以下代码序列完全等效:
spin_lock_irq(&lock);
对于rwlock_t和_irqsave()后缀变体也适用相同的规则。
在PREEMPT_RT内核上,这个代码序列会出错,因为RT-mutex需要一个完全可抢占的上下文。相反,使用spin_lock_irq()或spin_lock_irqsave()及其解锁对应项。在必须保持中断禁用和锁定分开的情况下,PREEMPT_RT提供了一种local_lock机制。获取local_lock会将任务固定到一个CPU上,允许获取诸如每个CPU中断禁用锁之类的锁。但是,只有在绝对必要的情况下才应使用这种方法。
一个典型的场景是在线程上下文中保护每个CPU变量:
struct foo *p = get_cpu_ptr(&var1);
spin_lock(&p->lock);
p->count += this_cpu_read(var2);
这是在非PREEMPT_RT内核上的正确代码,但在PREEMPT_RT内核上会出错。spinlock_t语义的PREEMPT_RT特定更改不允许获取p->lock,因为get_cpu_ptr()隐式禁用了抢占。以下替换在两个内核上都能工作:
struct foo *p;
migrate_disable();
p = this_cpu_ptr(&var1);
spin_lock(&p->lock);
p->count += this_cpu_read(var2);
migrate_disable()确保任务固定在当前CPU上,从而保证对var1和var2的每个CPU访问在任务保持可抢占的同时保持在同一个CPU上。
对于以下场景,migrate_disable()替换是无效的:
func()
{
struct foo *p;
migrate_disable();
p = this_cpu_ptr(&var1);
p->val = func2();
这会出错,因为migrate_disable()不能防止来自抢占任务的重入。这种情况的正确替换是:
func()
{
struct foo *p;
local_lock(&foo_lock);
p = this_cpu_ptr(&var1);
p->val = func2();
在非PREEMPT_RT内核上,这通过禁用抢占来保护免受重入的影响。在PREEMPT_RT内核上,通过获取底层的每个CPU自旋锁来实现这一点。
RT上的raw_spinlock_t
获取raw_spinlock_t会禁用抢占,并可能禁用中断,因此关键部分必须避免获取常规的spinlock_t或rwlock_t,例如,关键部分必须避免分配内存。因此,在非PREEMPT_RT内核上,以下代码完美工作:
raw_spin_lock(&lock);
p = kmalloc(sizeof(*p), GFP_ATOMIC);
但是,在PREEMPT_RT内核上,这段代码会失败,因为内存分配器是完全可抢占的,因此不能从真正的原子上下文中调用它。但是,在持有普通非原始自旋锁时调用内存分配器是完全可以的,因为它们在PREEMPT_RT内核上不会禁用抢占:
spin_lock(&lock);
p = kmalloc(sizeof(*p), GFP_ATOMIC);
bit spinlocks
PREEMPT_RT无法替代位自旋锁,因为一个位太小,无法容纳RT-mutex。因此,位自旋锁的语义在PREEMPT_RT内核上保持不变,因此raw_spinlock_t的注意事项也适用于位自旋锁。
一些位自旋锁在PREEMPT_RT上使用条件(#ifdef)代码更改替换为常规的spinlock_t。相比之下,对于spinlock_t的替换不需要使用地方更改。相反,头文件和核心锁定实现中的条件使编译器能够透明地进行替换。
锁类型嵌套规则
最基本的规则是:
-
同一锁类别(睡眠、CPU本地、自旋)的锁类型可以任意嵌套,只要它们遵守一般的锁顺序规则以防止死锁。
-
睡眠锁类型不能嵌套在CPU本地和自旋锁类型中。
-
CPU本地和自旋锁类型可以嵌套在睡眠锁类型中。
-
自旋锁类型可以嵌套在所有锁类型中。
这些约束在PREEMPT_RT和其他情况下都适用。
PREEMPT_RT将spinlock_t和rwlock_t的锁类别从自旋更改为睡眠,并用每个CPU的spinlock_t替换了local_lock,这意味着它们不能在持有原始自旋锁的情况下获取。这导致以下嵌套顺序:
-
睡眠锁
-
spinlock_t、rwlock_t、local_lock
-
raw_spinlock_t和位自旋锁
如果违反这些约束,无论是在PREEMPT_RT还是其他情况下,lockdep都会发出警告。
本文来自博客园,作者:摩斯电码,未经同意,禁止转载