《Linux6.5源码分析:进程管理与调度系列文章》
本系列文章将对进程管理与调度进行知识梳理与源码分析,重点放在linux源码分析上,并结合eBPF程序对内核中进程调度机制进行数据实时拿取与分析。
在进行正式介绍之前,有必要对文章引用进行提前说明。本系列文章参考了大量的博客、文章以及书籍:
-
《深入理解Linux内核》
-
《Linux操作系统原理与应用》
-
《奔跑吧Linux内核》
-
《深入理解Linux进程与内存》
-
《基于龙芯的Linux内核探索解析》
Linux进程调度与管理:(三)进程的调度之调度时机
在前面的文章中我们了解了进程是如何被fork系列接口创建出来的(Linux 进程管理与调度:(一)进程的创建与销毁)、如何被exec系列接口赋予可执行程序的(Linux进程调度与管理:(二)进程的加载与启动-优快云博客),至此一个完整的进程被创造了出来;接下来就是这个进程在CPU处理器上运行自己的程序了,但是系统中存在着成百上千的进程、线程,CPU核心却只有那么几个,哪些进程应该运行、哪些进程应该先运行,这都是内核开发者需要考虑的;针对以上问题,进程调度管理应运而生,系统通过定义不同的调度器对系统中的进程进行管理;
本篇文章将简单介绍一下进程调度的基础知识,包括进程何时加入就绪队列、进程何时真正被调度;
何时加入就绪队列?
进程会在被唤醒时加入就绪队列,系统中的唤醒时机有两个:
- 新进程被唤醒时,会通过
wake_up_new_task
唤醒新进程并将其加入指定的就绪队列; - 阻塞进程被唤醒时,会通过
wake_up_process
->try_to_wake_up
唤醒阻塞进程并将其加入就绪队列;
何时真正调度?
被加入就绪队列的进程何时被调度?也即CPU何时重新切换要运行的进程?
进程调度分为主动调度和被动调度
- 主动调度就是:运行在CPU上的进程主动放弃CPU,主动下CPU并申请其他进程上CPU(调用schedule()函数执行进程切换),比如进程由于等待IO资源而不得不转入睡眠状态;
- 被动调度就是:被动调度是指调度器在任务没有主动让出 CPU 的情况下,由外部事件或条件强制触发调度。这种调度通常是由于更高优先级的任务需要运行,或者外部中断改变了系统的调度需求。
1. 进程加入就绪队列
进程是怎么被放入就绪队列的呢? 通过进程唤醒来放入, 包括了将新创建的进程唤醒并放入就绪队列、将阻塞的进程唤醒并放入就绪队列;本小节将对这两部分进行介绍,先大致的看一下进程唤醒流程:
我们可以看到新老进程的唤醒最终都会通过active_task
将要唤醒进程放入对应的就绪队列中,并通过check_preempt_wakeup
检查当前进程是否需要进行抢占操作(抢占过程将在后面的文章中介绍);
1.1 唤醒新进程
在之前的文章中([Linux 进程管理与调度:(一)进程的创建与销毁](Linux 进程管理与调度:(一)进程的创建与销毁-优快云博客)),我们介绍了当新进程被创建时,会先通过process_copy
为新进程复制一个task_struct
并初始化,再通过wake_up_new_task
唤醒新进程,在唤醒的过程中,会为新进程选择一个合适的CPU及就绪队列,并通过activate_task
将进程加入就绪队列所对应的红黑树中。在加入就绪队列后,会触发check_preempt_curr
来判断是否需要触发抢占(这部分内容会在抢占触发时机小节中详细介绍)。
1.1.1 sched_fork
在进入唤醒新进程之前,我们需要看一下cpopy_process() -> sched_fork(),该函数为新进程的调度做了初始化
通过__sched_fork
初始化调度相关的结构体,并将当前新进程的运行状态改为TASK_NEW
,保证新进程在未准备好之前不会被调度。同时,该函数会将新进程的优先级重置为当前进程的默认优先级,防止继承不必要的 PI 提升。接着,根据需要重置调度策略与优先级,并选择合适的调度类(如实时或公平调度类),注意,在这里为新进程选择了调度类,这为该进程的调度打下了基础。最后,它还完成了与负载权重、抢占计数以及 SMP 下的相关初始化工作,从而确保新进程在加入调度器时处于正确且一致的状态。
int sched_fork(unsigned long clone_flags, struct task_struct *p)
{
/*1.初始化调度相关结构体*/
__sched_fork(clone_flags, p);
/*2. 将新进程标记为 TASK_NEW。
* 这样做保证了该进程在真正运行前不会被调度,
* 同时防止信号或其他外部事件唤醒它并将其插入到运行队列中。
*/
p->__state = TASK_NEW;
/*3. 重置进程优先级
* 为了防止将 PI(优先级继承)提升的优先级遗留给子进程,
* 在 fork 后将子进程的 prio 设置为当前进程的 normal_prio。
*/
/*4. 如果请求在 fork 时恢复到默认的优先级/策略,则进行相关重置:*/
/*5. 根据新进程的优先级选择相应的调度类:
* 如果是 deadline 优先级,则返回 -EAGAIN(表示 fork 失败或需要重试);
* 如果是实时优先级,则设置为实时调度类;
* 否则,设置为公平调度类。
*/
if (dl_prio(p->prio))
return -EAGAIN;
else if (rt_prio(p->prio))
p->sched_class = &rt_sched_class;
else
p->sched_class = &fair_sched_class;
/* 初始化该进程调度实体的平均可运行性数据 */
init_entity_runnable_average(&p->se);
/*6. 初始化任务的抢占计数器 */
init_task_preempt_count(p);
}
至此,sched_fork为新进程设置了TASK_NEW状态、并选择了对应的调度类;
1.1.2 wake_up_new_task 唤醒新进程
在为新进程创建了task_struct结构体并初始化调度器之后,新进程便获得了一个崭新的躯体,接下来就是将这个新进程唤醒并等待CPU调度。唤醒新进程主要分一下几步:
- select_task_rq: 为新进程选择一个合适的运行队列;
- _set_task_cpu: 使用选择好的CPU;
- activate_task: 将进程放入已经选择的就绪队列中;
- check_preempt_curr:判断是否需要触发抢占,需要的话对相应的抢占标志进行标记(将在下面的小节中详细介绍如何抢占);
我们看一下源码实现:
void wake_up_new_task(struct task_struct *p)
{
/*1.将任务的状态改为运行态*/
WRITE_ONCE(p->__state, TASK_RUNNING);
/*2.为进程指定运行队列*/
__set_task_cpu(p, select_task_rq(p, task_cpu(p), WF_FORK));
/*3.获取任务所属的运行队列*/
rq = __task_rq_lock(p, &rf);
/*4.将任务放到运行队列中*/
activate_task(rq, p, ENQUEUE_NOCLOCK);
/*5.检查新任务是否需要抢占当前正在运行的任务*/
check_preempt_curr(rq, p, WF_FORK);
}
本小节将围绕唤醒新进程三大步骤(选择就绪队列、放入就绪队列、是否抢占)中的前两步进行详细介绍,并于抢占触发时机小节介绍如何执行抢占的;
1.1.2.1 select_task_rq 选择就绪队列
该函数用于为指定任务选择一个合适的 CPU, 通过调用该进程所属调度类下对应的select_task_rq
进行CPU的选取。
/*
* 调度器相关调用者(如 fork、wakeup)在调用该函数时,
* 已经持有 p->pi_lock 锁,并且 p->cpus_ptr 表示的 CPU 集是稳定的。
* 本函数的主要作用是为任务选择一个合适的 CPU。
*/
static inline
int select_task_rq(struct task_struct *p, int cpu, int wake_flags)
{
/*
* 如果任务允许运行在多个 CPU 上(nr_cpus_allowed > 1),
* 且任务允许迁移(!is_migration_disabled(p)),则调用任务所属调度类的
* select_task_rq 方法来选择合适的 CPU。
* 否则(任务仅允许在单个 CPU 上运行或迁移被禁用),直接从任务允许的 CPU 掩码中任选一个CPU
*/
if (p->nr_cpus_allowed > 1 && !is_migration_disabled(p))
cpu = p->sched_class->select_task_rq(p, cpu, wake_flags);
else
cpu = cpumask_any(p->cpus_ptr);
/*
* 为了避免在处于阻塞状态的任务上调用 set_task_cpu(),
* 我们依赖于 ttwu()(即 "tickless wakeup")将任务放置到一个合法的 CPU 上,
* 该 CPU 必须属于任务允许运行的 CPU 集(->cpus_ptr)。
*
* 由于这一约束适用于所有的任务放置策略,因此在这里进行统一处理。
* [这样,调度类中的 ->select_task() 只需简单返回 task_cpu(p),
* 而不用担心额外的合法性检查。]
*/
if (unlikely(!is_cpu_allowed(p, cpu)))
cpu = select_fallback_rq(task_cpu(p), p);
}
在选择合适的CPU时涉及到了wake_affine机制,也就是尽量优先选择要唤醒进程上次使用的CPU逻辑核,或者唤醒他的进程所在核(当前的逻辑核),因为在这两个核上的缓存中大概率还是“热的”,进程调度上去之后,会运行的比较快。
所以在快速路径中,会尝试选择上次使用过或当前CPU;慢速路径则是用过调用find_idlest_group
函数选择负载最小的组,再从该组中选择一个负载最小的CPU做为最终目标CPU;
/*
* select_task_rq_fair: 为唤醒的任务选择目标运行队列,适用于设置了相关 SD 标志的调度域。
* 在实践中,这些标志通常是 SD_BALANCE_WAKE、SD_BALANCE_FORK 或 SD_BALANCE_EXEC。
*
* 通过选择最空闲组中的最空闲 CPU 来平衡负载,或者在特定条件下,如果调度域设置了
* SD_WAKE_AFFINE 标志,则选择一个空闲的兄弟 CPU。
*
* 返回目标 CPU 的编号。
*/
static int
select_task_rq_fair(struct task_struct *p, int prev_cpu, int wake_flags)
{
//1. 遍历当前 CPU 的所有调度域
for_each_domain(cpu, tmp) {
/*
* 关键步骤 1:唤醒亲和性检查
* 如果需要唤醒亲和性,且当前调度域支持 SD_WAKE_AFFINE,
* 并且 prev_cpu 在此调度域内,则优先选择与唤醒亲和性相关的 CPU
*/
if (want_affine && (tmp->flags & SD_WAKE_AFFINE) &&
cpumask_test_cpu(prev_cpu, sched_domain_span(tmp))) {
if (cpu != prev_cpu)
/*调用 wake_affine 选择 CPU*/
new_cpu = wake_affine(tmp, p, cpu, prev_cpu, sync);
sd = NULL; // 优先考虑唤醒亲和性,忽略负载均衡标志
break; // 找到后退出循环
}
/*
* 如果调度域的标志与 sd_flag 匹配,则记录当前调度域
* 通常仅对 WF_EXEC 和 WF_FORK 为真,因为调度域通常不设置 SD_BALANCE_WAKE
*/
if (tmp->flags & sd_flag)
sd = tmp;
else if (!want_affine)
break; // 如果不需要唤醒亲和性,且未匹配 sd_flag,则退出循环
}
// 关键步骤 2:负载均衡路径选择
// 根据 sd 是否为空选择慢速路径或快速路径
if (unlikely(sd)) {
/* 慢速路径:寻找最空闲的 CPU */
new_cpu = find_idlest_cpu(sd, p, cpu, prev_cpu, sd_flag);
} else if (wake_flags & WF_TTWU) { /* XXX always ? */
/* 快速路径:选择空闲的兄弟 CPU */
new_cpu = select_idle_sibling(p, prev_cpu, new_cpu);
}
rcu_read_unlock(); // 解锁
return new_cpu; // 返回最终选择的 CPU
}
1.1.2.2 activate_task 进程加入就绪队列
先说结论,再上源码流程:
在为进程选择了合适的CPU及就绪队列之后,我们就需要将其加入到该就绪队列中。通过调用关系wake_up_new_task
-> activate_task
-> enqueue_task
-> enqueue_task_fair
-> enqueue_entity
->rb_add_cached
,最终将当前进程加入到指定就绪队列的红黑树中;我们从enqueue_entity
函数开始进行逐步分析:
enqueue_entity
是 CFS 调度器中将调度实体加入运行队列的关键函数。它不仅完成实体的插入(通过红黑树),还处理虚拟运行时间调整、负载更新、统计维护和节流检查。
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
/*1. 如果当前进程正在运行 且 需要调整虚拟时间
* 则在原本虚拟时间的基础上 增加最小虚拟时间;
*/
if (renorm && curr)
se->vruntime += cfs_rq->min_vruntime;/*计算当前调度实体的虚拟时间*/
/*2. 更新运行队列的当前时间*/
update_curr(cfs_rq);
/*3. 如果该进程是迁移进程或非唤醒进程,且未在运行
* 则重新计算虚拟时间
*/
if (renorm && !curr)
se->vruntime += cfs_rq->min_vruntime;
/*4. 更新负载及权重*/
/*5. 处理唤醒进程
* 对唤醒进程的虚拟时间做调整;
* 具体做法见place_entity
*/
if (flags & ENQUEUE_WAKEUP)
place_entity(cfs_rq, se, 0);/*虚拟时间做调整*/
/*6. 处理迁移进程
* 任务开始执行的时间戳归零
*/
if (flags & ENQUEUE_MIGRATED)
se->exec_start = 0;
/*7. 将实体加入就绪队列红黑树*/
if (!curr)
__enqueue_entity(cfs_rq, se);/*最终插入红黑树*/
}
我们可以看到,在将进程加入就绪队列之前,会根据情况通过增加或减少虚拟时间来对该进程进行相应的惩罚或奖励,以保证调度的公平性。具体调整见place_entity()
函数:
- 当处理新任务时,增加 vruntime,延迟执行,保证公平性。如果启用了 START_DEBIT 特性,place_entity 函数会将新任务的 vruntime 增加一个调度时间片(由 sched_vslice 计算得出)。这样做是为了故意调高新任务的 vruntime,防止其立即抢占 CPU,从而保护现有任务的公平性。新任务因此被放置在当前调度周期的末尾,避免对正在运行的任务造成不公平的干扰。
- 对于唤醒任务,根据阈值减少 vruntime,提升响应性,同时针对空闲任务或温和公平性进行微调。函数会根据任务的类型(空闲或非空闲)选择一个阈值(thresh),并从任务的 vruntime 中减去该阈值。空闲任务使用较小的 sysctl_sched_min_granularity 作为阈值,而非空闲任务则使用 sysctl_sched_latency。如果启用了 GENTLE_FAIR_SLEEPERS 特性,阈值会减半。通过减小 vruntime,唤醒任务获得适度的优先级提升,以改善其响应性,但这种提升是有限的,以避免破坏整体调度公平性。
- 长时间睡眠者:对齐到 min_vruntime,处理极端情况。对于长时间睡眠的任务,place_entity 函数直接将调整后的 vruntime(即调度队列的 cfs_rq->min_vruntime)赋给该任务的调度实体。这是因为长时间睡眠可能导致任务的 vruntime 与队列基准相差过大,可能引发算术溢出或不公平调度的问题。通过重置 vruntime 到队列基准值,确保长时间睡眠者与当前队列保持一致,避免极端情况下的调度异常。
- 普通情况:取最大值,保留历史上下文并避免不公平提升。在普通情况下,函数会将调度实体的 vruntime 设置为当前实体原有 vruntime 和调整后 vruntime 的最大值。这样做保留了实体的历史执行记录,防止因过小的 vruntime 而获得不合理的优先级提升。这种方式确保了实体不会因调整而获得过大的调度优势,同时保持与队列基准的一致性。
static void
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
/*
* 当一个新任务被加入到调度队列中时,
* 当前调度周期的时间已经分配给了当前正在运行的任务。
* 新任务的加入会增加调度队列的总权重,从而影响当前任务的运行时间。
* 为了尽量减少对当前任务的影响,新任务会被放置在当前调度周期末尾的空闲时间槽中。
* 这样做可以确保当前任务能够尽可能地完成其预定的运行时间,而新任务则在剩余的时间内运行
*/
/*1. 处理新任务
* 通过 sched_vslice(cfs_rq, se) 计算调度实体在一个调度周期内应得的虚拟时间片;
* 将这个时间片加到 vruntime 上
*/
if (initial && sched_feat(START_DEBIT))
vruntime += sched_vslice(cfs_rq, se);
/*2. 处理唤醒任务
* 通过降低 vruntime,给唤醒任务适度优先级,
* 使其更快获得 CPU 执行机会
* 设计思想是“睡眠时间不超过一个调度延迟的不予过度惩罚”
*/
/* sleeps up to a single latency don't count. */
if (!initial) {
unsigned long thresh;
if (se_is_idle(se))
thresh = sysctl_sched_min_granularity;
else
thresh = sysctl_sched_latency;
/*
* Halve their sleep time's effect, to allow
* for a gentler effect of sleepers:
*/
if (sched_feat(GENTLE_FAIR_SLEEPERS))
thresh >>= 1;
vruntime -= thresh;
}
/*3. 处理长时间睡眠的进程
* 如果被唤醒的进程睡眠很长时间,
* 则将当前运行队列最小虚拟时间给她
*/
if (entity_is_long_sleeper(se))
se->vruntime = vruntime;
else
se->vruntime = max_vruntime(se->vruntime, vruntime);
}
在重新计算完虚拟时间之后,会调用_enqueue_entity()
函数将进程加入就绪队列中。
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
/*将一个调度实体插入红黑树*/
rb_add_cached(&se->run_node, &cfs_rq->tasks_timeline, __entity_less);
}
1.2 阻塞进程的唤醒
阻塞进程(老进程)的唤醒和新进程的唤醒工作类似,都是先通过select_task_rq
选择一个就绪队列,再通过activate_task函数将进程加入到就绪队列对应的红黑树中。这两个函数的实现方法及步骤在上一小节中有介绍。这里给出函数调用关系图:
2. 真正的调度
前面我们讲了何时以及如何将进程放入相应的就绪队列中,但每个cpu核心所对应的就绪队列中包含着若干个等待调度的进程,这些进程何时能获得CPU资源,如何获得,又是一个值得探讨的问题;我们将进程调度分为主动调度和被动调度,本小结将围绕主动调度与被动调度展开;
2.1 主动调度
- 当前进程主动让出CPU,叫做主动调度;
主动调度,顾名思义,调度是进程主动触发的。对于主动调度,触发调度和执行调度是同步的、一体的,触发即执行。主动调度发生的时机有IO等待、加锁失败等各种阻塞操作以及用户空间主动调用sched_yield。
主动调度又可以分为自愿性主动调度和非自愿性主动调度。自愿性主动调度是指进程主动调用sched_yield让出CPU,进程本身还会回到运行队列中去等待下次调度。如果运行队列中没有其它进程的话,此进程还会继续运行。非自愿性主动调度是指进程运行时遇到了无法继续运行的情况,只能进行调度让其它进程运行。进程无法继续运行的情况有加锁失败、要读的文件现在不在内存中、进程死亡等。
对于主动调度,会通过yield
系统调用,主动申请执行schedule()
函数,去进行进程上下CPU的切换;看一下源码:
首先会通过系统调用sched_yield
陷入内核,执行do_sched_yield
,并在do_sched_yield
该函数中触发schedule()
进程切换;
SYSCALL_DEFINE0(sched_yield)
{
do_sched_yield();
return 0;
}
/*调用schedule()进行进程切换*/
static void do_sched_yield(void)
{
/*1. 获取当前 CPU 的运行队列并加锁,同时禁用中断*/
/*2. 调用当前任务的调度类(sched_class)中的 yield_task 方法*/
/*3. 禁用抢占,确保接下来的解锁和调度操作不被中断*/
/*4. 启用抢占,但不立即触发重新调度*/
/*5. 调用调度器,触发任务切换*/
schedule();
}
我们可以看到do_sched_yield
函数会调用schedule()
函数进行主动进程切换; schedule()
函数则会通过调用__schedule()
函数进行进程的切换;
asmlinkage __visible void __sched schedule(void)
{
/*1. 获取当前任务的指针*/
/*2. 提交调度工作给内核*/
/*3. 进入调度循环,直到不再需要调度 */
do {
/*3.1 禁用抢占 */
/*3.2 执行调度 */
__schedule(false);
/*3.3 启用抢占但不重新调度*/
} while (need_resched());
/*4. 更新工作线程*/
}
在__schedule()
函数中,首先会选择下一个要上CPU的进程, 其次会进行进程上下文切换工作,在执行完这两步之后, 便完成了主动调度(主动切换进程)工作, __schedule()
如何选择下一个要运行的进程,以及如何切换上下文,将在后面的文章中详细介绍.
至此,我们大致的过了一下主动调度, 如果在进程调度管理中只有主动调度, 那么无法约束哪些长期霸占CPU资源不主动执行schedule的进程,这便需要被动调度的约束(有的进程不体面,系统帮他体面);
关于schedule函数是如何选择下一个运行的进程已经如何进行上下文切换的,可参考这篇文章: Linux进程调度与管理:(四)进程的调度之schedule进程切换
2.2 被动调度
- 当前进程被动让出CPU,叫做被动调度,也即进程抢占
为了解决单个进程长时间占用CPU而不进行主动调度的情况,内核提供了被动调度,可以强制执行进程调度,被动调度的实现依赖于中断机制。中断通过在正常执行流程中强制插入一段代码,改变了程序后续的执行路径。借助中断机制,我们可以设置一个定时器中断,例如每隔 10 毫秒触发一次,用于检查进程的运行时间是否过长。如果发现某个进程占用时间超出预期,就会启动调度过程。这样,任何进程都无法独占 CPU,从而保证所有进程能够公平地分配到 CPU 时间。
被动调度不像主动调度那样,一旦触发主动调度就会执行调度操作;被动调度涉及到被动调度的触发时机(下文统称抢占触发时机)以及被动调度的执行时机(下文统称抢占执行时机)。
- 抢占触发时机:也即何时标记抢占标志;
- 抢占执行时机:也即何止执行抢占操作,即何时执行进程切换;
2.2.1 抢占触发时机
抢占触发时机:
- 新进程被创建时;
- 阻塞进程被唤醒时;
- 时钟中断触发调度节拍时;
- 负载均衡迁移进程时;
- 更改进程优先级nice时
上述的五个抢占触发时机中,最终均是通过调用resched_curr()
函数,更改进程 task_struct
下面thread_info->flag
为TIF_NEED_RESCHED
;再设置内核抢占标志位,告诉系统需要调度了。我们首先看一下触发抢占时,是如何进行标记的:
首先会检查相关的标志是否已经被标记,如果已经被标记,则直接退出。对于要抢占当前CPU所对应的就绪队列的情况,先通过set_tsk_need_resched(curr)
函数将thread_info->flag
标记为TIF_NEED_RESCHED
,再通过set_preempt_need_resched()
设置内核抢占位,等下一次抢占执行时机被触发时,执行抢占操作;对于要抢占远端CPU对应的就绪队列,则先用过set_nr_and_not_polling
标记当前进程的thread_info->flag
,再通过smp_send_reschedule(cpu)
告知远端CPU触发调度操作;
void resched_curr(struct rq *rq)
{
struct task_struct *curr = rq->curr;//就绪队列当前进程
int cpu;
lockdep_assert_rq_held(rq);//确保运行队列被锁定;
/*1.检查当前任务是否已经被标记为需要重新调度,防止重复标记*/
if (test_tsk_need_resched(curr))
return;
/*2.重新调度相关工作:
* 2.1运行队列所属cpu是当前cpu,即处理本地cpu情况:
* set_tsk_need_resched(curr)更改 task_struct下面thread_info->flag为TIF_NEED_RESCHED;
* set_preempt_need_resched()设置内核的抢占标志位,允许调度器在下一次中断时触发任务切换
*/
cpu = cpu_of(rq);
if (cpu == smp_processor_id()) {
set_tsk_need_resched(curr);
set_preempt_need_resched();
return;
}
/*2.重新调度相关工作:
* 2.2处理远程CPU情况:
* set_nr_and_not_polling(curr)标记当前任务为TASK_RUNNING并判断目标CPU是否是空闲轮询状态
* smp_send_reschedule(cpu)发送信号,通知目标 CPU 触发调度操作。
*/
if (set_nr_and_not_polling(curr))
smp_send_reschedule(cpu);
else
trace_sched_wake_idle_without_ipi(cpu);
}
接下来我们针对这五种抢占触发时机进行深入介绍,深入源码看一下他们是如何触发抢占的;在第一节中已经对新老进程被唤醒时如何加入就绪队列进行梳理与分析,接着这部分内容,我们看一下新老进程在被唤醒时,是如何触发抢占时机的;更改进程优先级时触发抢占的情况,我们将在下一小节介绍,对于系统在进行调度节拍及负载均衡时所触发的抢占时机将在以下这两篇文章中详细介绍;
2.2.1.1 唤醒进程时触发抢占时机
在第一章中,我们介绍了何时将进程加入就绪队列:新老进程被唤醒时会加入就绪队列。在将被唤醒的进程加入就绪队列之后,标志着进程在不久的将来就可以上CPU运行了。但对于部分被唤醒的进程来说,可能需要马上上CPU执行,这就需要在进程加入就绪队列之后,问一下这些进程:着不着急呀?要不要马上上CPU呀?要不要抢CPU资源呀?
基于这种思路,实现了check_preempt_curr
函数, 该函数会检查是否需要抢占,如果需要抢占CPU资源,那么就调用resched_curr()
函数标记进程和内核抢占标志位,等待下一个抢占执行时机被触发时,调用schedule()接口完成进程的切换工作。
本小节我们来看一下check_preempt_curr()
函数是如何实现的:
- 被唤醒的进程 与 所在就绪队列上运行的进程调度类相同:调用调度类中的
check_preempt_curr
函数执行; - 被唤醒的进程所属调度类 优于 当前就绪队列所属调度类:调用
resched_curr
(前面有涉及)直接标记抢占标志位;
/*check_preempt_curr()
*1.检查新任务是否需要抢占当前正在运行的任务,
* 以确保调度器能够正确响应高优先级任务的到来。
*2.该函数基于任务的调度类和优先级来决定是否需要进行抢占,
* 或设置相关标志以通知调度器在下次时钟中断时进行任务切换。
*/
void check_preempt_curr(struct rq *rq, struct task_struct *p, int flags)
{
/*1.判断新进程和当前运行队列正在运行的任务的调度类是否相同*/
if (p->sched_class == rq->curr->sched_class)
/*若二者调度类相同,则调用该调度类的check_preempt_curr函数去检查是否需要抢占当前任务*/
rq->curr->sched_class->check_preempt_curr(rq, p, flags);
else if (sched_class_above(p->sched_class, rq->curr->sched_class))
/*若新进程所属调度类优于当前运行队列调度类,则调用resched_curr强制重新调度*/
resched_curr(rq);
/*2. 如果当前任务已经被标记为需要重新调度,
* 并且还在运行队列中,那么可以跳过不必要的时钟更新操作
*/
if (task_on_rq_queued(rq->curr) && test_tsk_need_resched(rq->curr))
rq_clock_skip_update(rq);
}
再看一下cfs调度类下所对应的check_preempt_curr
:check_preempt_wakeup
函数。该函数会比较被唤醒的任务与正在运行的任务,判断是否需要触发抢占;最终还是通过resched_curr()
设置抢占标志位。
/*
* Preempt the current task with a newly woken task if needed:
* 如果需要,用新唤醒的任务抢占当前任务。
*/
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
{
/*1. 如果当前任务和新唤醒任务是同一个(调度实体相同),无需抢占,直接返回 */
if (unlikely(se == pse))
return;
/*
*2. 如果当前任务已设置TIF_NEED_RESCHED标志(表示需要重新调度),
* 直接返回,避免重复设置抢占标志。同时处理当前任务在节流组的边缘情况。
*/
if (test_tsk_need_resched(curr))
return;
/*
*3. 如果当前任务是空闲任务(SCHED_IDLE策略),而新任务不是空闲任务,
* 则立即触发抢占,因为空闲任务优先级最低。
*/
if (unlikely(task_has_idle_policy(curr)) &&
likely(!task_has_idle_policy(p)))
goto preempt;
/*
*4. 批处理任务(SCHED_BATCH)和空闲任务不会抢占非空闲任务,它们的抢占由时钟tick驱动。
* 如果未启用WAKEUP_PREEMPTION特性,也不进行唤醒抢占。
*/
if (unlikely(p->policy != SCHED_NORMAL) || !sched_feat(WAKEUP_PREEMPTION))
return;
/*
*5. 如果当前调度实体是空闲的,而新任务不是,则触发抢占。
* 如果两者空闲状态不同且不满足上述条件,则返回。
*/
if (cse_is_idle && !pse_is_idle)
goto preempt;
if (cse_is_idle != pse_is_idle)
return;
/*6. 更新当前任务的虚拟运行时间(vruntime),确保公平性 */
/*
*7. 比较当前任务和新任务的vruntime,决定是否抢占。
* 如果wakeup_preempt_entity返回1,表示新任务优先级更高。
*/
if (wakeup_preempt_entity(se, pse) == 1) {
/*
* 如果尚未设置next_buddy,将新任务设置为next_buddy,
* 提示调度器下次优先选择它。
*/
if (!next_buddy_marked)
set_next_buddy(pse);
goto preempt;
}
return;
preempt:
/*8. 标记当前任务需要重新调度,触发抢占*/
resched_curr(rq);
}
2.2.1.2 更改nice触发抢占时机
本小节将介绍一下更改进程优先级时触发抢占的情况,先上一张图看一下调用关系:
当用户通过nice接口更改进程优先级时,会调用nice系统调用陷入内核:
/* nice系统调用入口 */
SYSCALL_DEFINE1(nice, int, increment)
{
/*1. 计算新nice值,并限制在允许范围内*/
/*2. 设置当前进程的nice值为新计算的值*/
set_user_nice(current, nice);
return 0;
}
看一下set_user_nice()
, 该函数通过nice计算进程的优先级、负载权重,最终调用所属调度类中的prio_changed()
函数 更改优先级;所以我们把重点放到prio_changed()
函数中;
/*更改进程nice值,调整优先级*/
void set_user_nice(struct task_struct *p, long nice)
{
...
/*5. 根据nice值计算并更新相关内容
* 其优先级 static_prio
* 任务的负载权重
* 新任务的有效优先级
*/
/*7. 调用所属调度类下的 prio_changed函数进行*/
p->sched_class->prio_changed(rq, p, old_prio);
}
我们看一下prio_changed()
函数,该函数会根据目标进程(需要调整优先级的进程)的运行情况以及调整优先级的情况来分情况处理,对于目标进程正在CPU上运行且需要将其优先级降低,则调用resched_curr
标记抢占位等待下一个抢占执行时机进行抢占操作;对于目标进程并未在CPU上,则调用check_preempt_curr()
函数检查是否应当触发抢占操作;
/*
* 当任务的优先级发生变化时,检查是否需要抢占当前任务。
*/
static void prio_changed_fair(struct rq *rq, struct task_struct *p, int oldprio)
{
/*1. 如果任务不在运行队列中,则无需检查,直接返回*/
/*2. 如果运行队列中只有一个任务,则没有其他任务可抢占,也无需重新调度*/
/*3. 更改优先级,并判断是否需要抢占或重新调度;
* 3.1 如果任务正在当前运行队列上运行:
* - 当任务优先级下降(即p->prio值增大)时,当前任务需要重新调度,
* 以便让其他优先级更高的任务获得CPU
* 3.2 如果任务不在当前运行队列上运行:
* - 检查是否应当抢占当前运行的任务
*/
if (task_current(rq, p)) {
if (p->prio > oldprio)
resched_curr(rq);
} else
check_preempt_curr(rq, p, 0);
}
我们可以注意到,当用户通过nice更改莫伊进程得优先级时,最终会根据进程的运行即要调整优先级情况判断是否要更改抢占标记。
2.2.2 抢占执行时机
在抢占触发时机中,我们将 要抢占的进程中的抢占标志位TIF_NEED_RESCHED进行标记、并设置了内核的抢占标志位。接下来的工作就是等待下一次抢占执行时机的到来,执行调度时机是系统会在某些特定的点去检查调度标记,如果被设置的话就执行调度
抢占执行时机:
- 系统调用返回用户空间时;
- 中断返回用户空间时;
- 中断返回内核空间时;
- 禁用抢占临界区结束时,即关抢占结束重新开抢占时;
- 禁用软中断临界区结束时;
cond_resched
调用点
被动调度中的抢占执行时机,与主动调度类似,最终都是通过schedule() 进行进程的选择和上下文切换,具体细节将在下一篇文章中介绍Linux进程调度与管理:(四)进程的调度之进程切换;本小节将通过源码展示的方式介绍以上六种抢占执行时机。
2.2.2.1 系统调用返回用户空间时
系统调用完成之后返回用户空间之前会检测thread_info flag
中的_TIF_NEED_RESCHED
,如果设置了就会执行调度。
我们从exit_to_user_mode_prepare()
开始看,在系统调用返回用户空间之前,会检查thread_flags标志,也就是我们在抢占触发时机时所标记的标志;
static void exit_to_user_mode_prepare(struct pt_regs *regs)
{
...
ti_work = read_thread_flags();
/*返回用户空间前,检查thread_flags*/
if (unlikely(ti_work & EXIT_TO_USER_MODE_WORK))
ti_work = exit_to_user_mode_loop(regs, ti_work);
...
}
在exit_to_user_mode_loop(regs, ti_work)
函数中会检查thread_info
标志,并最终检查该标志中是否标记了_TIF_NEED_RESCHED
,如果标记了,则调用schedule()
函数重新调度;
static unsigned long exit_to_user_mode_loop(struct pt_regs *regs,
unsigned long ti_work)
{
while (ti_work & EXIT_TO_USER_MODE_WORK) {
local_irq_enable_exit_to_user(ti_work);
/*如果标记了需要抢占,则重新调度*/
if (ti_work & _TIF_NEED_RESCHED)
schedule();
/*其他标志位检查*/
...
}
}
2.2.2.2 中断返回用户空间时
所有中断和异常的入口函数, 其中一定会调用irqentry_exit
来恢复中断前保存的状态,并退出中断处理,看一下调用关系图:
我们来看一下源码,在中断入口函数中,会在处理完中断并返回用户空间时, 调用irqentry_exit
函数, 并在执行该函数时检查抢占标志位:
/**
* DEFINE_IDTENTRY_IRQ - 为设备中断的 IDT 入口点生成相应代码
* @func: 入口函数的名称
*
* 说明:
* 1. 在低级中断入口处,向堆栈中压入中断向量号,并作为 error_code 参数传递给该函数。
* 由于 error_code 的压栈过程涉及符号扩展,因此需要将其截断为 8 位 (u8) 后再转换为 u32。
*
* 2. 在进入函数体前,会调用 irqentry_enter() 来进行中断入口相关状态的保存,
* 同时调用 irqentry_exit() 来在退出时恢复状态。此外,还会调用 kvm_set_cpu_l1tf_flush_l1d()
* 来设置 KVM L1D flush 请求。
*
* 3. 如果有必要,中断处理过程中会进行栈切换,切换到专用的中断栈,这一过程由 run_irq_on_irqstack_cond()
* 根据条件来执行。
*
* 4. 该宏定义了两个函数:
* - 一个是公开的入口函数 func(),它包装了中断入口的通用操作(如状态保存、仪表化、KVM flush 等)。
* - 另一个是实际的中断处理函数 __func(),由 func() 调用,该函数应包含具体的中断处理逻辑,
* 并且被标记为 noinline,以防止内联优化。
*/
#define DEFINE_IDTENTRY_IRQ(func) \
/* 声明实际中断处理函数 __func,后续由开发者提供具体实现 */ \
static void __##func(struct pt_regs *regs, u32 vector); \
\
/* 定义中断入口函数 func,公开可见且不进行额外指令插装 */ \
__visible noinstr void func(struct pt_regs *regs, \
unsigned long error_code) \
{ \
/* 进入中断处理,保存中断状态 */ \
irqentry_state_t state = irqentry_enter(regs); \
/* 从 error_code 中截取低 8 位获得中断向量号,并转换为 u32 类型 */ \
u32 vector = (u32)(u8)error_code; \
\
/* 开始仪表化,用于性能监控等目的 */ \
instrumentation_begin(); \
/* 设置 KVM L1D flush 请求,确保 CPU 缓存一致性 */ \
kvm_set_cpu_l1tf_flush_l1d(); \
/* 根据条件判断是否需要切换到专用中断栈,并调用实际中断处理函数 __func */ \
run_irq_on_irqstack_cond(__##func, regs, vector); \
/* 结束仪表化 */ \
instrumentation_end(); \
/* 恢复中断前保存的状态,并退出中断处理 */ \
irqentry_exit(regs, state); \
} \
\
/* 定义实际的中断处理函数 __func,标记为 noinline 防止内联优化 */ \
static noinline void __##func(struct pt_regs *regs, u32 vector)
在irqentry_exit
函数中会通过调用irqentry_exit_to_user_mode
-> exit_to_user_mode_prepare
执行返回用户空间操作,该函数在上一小节有介绍;
/*恢复中断前保存的状态,并退出中断处理*/
noinstr void irqentry_exit(struct pt_regs *regs, irqentry_state_t state)
{
/* Check whether this returns to user mode */
if (user_mode(regs)) {
/*中断返回用户空间*/
irqentry_exit_to_user_mode(regs);
} else if (!regs_irqs_disabled(regs)) {
if (IS_ENABLED(CONFIG_PREEMPTION))
/*中断返回内核空间时*/
irqentry_exit_cond_resched();
...
}
...
}
/*中断返回用户空间*/
noinstr void irqentry_exit_to_user_mode(struct pt_regs *regs)
{
/*返回用户空间*/
exit_to_user_mode_prepare(regs);
}
2.2.2.3 中断返回内核空间时
在中断执行结束,准备退出中断时,会根据寄存器的状态判断返回到用户空间还是内核空间,上文中已经介绍了中断返回用户空间的情况,本小节将接着介绍一下中断返回内核空间的情况;
/*恢复中断前保存的状态,并退出中断处理*/
noinstr void irqentry_exit(struct pt_regs *regs, irqentry_state_t state)
{
/* Check whether this returns to user mode */
if (user_mode(regs)) {
/*中断返回用户空间*/
} else if (!regs_irqs_disabled(regs)) {
if (IS_ENABLED(CONFIG_PREEMPTION))
/*中断返回内核空间时*/
irqentry_exit_cond_resched();
...
}
...
}
/*中断返回内核空间时*/
void raw_irqentry_exit_cond_resched(void)
{
/*内核是否允许抢占*/
if (!preempt_count()) {
/* Sanity check RCU and thread stack */
rcu_irq_exit_check_preempt();
if (IS_ENABLED(CONFIG_DEBUG_ENTRY))
WARN_ON_ONCE(!on_thread_stack());
/*是否需要抢占*/
if (need_resched())
/*内核抢占*/
preempt_schedule_irq();
}
}
该函数最终会调用__schedule(SM_PREEMPT)
重新调度;
/*
* 这是从内核抢占(preemption)上下文下,
* 从 IRQ(中断)上下文调用 schedule() 的入口函数。
*
* 注意:该函数调用和返回时都保持中断关闭状态,
* 这样可以防止在中断上下文中递归调用调度函数。
*/
asmlinkage __visible void __sched preempt_schedule_irq(void)
{
/*
* 循环执行调度操作,直到不再需要调度(need_resched() 返回 false)。
*/
do {
/*1. 禁用抢占,防止在调度过程中再次抢占 */
preempt_disable();
/*2. 临时开启本地中断,以便在 __schedule() 执行期间响应中断 */
local_irq_enable();
/*3. 调用真正的调度函数,参数 SM_PREEMPT 指示这是一次抢占调度 */
__schedule(SM_PREEMPT);
/*4. 调度完成后,重新关闭本地中断 */
local_irq_disable();
/*5. 重新允许抢占,但不触发重新调度 */
sched_preempt_enable_no_resched();
} while (need_resched()); /* 如果仍有调度请求,则继续循环 */
}
2.2.2.4 禁用抢占临界区结束
preempt_disable
增加引用计数,preempt_enable
减少引用计数并检测是否为0,如果为0则调用__preempt_schedule
执行调度。
/*禁用抢占*/
#define preempt_disable() \
do { \
/*preempt_count +1*/
preempt_count_inc(); \
barrier(); \
} while (0)
/* 抢占开启
* 检查是否需要抢占,需要的话,通过__preempt_schedule()执行抢占操作
*/
#define preempt_enable() \
do { \
barrier(); \
/*preempt_count_dec_and_test检查preempt_count标志位是否被置为0(即需要抢占)\
*当前线程的抢占计数(preempt count)减一,并检查减一后的结果是否为零 \
*/
if (unlikely(preempt_count_dec_and_test())) \
/*__preempt_schedule()会检查是否有挂起的抢占请求(TIF_NEED_RESCHED 标志)并触发调度,
*可能会导致当前线程主动让出 CPU,从而实现抢占
*/
__preempt_schedule(); \
} while (0)
preempt_schedule
函数会调用preempt_schedule_common()
执行真正的抢占;
asmlinkage __visible void __sched notrace preempt_schedule(void)
{
if (likely(!preemptible()))
return;
/*抢占调度时会触发调度执行*/
preempt_schedule_common();
}
我们看一下preempt_schedule_common()
函数怎么真正执行抢占的;
static void __sched notrace preempt_schedule_common(void)
{
do {
preempt_disable_notrace();
preempt_latency_start(1);
/*抢占调度执行点*/
__schedule(SM_PREEMPT);
preempt_latency_stop(1);
preempt_enable_no_resched_notrace();
} while (need_resched());
}
2.2.2.5 禁用软中断临界区结束
在禁用软中断结束时,即重启软中断时,会触发preempt_check_resched()
,最终触发preempt_schedule
函数;
/*是否需要抢占*/
#define preempt_check_resched() \
do { \
if (should_resched(0)) \
__preempt_schedule(); \
} while (0)
2.2.2.6 cond_resched调用点
在很多比较耗时的内核操作中都会加上cond_resched调用,用来增加抢占调度的检测点,提高系统的响应性。
我们看一下__cond_resched()
函数是如何调用preempt_schedule_common()
函数的:
int __sched __cond_resched(void)
{
/*1. 检查是否需要重新调度:
* should_resched(0) 会判断当前是否存在需要调度的条件。
* 如果返回 true,则调用 preempt_schedule_common() 执行抢占调度,
* 并返回 1 表示调度发生了。
*/
if (should_resched(0)) {
preempt_schedule_common();
return 1;
}
...
}
至此本篇文章讨论了进程何时加入就绪队列,何时上CPU执行;