Linux内核之内核抢占
1.开场白
环境:
处理器架构:arm64
内核源码:linux-5.11
ubuntu版本:20.04.1
代码阅读工具:vim+ctags+cscope
或许我们经常听说内核抢占的相关概念?它与抢占式内核之间究竟存在什么关系?抢占计数器又在实际应用中发挥着怎样的作用?… 本文我们将深入探讨内核抢占的相关技术细节,并旨在帮助读者更好地理解其工作原理及其在现代操作系统中的重要性。
注:本文主要关注CFS调度类。
2.内核抢占和抢占式内核
我们常用 uname -a 命令观察到显示'PREEMPT'的文字,并且实际上我们所使用的内核正是基于抢占式的。
# uname -a
Linux (none) 5.11.0-g08a3831f3ae1 #1 SMP PREEMPT Fri Apr 30 17:41:53 CST 2021 aarch64 GNU/Linux
那么什么是抢占式内核呢?实际上,在支持内核抢占的机制中被称为抢占式内核机制;而不支持这种机制的则称为不可抢占式内核机制。那么问题接着来了:什么是内核抢占呢?我们都知道,在使用周期性的tick机制时:对于用户态的任务而言,在每一个时钟中断到来后都会检查其实际运行时间是否超出预期运行时间或者运行队列中是否存在更高优先级的任务;如果满足其中一个条件就会设置一个重排标志(即标记该任务应被提前调度),然后在中断返回用户态之前发生相应的重排操作——这就是所谓用户的任务被剥夺了优先权的情况(即所谓的用户任务被重排)。但是当一个处于核心态的任务被唤醒并处理一个高优先级的任务时——这个时候就会出现两种情况需要分别分析:如果是基于可重排的核心机制(即所谓的是支持核心抢占式的),那么高优先级的任务就有可能取代当前核心中的任务并执行——不过这种情况只有在两者虚拟运行时间差值超过预定的核心重排粒度的情况下才会发生);如果是不可重排的核心机制(即传统的非抢占型核心),则只有当当前进程完成本机代码或者主动请求提升更高优先级进程的机会的时候才允许进行重排操作。(请注意这里讨论的核心状态是核心态的情况)因此对于那些对交互性和实时性要求较高的应用场景来说(如手持设备和桌面应用等),这种可重排的核心设计非常友好——响应速度很快;而对于服务器端系统而言,则需要更高的吞吐量目标——希望获得更多的CPU时间片而交互性或实时性则显得不太重要——因此它们通常设计成不可重排的核心架构。
下图给出非抢占式内核调度情况:

下图给出抢占式内核调度情况:

通过比较两个图表可以看出:当采用抢占型内核调度时,在发生中断的情况下唤醒一个高优先级的任务能够获得良好的响应。
探讨抢占式内核与不可抢占式内核选择的问题,在源码的kernel/Kconfig.preempt目录中有所说明。
config PREEMPT_NONE
bool "No Forced Preemption (Server)"
help
¦ This is the traditional Linux preemption model, geared towards
¦ throughput. It will still provide good latencies most of the
¦ time, but there are no guarantees and occasional longer delays
¦ are possible.
¦ Select this option if you are building a kernel for a server or
¦ scientific/computation system, or if you want to maximize the
¦ raw processing power of the kernel, irrespective of scheduling
¦ latencies.
config PREEMPT
bool "Preemptible Kernel (Low-Latency Desktop)"
depends on !ARCH_NO_PREEMPT
select PREEMPTION
select UNINLINE_SPIN_UNLOCK if !ARCH_INLINE_SPIN_UNLOCK
select PREEMPT_DYNAMIC if HAVE_PREEMPT_DYNAMIC
help
¦ This option reduces the latency of the kernel by making
¦ all kernel code (that is not executing in a critical section)
¦ preemptible. This allows reaction to interactive events by
¦ permitting a low priority process to be preempted involuntarily
¦ even if it is in kernel mode executing a system call and would
¦ otherwise not be about to reach a natural preemption point.
¦ This allows applications to run more 'smoothly' even when the
¦ system is under load, at the cost of slightly lower throughput
¦ and a slight runtime overhead to kernel code.
¦ Select this if you are building a kernel for a desktop or
¦ embedded system with latency requirements in the milliseconds
¦ range.
提及其两种选择:一种是支持内核抢占机制的编译选项;另一种则是完全不采用此类机制的编译策略。值得注意的是,在这种情况下还包含两个相关选项:PREEMPT VOLUNTARY与 PREEMPT RT;其中前者明显地增加了若干抢占点;后者则专为实现实时性需求而设计。
3.重新调度标志和抢占计数器
内核中有些路径被禁用以避免任务冲突,在特定条件下如果唤醒高优先级任务或在tick事件时检测到可重排条件满足,则该任务无法立即执行但需标记其为待调度状态当返回可重排上下文环境(如开启抢占式执行)时系统会根据标记状态决定是否调用调度器选择下一个任务运行。
标识重新调度是设置:
//当前任务的task_struct的thread_info的flag
stsk->thread_info->flags设置TIF_NEED_RESCHED标志
#define TIF_NEED_RESCHED 1 /* rescheduling necessary */
内核的一些路径上配置了一个标志之后,在接下来的一个调度周期内将被触发调度(这可能发生在进程被强行抢占资源时,也可能发生在系统检测到进程出现异常中断时)
当系统给定了一个重新调度标志之后,并不意味着立即就会执行调度操作;从用户任务的角度看,则是在出现中断异常并即将返回用户态时会触发调度;而对于处于内核态的任务而言,则仅靠设置为高值是不够的;还需检查该进程的抢占计数值是否归零
所有处于内核态的任务都需要关注抢占计数器,在重新调度过程中其扮演着关键角色。当且仅当其值不为零时(即不管该进程正处于紧急状态),任何被唤醒的任务都无法获得调度权限。让我们深入探讨这个抢占计数器的作用机制:
tsk->
thread_info->preempt.count
我们来看下对于arm64架构,抢占计数器的定义:
24 struct thread_info {
25 unsigned long flags; /* low level flags */
29 union {
30 u64 preempt_count; /* 0 => preemptible, <0 => bug */
31 struct {
32 #ifdef CONFIG_CPU_BIG_ENDIAN
33 u32 need_resched;
34 u32 count;
35 #else
36 u32 count;
37 u32 need_resched;
38 #endif
39 } preempt;
40 };
45 };
可以看出它们属于同一个共享机制。其中一些路径采用了preempt_count这一指标来衡量资源竞争程度;而另一些则直接使用preempt这一机制。这背后的原因是什么呢?每个成员都承担着两种不同的角色:一方面负责监控资源重排情况;另一方面则用于记录抢占计数器的实际数值。
当触发重排需求时设置flags中的TIF_NEED_RESCHED标志;同时将preempt.need_resched字段归零。一旦检查到thread_info中的preempt_count等于零时,则表明抢占计数器值已归零且flags中的TIF_NEED_RESCED状态已被设置;此时可执行进程重排操作(例如,在中断返回内核态前夕进行重排检查)。
下面看下如何设置重新调度标志:
resched_curr //kernel/sched/core.c
613 if (cpu == smp_processor_id()) {
614 set_tsk_need_resched(curr);
615 set_preempt_need_resched();
616 return;
617 }
29 static inline void set_preempt_need_resched(void) //arch/arm64/include/asm/preempt.h
30 {
31 current_thread_info()->preempt.need_resched = 0;
32 }
在内核中某个特定路径,在发生时钟中断tick事件时会触发resched_curr函数来处理重排标记:除了将任务的flags中的TIF_NEED_RESCHED标志设为1外,并未更改preempt.need_resched标志。
如何清除重新调度标志:
kernel/sched/core.c
__schedule //主动调度或抢占式调度 都会调用到这
5046 clear_tsk_need_resched(prev);
5047 clear_preempt_need_resched();
//arch/arm64/include/asm/preempt.h
34 static inline void clear_preempt_need_resched(void)
35 {
36 current_thread_info()->preempt.need_resched = 1;
37 }
可以看出,在主调度器中,除了将clear_tsk_need_resch用于清除任务相关字段的TIF_NEED_RESCH标志之外,还会使用clear_preempt_need_resch将preempt.need_resch设为1,并进行重排。
下面为抢占计数器的各个域的表示:

在不同的上下文中设置响应的位域分别表示:抢占计数器在内核态被禁止被抢占的条件是当对应的位域被设置且该计数器的值不为零时
所以,在操作系统中,“抢占计数器”有两个功能:其一是作为某个原子上下文的内核路径标识符;其二是用于判断任务是否能够在内核态被抢占。
include/linux/preempt.h
85 /*
86 * Macros to retrieve the current execution context:
87 *
88 * in_nmi() - We're in NMI context
89 * in_hardirq() - We're in hard IRQ context
90 * in_serving_softirq() - We're in softirq context
91 * in_task() - We're in task context
92 */
93 #define in_nmi() (nmi_count()) //判断是否在 不可屏蔽中断上下文
94 #define in_hardirq() (hardirq_count()) //判断是否在硬中断上下文
95 #define in_serving_softirq() (softirq_count() & SOFTIRQ_OFFSET) //判断是否在软中断上下文
96 #define in_task() (!(in_nmi() | in_hardirq() | in_serving_softirq())) //判断是否在进程上下文
97
//判断是否在原子上下文(抢占计数器不为0)
144 #define in_atomic() (preempt_count() != 0)
文章福利
文章福利
学习资源分享

内核资料直通车:Linux内核源码技术学习路线+视频教程代码资料
学习直通车:Linux内核源码/内存调优/文件系统/进程管理/设备驱动/网络协议栈-学习视频教程
4.内核抢占的调度时机
在调度时机时,我将其划分为两种情况:一种是无需进行调度的检查点;另一种则是真正的抢占点(即调用主调度器来进行调度)。
check点->
在tick事件发生时 : 当任务完成理想运行时间且运行时间大于最小抢占粒度,并且运行队列中有优先级更高的任务时(即条件满足),触发TIF_NEED_RESCHED标志位设置,并相应地将最近的抢占点被触发调度机制处理
当满足特定逻辑判断结果时(其中...),触发相应的抢占点被触发调度机制(同时...)
系统发生中断时返回到内核态 : 当上述条件得到满足时(即重新调度标志被设置且抢占计数器数值为零),将采用抢占式调度机制进行处理。
当开启抢占机制时 : 例如断开下半部分并释放自旋锁。当重新调度标志被置位且抢占计数器为零时触发,则采用抢占式调度方式。
当软中断被启动时:当重新调度标志被置位且抢占计数器归零时,则采用抢占式调度机制。
内核态返回中断是一种常见的抢占点类型。
即便没有其他中断事件的发生,在一定周期性的时间间隔下也会触发中断。
当任务触发条件得到满足(即重新调度标志已设置且抢占计数器值归零)时,则当前任务将被提升优先级。
在此类场景下:
一部分操作直接执行preempt_disable指令;
另一些则通过间接途径完成该操作(如在申请自旋锁的操作系统内部核中执行);
还有一种情况则是禁止软中断的发生。
值得注意的是:
在未进行抢占优先级控制的特定区域中,
虽然禁止了当前CPU所在的内核进行抢占,
但其他CPU依然可以正常进行内核间的抢占,
以确保整个系统的安全性和稳定性。
4.1 check点
时钟中断tick时:
kernel/sched/core.c
scheduler_tick
->curr->sched_class->task_tick(rq, curr, 0)
->task_tick_fair
->entity_tick
->check_preempt_tick
->4374 if (delta_exec > ideal_runtime) { //1.当前任务的实际运行时间大于理想运行时间
4375 resched_curr(rq_of(cfs_rq)); //设置重新调度标志
4389 if (delta_exec < sysctl_sched_min_granularity) //当前任务的实际运行时间 小于 最小调度粒度吗?
4390 return;
4398 if (delta > ideal_runtime) //2.红黑树最左边的任务的虚拟运行时间和当前任务的虚拟运行时间的差值大于理想运行时间
4399 resched_curr(rq_of(cfs_rq)); //设置重新调度标志
每当时钟tick事件发生时,系统将执行scheduler_tick函数以检查是否需要重新调度。只要任何一个条件满足就会触发重排。
当前实际运行时长超过了预期值(为了确保任务在一个调度周期内的实际运行时长不超过预期值,并防止异常进程长时间占用CPU资源,系统会定期触发时钟中断机制来恢复CPU控制权)
该任务的实际运行时长大于最小调度粒度,并且,在红黑树中最左边的任务与当前任务之间的虚拟时长差额也超过了理想时长(这表明,在红黑树架构中存在高优先级作业抢占现有作业的情况)。
2)唤醒抢占:
在fork和正常的唤醒路径上:
fork路径:
kernel/fork.c
kernel_clone
->wake_up_new_task(p)
->check_preempt_curr(rq, p, WF_FORK)
->rq->curr->sched_class->check_preempt_curr(rq, p, flags)
->check_preempt_wakeup //kernel/sched/fair.c
-> 6994 if (wakeup_preempt_entity(se, pse) == 1) { //唤醒的任务的虚拟运行时间和当前任务的虚拟运行时间差值大于最小唤醒抢占粒度转换的虚拟运行时间
6995 /*
6996 ¦* Bias pick_next to pick the sched entity that is
6997 ¦* triggering this preemption.
6998 ¦*/
6999 if (!next_buddy_marked)
7000 set_next_buddy(pse);
7001 goto preempt;
7002 }
7003
7004 return;
7005
7006 preempt:
7007 resched_curr(rq); //设置重新调度标志
正常唤醒路径:
kernel/sched/core.c
wake_up_process
->try_to_wake_up
->ttwu_queue
->ttwu_do_activate
->ttwu_do_wakeup
->check_preempt_curr(rq, p, wake_flags)
在启动新任务或执行唤醒指令的过程中,有可能出现当前任务被新唤醒任务抢占的情况。判断依据为:新被唤醒的任务具有较短的虚拟运行时长,并与当前任务所占用的虚拟运行时长产生显著的时间差值(即前者小于后者)。这种情况下认为存在抢占风险。
4.2 抢占点
上述内容均为关键检查点。具体而言,在配置重排标志位后,并未使得抢占的任务运行。真正的抢占机制是在主调度器被调用时触发的。
1)中断返回内核态
当启动内核抢占机制时,在系统即将从中断态返回到内核态的过程中(即在中断返回内核态的前夕),系统会首先检查当前任务是否已设置了重新调度标志;接着检查该任务的抢占计数器是否为零。若上述两个条件均满足,则执行相应的抢占式调度策略。
arch/arm64/kernel/entry.S
el1_irq
-> 671 #ifdef CONFIG_PREEMPTION
672 ldr x24, [tsk, #TSK_TI_PREEMPT] // get preempt count
673 alternative_if ARM64_HAS_IRQ_PRIO_MASKING
674 /*
675 ¦* DA_F were cleared at start of handling. If anything is set in DAIF,
676 ¦* we come back from an NMI, so skip preemption
677 ¦*/
678 mrs x0, daif
679 orr x24, x24, x0
680 alternative_else_nop_endif
681 cbnz x24, 1f // preempt count != 0 || NMI return path
682 bl arm64_preempt_schedule_irq // irq en/disable is done inside
683 1:
684 #endif
每当发生中断时,在发生中断时
下面看下抢占式调度:
arm64_preempt_schedule_irq
->preempt_schedule_irq
->__schedule(true) //调用主调度器进行抢占式调度
2)打开抢占的时候
开启抢占:
preempt_enable
->if (unlikely(preempt_count_dec_and_test())) \ //抢占计数器减一 为0
__preempt_schedule(); \
->preempt_schedule //kernel/sched/core.c
-> __schedule(true) //调用主调度器进行抢占式调度
释放自旋锁:
spin_unlock
->raw_spin_unlock
->__raw_spin_unlock
->preempt_enable //如上
开启软中断
local_bh_enable
->__local_bh_enable_ip
->preempt_check_resched
->if (should_resched(0)) \
__preempt_schedule();
->preempt_schedule
-> __schedule(true) //调用主调度器进行抢占式调度
其实在IS……的情况下__schedule这一段代码块则会触发异常。
5.不可抢占内核的低延迟处理
下面我们来看下在没有开启内核抢占的内核中如何处理低延迟:
我们通常会注意到,在那些处理时间较长的过程中(特别是涉及文件系统管理和内存回收相关的路径),cond_resched会被调用。它的作用是什么呢?
以下是一个使用该宏的例子:在内存回收路径上,在不活跃的lru链表尾部将一些页面被分离出来的页面回收到page_list中,并最终会被调用到
shrink_page_list:
mm/vmscan.c
shrink_page_list
->
1084 while (!list_empty(page_list)) {
...
1091 cond_resched();
... //回收处理
}
可以看到,在处理每个被隔离的候选回收页之前(即page_list中的每一个),系统会通过cond_resched来进行主动判断,并决定是否需要重新调度。
下面我们来看下cond_resched这个宏实现:
include/linux/sched.h
1868 /*
1869 * cond_resched() and cond_resched_lock(): latency reduction via
1870 * explicit rescheduling in places that are safe. The return
1871 * value indicates whether a reschedule was done in fact.
1872 * cond_resched_lock() will drop the spinlock before scheduling,
1873 */
1874 #ifndef CONFIG_PREEMPTION
1875 extern int _cond_resched(void);
1876 #else
1877 static inline int _cond_resched(void) { return 0; }
1878 #endif
1879
1880 #define cond_resched() ({ \
1881 ___might_sleep(__FILE__, __LINE__, 0); \
1882 _cond_resched(); \
1883 })
通过详细观察可知,在支持抢占式的内核架构下(CONFIG_PREEMPTION=y)cond_resched宏的条件重排函数并未被激活,并未主动进行资源调度功能检测;相比之下,在非抢占式内核中会调用该函数来进行相关检测。
下面我们来看下_cond_resched:
6671 #ifndef CONFIG_PREEMPTION
6672 int __sched _cond_resched(void)
6673 {
6674 if (should_resched(0)) { //判断抢占计数器是否为0
6675 preempt_schedule_common(); //进行抢占式调度
6676 return 1;
6677 }
6678 rcu_all_qs();
6679 return 0;
6680 }
6681 EXPORT_SYMBOL(_
cond_resched);
6682 #endif
主动检查抢占计数器的值是否为零,并且当前任务已设置了重新调度标志,则执行抢占式调度
值得注意的是,在非抢占式内核中,在其运行的多个地方——尤其是处理文件系统操作以及内存管理相关的耗时路径时——都已经被内核开发者确定为需要优化的重点区域,并采用cond_resched机制降低了延迟(感兴趣的朋友可以通过grep和wc -l命令查看相关内容)。
6.自愿内核抢占
该文档中提到一种名为CONFIG_PREEMPT_VOLUNTARY=y的自顶内核抢占模型(CONFIG_PREEMPT Volvountary=y),该机制能够帮助内核开发者,在执行长时间操作时主动判断是否有必要实施抢占式调度策略。这一机制与上一节所述的内容基本一致。
config PREEMPT_VOLUNTARY
bool "Voluntary Kernel Preemption (Desktop)"
depends on !ARCH_NO_PREEMPT
help
¦ This option reduces the latency of the kernel by adding more
¦ "explicit preemption points" to the kernel code. These new
¦ preemption points have been selected to reduce the maximum
¦ latency of rescheduling, providing faster application reactions,
¦ at the cost of slightly lower throughput.
¦ This allows reaction to interactive events by allowing a
¦ low priority process to voluntarily preempt itself even if it
¦ is in kernel mode executing a system call. This allows
¦ applications to run more 'smoothly' even when the system is
¦ under load.
¦ Select this if you are building a kernel for a desktop system.
使用might_resched:
83 #ifdef CONFIG_PREEMPT_VOLUNTARY
84 extern int _cond_resched(void);
85 # define might_resched() _cond_resched()
86 #else
87 # define might_resched() do { } while (0)
88 #endif
发现只有CONFIG_PREEMPT_VOLUNTARY=y时,might_resched才有效,否则为空。
发现了这一现象:当我们搜索mightch在整个内核代码库中的使用情况时,并未发现任何地方被实际调用;推测其原因在于大多数耗时的内核路径已经采用了condch来进行调度时机检查
7.总结
本文全面探讨了内核抢占机制的各种方面。非抢 核主要应用于对吞 吐量有较高需求的场景, 而抢 核则主要应用于响应速度要求较高的嵌入 式设备及桌面系统。对于inner kernel 的调度时机, 我们主要从检查点 和抢 点两个角度展开分析: 检查点通常出现在时钟中断 tick 时刻 或者任务唤醒的时候, 判断是否需要重新排列队列; 如果发现需要重排, 就设置一个标志位 need_resched, 并不立即执行重排操作, 而是在下一个最邻近 的抢 点执行重排; 而真正的重排操作则会在相应的中断返回到用户态后才执行。此外, 我们还深入研究了主动 抢 核的具体实现方法。
