概述
本篇文章主要讲Linux的实时包PREEMPT_RT是如何实现的。
PREEMPT_RT的原理
PREEMPT_RT包的关键点是要使非抢占式的内核代码量尽可能的少,同时为了提供抢占性而必须修改的代码量也要尽可能的少。特别是临界区,中断处理程序和中断禁用的代码序列通常是可抢占式的。PREEMPT_RT包充分利用Linux内核的SMP能力来增加额外的抢占能力,而不是重写Linux内核。某种程度上,可以大致认为抢占是给系统新加了一个CPU,然后使用常规锁定原语与抢占任务采取的操作进行同步。
注意:这里说的一些原理不要从字面意思上去理解。比如PREEMPT_RT包执行每一个抢占并不是执行一个CPU热拔插事件。相反,关键点是提供几乎容忍无限制抢占的底层机制是必须提供SMP环境。后面的章节会详细介绍如何应用这些原理以及如何使用。
PREEMPT_RT的功能
PREEMPT_RT包有如下特性:
- 抢占式临界区
- 抢占式中断处理
- 抢占式中断禁止代码序列
- 内核自旋锁和信号量的优先级继承
- 递延操作
- 降低延迟的措施
抢占式临界区
在PREEMPT_RT中,普通的自旋锁(spinlock_t and rwlock_t)是抢占式的,RCU读取侧临界区((rcu_read_lock() 和rcu_read_unlock())也是一样的。信号量临界区是可抢占的,他们已经存在于可抢占和非抢占内核中。这种可抢占性意思是可以阻止获取自旋锁,也就是在可抢占或中断禁用的情况下获取自旋锁是非法的(这个原则的一个例外就是变体_trylock,只要不是在密集信号中重复调用)。这也意味着当使用spinlock_t的时候spin_lock_irqsave()不会禁用硬件中断。
问题1:如何在非抢占内核中实现抢占式信号量临界区?
在中断或抢占禁用的情况下要获取锁要做什么?用raw_spinlock_t而不是spinlock_t,调用spin_lock()的时候使用raw_spinlock_t。PREEMPT_RT包含一个宏集合,这样会让spin_lock()调用的时候就像c++中的函数重载。当使用raw_spinlock_t的时候,就是传统的自旋锁。但是当使用spinlock_t,临界区就是可抢占的。当使用raw_spinlock_t时,各种_irq原语(例如spin_lock_irqsave())会禁用硬件中断,而在使用spinlock_t时不会禁用硬件中断。但是,使用raw_spinlock_t(及其对应的rwlock_t,raw_rwlock_t)应该是例外,而不是常规使用。在一些底层区域比如调度,特定的架构代码和RCU,是不需要这些原始锁的。
由于临界区可以被抢占,就不能依赖单个CPU上给定的临界区,因为可能会移到其他CPU上。所以,当你在临界区使用per-CPU变量时,必须单独处理抢占的可能性。因为spinlock_t和rwlock_t不再具有这个功能。
可以通过以下两种方式实现:
- 显示禁用中断,或者通过调用get_cpu_var(), preempt_disable(),或者禁掉硬件中断。
- 使用per-CPU锁来保护per-CPU变量,可以通过使用新的DEFINE_PER_CPU_LOCKED()原语。
由于spin_lock可以睡眠,所以会增加一个额外的任务状态。思考一下下面的代码序列:
spin_lock(&mylock1);
current->state=TASK_UNINTERRUPTIBLE;
spin_lock(&mylock2); // [*]
blah();
spin_unlock(&mylock2);
spin_unlock(&mylock1);
由于第二个spin_lock()调用可以睡眠,所以有可能会改变current-state的值,有可能使函数blah()产生令人惊讶的结果。在这种情况下,调度程序可以使用新的TASK_RUNNING_MUTEX位来保留current-state之前的值。尽管生成的环境有点陌生,但是通过少量的代码改动就实现了临界区抢占,并且PREEMPT_RT, PREEMPT和 non-PREEMPT三个配置项都是用相同的代码。
抢占式中断处理
在PREEMPT_RT环境中几乎所有的进程上下文都有中断处理。虽然任何标为SA_NODELAY的中断都可以在其上下文中运行,但是仅在fpu_irq, irq0, irq2和lpptest指定了SA_NODELAY。其中,只有irq0(per-CPU计时器中断)可以正常使用。fpu-irq是用于浮点协处理器中断,而lpptest是用于中断等待时间基准测试。注意软件计时器(add_timer())不在硬件上下文中运行。它是运行在进程上下文中,并且是完全抢占式的。
注意不要轻易使用SA_NODELAY,因为它会大大降低中断和调度延迟。Per-CPU计时器中断之所以符合条件,是因为它与调度程序和其他核心内核组件紧密相关。此外,在写SA_NODELAY中断处理代码的时候必须要非常谨慎,否则很容易出现崩溃和死锁。
由于per-CPU计时器中断运行在硬件中断上下文中,因此任何和进程上下文代码共享的锁必须是原始自旋锁(raw_spinlock_t 或 raw_rwlock_t)。并且,从进程上下文获取时,必须使用_irq变体,比如spin_lock_irqsave()。另外,当进程上下文代码访问每个和SA_NODELAY中断处理程序共享的per-CPU变量的时候,一般上要禁用硬件中断。
抢占式“中断禁用”代码序列
抢占式中断禁用代码序列的概念从术语上理解似乎是矛盾的,但是牢记PREEMPT_RT原理很重要。原理就是要依靠Linux内核的SMP功能来处理和中断处理程序的竞争。大多数中断处理程序都运行在进程上下文中。任何与中断处理程序有交互的代码都要准备处理在其他CPU上同时运行的该中断处理程序。
因此,spin_lock_irqsave()和相关的原语不需要禁用抢占。之所以安全的原因是,即使中断处理程序运行,即使它抢占了拥有spinlock_t的代码,但是在试图获取spinlock_t的时候会立即阻塞。临界区依旧会被保留。
但是,local_irq_save()依旧禁用抢占,因为没有任何锁依赖它。因此使用锁而不是local_irq_save()可以降低调度延迟,但是以这种方式替换锁会降低SMP性能,因此要小心。
需要和SA_NODELAY中断交互的代码不能使用local_irq_save(),因为它没用禁用硬件中断。相反,应该使用raw_local_irq_save(),类似的,当需要和SA_NODELAY中断处理程序交互的时候,需要使用原始自旋锁(raw_spinlock_t, raw_rwlock_t 和raw_seqlock_t)。但是原始自旋锁和原始中断禁用不应该在一些底层区域,如调度程序,架构依赖代码和RCU之外使用。
内核自旋锁和信号量的优先级继承
实时程序员会经常担心优先级倒置,这可能会发生一下几种情况:
- 低优先级任务A获取资源,比如获取锁
- 中优先级任务B开始执行CPU绑定,抢占低优先级任务A
- 高优先级任务C试图获取低优先任务A持有的锁,但是被阻塞了。因为中优先级任务B已经抢占了低优先级任务A
这种优先级倒置可以无限期地延迟高优先级任务。有两种方式可以解决这个问题:(1)抑制抢占;(2)优先级继承。第一种情况,由于没有抢占,所以任务B不能抢占任务A,从而阻止优先级反转的发生。这种方式在PREEMPT内核中用于自旋锁,但不用于信号量。抑制抢占对于信号量来说是没有意义的。因为持有一个信号量的时候阻塞是合法的,即使没有抢占也会导致优先级反转。对于某些实时工作负载,自旋锁也不能抑制抢占,因为会对调度延迟造成影响。
优先级继承可以用在抢占抑制没有意义的场合。就是高优先级任务临时把优先级赠与持有关键锁的低优先级任务。优先级继承是可以传递的:在上面的例子中,如果更高优先级任务D试图获取高优先级任务C已经持有的第二把锁,任务C和A都将暂时提升为任务D的优先级。优先级提升的持续时间也受到严重限制:一旦低优先级任务A释放了锁,它会立刻失去临时提升的优先级,把锁交给任务C。
但是,任务C运行需要时间,很可能同时另一个更高优先级任务E来试图获取锁。如果发生这种情况,任务E会从任务C那里偷到锁。这样是合法的,因为任务C还没有运行,因此实际上它并没有获取锁。另一方面,如果任务C在任务E试图获取锁之前已经运行,那么任务E是无法偷锁的,必须等待任务C释放锁,可能会提高任务C的优先级以加快处理速度。
另外,在某些情况下会长时间保持锁定。其中一些增加了“抢占点”,以便锁持有者在某些其他任务需要时丢弃该锁。
事实证明,读写优先级继承特别成问题。因此,尽管任务可以递归获取,但Preempt_RT可以通过一次只允许一个任务获取读写锁或信号量来简化这个为题。尽管限制了可扩展性,但这让优先级继承实现成为可能。
问题2:实现读写优先级继承的简单快捷的方法是什么?
此外,在某些情况下,信号量不需要优先级继承,比如当信号量用于事件机制而不是锁的时候。compat_semaphore 和compat_rw_semaphore变体可以用于这种情况。很多信号量原语(up(), down()等)可用于compat_semaphore 和compat_rw_semaphore。相同的,读写信号量原语(up_read(), down_write()等)可用于compat_rw_semaphore 和rw_semaphore。
总结一下,优先级继承可以防止优先级反转,允许高优先级任务及时获取锁和信号量,即使这些锁和信号量被低优先级任务持有。PREEMPT_RT的优先级继承具有传递性且能够及时移除,并且具有当高优先级任务突然需要低优先任务持有的锁时,处理这种情况的灵活性。当信号量用于事件机制的时候,compat_semaphore 和compat_rw_semaphore可以避免优先级继承。
递延操作
由于spin_lock()现在可以休眠,所以当抢占或中断被禁用的时候,调用它就不再合法了。在一些情况下,可以通过递延操作要求spin_lock()等到抢占被重新启用的时候来解决这个问题。
- 当合法获取task_struct中的spinlock_t alloc_lock是,可以将put_task_struct()放到put_task_struct_delayed()队列中,以便延迟运行。
- 把mmdrop()放到mmdrop_delayed()队列中,延迟运行。
- TIF_NEED_RESCHED_DELAYED重新调度,不过需要等到进程返回到用户空间,或者等到下一个preempt_check_resched_delayed()。无论哪种方式,关键点在于避免在唤醒高优先级任务直到当前任务未锁定之前无法取得进展的情况下进行不必要的抢占。没有TIF_NEED_RESCHED_DELAYED,高优先级任务会立刻抢占低优先级任务,只能被快速阻塞等待低优先级任务持有的锁。
解决方案是在spin_unlock()之后增加wake_up()去替代wake_up_process_sync()。如果唤醒的进程抢占当前进程,通过TIF_NEED_RESCHED_DELAYED,唤醒操作会被延迟。
在所有这些情况下,解决方案是将操作推迟到可以更安全或更方便地执行该操作。
降低延迟的操作
在PREEMPT_RT中的一些改变,主要目的是降低调度或中断延迟。
第一种改变是引入x86 MMX/SSE硬件。这个硬件在内核中处理中断禁用。某些情况下意味着等待直到MMX/SSE指令完成。一些MMX/SSE指令没有问题,但是有些指令要花很长时间,所以PREEMPT_RT拒绝使用这些很慢的指令。
第二个改变是使用per-CPU变量用于板坯分配器,以代替之前随意的中断禁用。
PREEMPT_RT原语总结
这个章节总结PREEMPT_RT增加的原语列表或者原来的行为几乎被PREEMPT_RT改变的原语列表。
锁原语
- spinlock_t
关键临界区是抢占式的。_irq操作没有禁用硬件中断。优先级继承用来防止优先级反转。rt_mutex在PREEMPT_RT用来实现spinlock_t(包括rwlock_t, struct semaphore和struct rw_semaphore)
- raw_spinlock_t
提供spinlock_t原有功能的的特定变种,所以临界区是非抢占的,_irq真的禁用了硬件中断。需要注意的是在raw_spinlock_t上应该使用正常原语(比如spin_lock())。也就是,除了特定架构或者底层调度与同步原语外禁止使用raw_spinlock_t。误用raw_spinlock_t会破坏PREEMPT_RT的实时性。
- rwlock_t
关键临界区是抢占式的。_irq操作没有禁用硬件中断。优先级继承用来防止优先级反转。为了保持优先级继承窒息的复杂度,每个任务只允许读取/获取一次给定的rwlock_t,即使这个任务会递归读取/获取rwlock_t。
- RW_LOCK_UNLOCKED(mylock)
RW_LOCK_UNLOCKED宏根据优先级继承的要求蒋锁自身作为参数。但是,这样使用的话,与抢占和非抢占的内核都是不兼容的。使用RW_LOCK_UNLOCKED因此要改为DEFINE_RWLOCK()。
- raw_rwlock_t
提供rwlock_t原有功能的特定变种,所以临界区是非抢占的,_irq真的禁用了硬件中断。需要注意的是在raw_rwlock_t上应该使用正常原语(比如read_lock ())。也就是,除了特定架构或者底层调度与同步原语外禁止使用raw_rwlock_t。误用raw_rwlock_t会破坏PREEMPT_RT的实时性。
- seqlock_t
临界区是抢占式的。更新操作已经使用优先级继承。(读取操作不需要优先级继承因为seqlock_t读者不能阻塞写操作)
- SEQLOCK_UNLOCKED(name)
SEQLOCK_UNLOCKED宏根据优先级继承的要求将锁自身作为参数。但是,这样使用与抢占和非抢占的内核都是不兼容的。使用SEQLOCK_UNLOCKED因此要改为DECLARE_SEQLOCK ()。注意DECLARE_SEQLOCK()定义并初始化seqlock_t。
- struct semaphore
semaphore受优先级继承的约束。
- down_trylock()
这个原理用于调度,因此不能在禁用硬件中断和禁用抢占的情况下使用。但是几乎所有的中断都需要在启用了抢占和中断的进程上下文中允许,所以这个限制目前没有任何影响。
- struct compat_semaphore
结构体semaphore的变种,不受优先级继承的约束。这个结构体在事件机制下非常有用,对睡眠锁没用。
- struct rw_semaphore
结构体rw_semaphore受继承优先级约束,并且一个任务每次只能读取一次。但是,这个任务可以递归的读取rw_semaphore.
- struct compat_rw_semaphore
结构体rw_semaphore的变种,不受优先级继承的约束。这个结构体在事件机制下非常有用,对睡眠锁没用。
Per-CPU 变量
- DEFINE_PER_CPU_LOCKED(type, name)
- DECLARE_PER_CPU_LOCKED(type, name)
定义/声明有指定类型和名字的per-CPU变量,但是也要定义/声明相应的spinlock_t。如果有一组per-CPU变量需要回旋锁的保护,要把它们分组到一个结构体中。
- get_per_cpu_locked(var, cpu)
返回指定CPU的指定的per-CPU变量,但是只能在获取相应的自旋锁之后。
- put_per_cpu_locked(var, cpu)
释放指定CPU相应的自旋锁给指定的per-CPU变量。
- per_cpu_lock(var, cpu)
释放指定CPU相应的自旋锁给指定的per-CPU变量,但是是作为左值。当调用的函数的参数是一个将要释放的自旋锁试非常有用。
- per_cpu_locked(var, cpu)
将指定 CPU 的指定 per-CPU 变量作为左值返回,但不获取锁,大概是因为已经获取了锁但需要获取对该变量的另一个引用。或者可能是因为正在对变量进行 RCU 读取端引用,因此不需要获取锁。
中断处理
- SA_NODELAY
在结构体irqaction使用,指定直接调用在硬件中断上下文中相应的中断处理程序,而不是移交线程irq。函数redirect_hardirq()负责唤醒,在do_irqd()函数中可以找到中断处理循环。
注意:SA_NODELAY不能用于正常的设备中断。
- 会降低中断和调度延迟
- SA_NODELAY中断处理程序的编码和维护比普通的中断处理程序要困难。只在低级别的中断或需要极端实时延迟的硬件中断使用SA_NODELAY
- local_irq_enable()
- local_irq_disable()
- local_irq_save(flags)
- local_irq_restore(flags)
- irqs_disabled()
- irqs_disabled_flags()
- local_save_flags(flags)
local_irq*() 函数实际上并没有禁用硬件中断,它们只是禁用了抢占。这些适用于普通中断,但不适用于 SA_NODELAY 中断处理程序。
然而,对于 PREEMPT_RT 环境,使用锁(可能是per-CPU 的锁)而不是这些函数通常会更好——但也 要考虑对使用非 抢占内核的 SMP 机器的影响!
- raw_local_irq_enable()
- raw_local_irq_disable()
- raw_local_irq_save(flags)
- raw_local_irq_restore(flags)
- raw_irqs_disabled()
- raw_irqs_disabled_flags()
- raw_local_save_flags(flags)
这些函数禁用了硬件中断,因此适用于SA_NODELAY中断。这些函数特定只在低级代码中使用,例如调度程序、同步原语等。注意,在 raw_local_irq*() 的影响下,无法获得正常的 spinlock_t 锁。
其他项
- wait_for_timer()
等待指定的计时器到期。这是必需的,因为定时器在 PREEMPT_RT 环境中运行,因此可以被抢占,也可以阻塞,比如如在 spinlock_t 获取期间。
- smp_send_reschedule_allbutself()
将重新调度 IPI 发送到所有其他 CPU。这在调度器中用于快速找到另一个 CPU 来运行新唤醒的高优先级实时任务,但没有足够高的优先级在当前 CPU 上运行。这种能力对于进行实时所需的高效全局调度是必要的。非实时任务继续以传统方式按 CPU 进行调度,牺牲一些优先级的准确性以提高效率和可扩展性。
- INIT_FS(name)
将变量的名称作为参数,以便内部 rwlock_t 可以正确初始化(考虑到优先级继承的需要)
- local_irq_disable_nort()
- local_irq_enable_nort()
- local_irq_save_nort(flags)
- local_irq_restore_nort(flags)
- spin_lock_nort(lock)
- spin_unlock_nort(lock)
- spin_lock_bh_nort(lock)
- spin_unlock_bh_nort(lock)
- BUG_ON_NONRT()
- WARN_ON_NONRT()
这些在 PREEMPT_RT 中什么都不做(或几乎什么都不做),但在其他环境中具有正常效果。这些原语不应在低级代码之外使用(例如,在调度程序、同步原语或特定于体系结构的代码中)。
- spin_lock_rt(lock)
- spin_unlock_rt(lock)
- in_atomic_rt()
- BUG_ON_RT()
- WARN_ON_RT()
这些在 PREEMPT_RT 中有正常的效果,但在其他环境中什么也不做。同样,这些原语不应在低级代码之外使用(例如,在调度程序、同步原语或特定于体系结构的代码中)。
- smp_processor_id_rt(cpu)
在 PREEMPT_RT 环境中返回“cpu”,但在其他环境中的作用与 smp_processor_id() 相同。这仅用于slab分配器。
PREEMPT_RT配置选项
High-Level Preemption-Option Selection
- PREEMPT_NONE:为服务器操作系统选择传统的非抢占内核
- PREEMPT_VOLUNTARY:启动自愿抢占点,但是不能批发内核抢占。这个主要是桌面操作系统使用
- PREEMPT_DESKTOP:启用自愿抢占点以及非关键部分抢占 。适用于低延迟桌面操作系统使用。
- PREEMPT_RT:启用完全抢占,包括临界区。
Feature-Selection Configuration Options
- PREEMPT:启用非临界区内核抢占
- PREEMPT_BKL :大内核锁临界区抢占.
- PREEMPT_HARDIRQS:硬中断在进程上下文中允许,从而可抢断。但是标记为SA_NODELAY的irqs继续在硬件中断上下文中进行。
- PREEMPT_RCU :RCU读侧临界区可抢占。
- PREEMPT_SOFTIRQS :软中断在进程上下文中进行,从而可抢占。
调试配置项
有些可能已经发生了变化,但是可以了解下PREEMPT_RT可以提供的调试种类:
- CRITICAL_PREEMPT_TIMING: 测量内核在禁用抢占的情况下花费的最长时间
- CRITICAL_IRQSOFF_TIMING :测量内核在禁用硬件中断请求的情况下花费的最长事件。
- DEBUG_IRQ_FLAGS:内核验证spin_unlock_irqrestore()和其他类似原语的“flg“参数。
- DEBUG_RT_LOCKING_MODE:启用从可抢占到不可抢占的自旋锁的运行事件切换。对于想要评估 PREEMPT_RT 机制开销的内核开发人员很有用。
- DETECT_SOFTLOCKUP:内核在转储任何进程当前堆栈跟踪,超过10秒不需要内核重新调度。
- LATENCY_TRACE :记录表示长延迟事件的函数调用跟踪。这些跟踪可以通过 /proc/latency_trace 从内核中读出。可以通过/proc/sys/kernel/preempt_thresh 过滤掉低延迟跟踪。这个选项在跟踪过度低延时非常有用。
- LPPTEST:执行基于并行端口的延迟测量的设备驱动程序,使用 scripts/testlpp.c 实际运行此测试
- PRINTK_IGNORE_LOGLEVEL :-all-printk() 消息被转储到控制台。通常不是什么好方法,但在其他调试工具失败时很有帮助。
- RT_DEADLOCK_DETECT:发现死锁循环。
- RTC_HISTOGRAM :使用 /dev/rtc 为应用程序生成延迟直方图数据。
- WAKEUP_TIMING :测量从高优先级线程被唤醒到它实际开始运行的最长时间(以微秒为单位)。结果是从 /proc/sys/kernel/wakeup_timing 访问的。并且可以通过 echo 0 > /proc/sys/kernel/preempt_max_latency 重新启动测试
快速问答
问题#1: 如何在非抢占内核中实现抢占信号量临界区?
严格来说,抢占根本不会发生在不可抢占的内核中。但是,由于访问用户数据时的页面错误以及显式调用调度程序等原因,可能会发生大致相同的事情。
问题#2: 实现从写入者到多个读写者的优先级继承的简单快捷的方法是什么?
没有已知的解决方案,并且已经进行了相当彻底的讨论。特别是,在考虑提高写入者到读写者的优先级时,请考虑这样一种情况,即一个读写锁被多个读者读取持有,并且每个读者都被阻止尝试写入/获取其他一些读写锁。然后每次读写锁再次被多个读者读取持有。当然,提升(然后取消提升)所有这些读者所需的时间会影响调度延迟。
问题#3: 为什么事件机制不能使用优先级继承?
Linux 无法确定要提升哪个任务
使用睡眠锁,获取信号量的任务可能是释放信号量的任务,因此可以确定是会提升优先级的任务。相比之下,对于事件,任何任务都可能执行唤醒高优先级任务的 down()。
PREEMPT_RT意想不到的影响
由于 PREEMPT_RT 环境严重依赖以 SMP 安全方式编码的 Linux,因此使用 PREEMPT_RT 清除了 Linux 内核中的许多 SMP 错误,包括一些定时器死锁、ns83820_tx_timeout() 中的锁遗漏,ACPI-空闲调度延迟错误、核心网络锁定错误以及块 IO 统计代码中的许多抢占式错误。
参考链接:
https://lwn.net/Articles/146861/
https://tldp.org/HOWTO/Parallel-Processing-HOWTO-2.html
https://www.halolinux.us/kernel-architecture/the-big-kernel-lock.html
https://lwn.net/Articles/262464/
相关内容:
如何以符合标准的方式确保 Linux 安全 – 技术文章
安全关键系统的开发和认证必须遵循明确定义的过程。 Linux 由社区开发,并无制造商承担法律责任,因此不满足这些要求。 但是,诸多标准指明了允许 Linux 在安全关键环境中使用的方法。
基于 IEC 61508 汽车功能安全(SIL-2)的 Linux – 网络研讨会
Elektrobit 和 emlix 联手对于 IEC 61508 汽车功能安全(SIL-2)中应用 Linux 的内部工作方式进行了分解。
相关产品:
功能安全产品: EB tresos Safety