Linux进程管理:(三)进程调度

文章说明:

1. 前置知识:进程地址空间ID(AddressSpaceID,ASID)

当进程切换时,为了避免造成数据不—致和系统不稳问题,需要对TLB进行刷新操作(在ARM架构中也称为失效(invalidate)操作)。但是这种方法不太合理,对整个TLB进行刷新操作后,next进程面对一个空白的TLB,因此刚开始执行时会出现很严重的TLB未命中和高速缓存未命中,导致系统性能下降。

如何提高TLB的性能?这是最近几十年来芯片设计人员和操作系统设计人员共同努力解决的问题。从Linux内核角度看,地址空间可以划分为内核地址空间和用户空间,TLB可以分成全局(global)类型和进程独有(process-specific)类型:

  • 全局类型的TLB:内核空间是所有进程共享的空间,因此这部分空间的虚拟地址到物理地址的翻译是不会变化的,可以理解为全局的
  • 进程独有类型的TLB:用户地址空间是每个进程独立的地址空间。从prev进程切换到next进程时, TLB中缓存的prev进程的相关数据对于next进程是无用的,因此可以刷新

为了支持进程独有类型的TLB,ARM架构出现了一种硬件解决方案,叫作进程地址空间ID(Address Space ID,ASID),TLB可以识别哪些TLB项是属于某个进程的。ASID方案让每个TLB表项包含一个ASID,ASID用于标识每个进程的地址空间,TLB命中查询的标准在原来的虚拟地址判断之上,再加上ASID条件。因此有了ASID硬件机制的支持,进程切换不需要刷新整个TLB,即使next进程访问了相同的虚拟地址,prev进程缓存的TLB项也不会影响到next进程,因为ASID机制从硬件上保证了prev进程和next进程的TLB不会产生冲突。

  • 硬件ASID:
    • 对于ARM64架构,ASID的代码实现在 arch/arm64/mm/context.c 文件中。ID_AA64MMFR0_EL1寄存器中的ASlDBits字段显示当前CPU支持多少位宽的ASID。当ASIDBits字段为0时,表示支持8位宽的ASID,也就是最多支持256个ID。当ASIDBits字段为1时,表示支持16位宽的ASID,最多支持65536个ID。
    • 系统初始化时会通过asids_init()函数来初始化ASID。它由get_cpu_asid_bits()函数来判断当前系统支持的ASID位宽,并存储在asidbits变量中
    • 硬件ASID的分配通过位图来管理,分配时通过asid_map位图变量来记录,见asids_init() 函数。
    • 在AArch32状态下,硬件ASID存放在CONTEXTIDR寄存器的ASID域中。在AArch64状态下,硬件ASID存放在TTBR1_EL1中。
    • 在使能了 CONFIG_UNMAP_KERNEL_AT_EL0 配置的内核里为每个进程分配两个ASID,即奇、偶数组成一对。当进程运行在用户态时,使用奇数ASID来查询TLB;当进程陷入内核态运行时,使用偶数ASID来查询TLB。

当系统中所有CPU的硬件ASID加起来超过硬件最大值时会发生溢出,需要刷新全部TLB,然后重新分配硬件ASID,这个过程还需要软件来协同处理。

  • 软件ASID:
    • 全局原子变量asid_generation其中Bit[31:8]用于存放软件管理用的软件generation计数。软件generatjon计数是从ASID_FIRST_VERSION开始计算的,每当硬件ASID溢出时,软件generation计数要加上ASID_FIRST_VERSION(ASID_FIRST_VERSION其实是 1<<asid_bits)。
    • 软件ASID是ARM Linux软件提出的概念,它存放在进程的mm->contextid中,它包括两个域,低8位是硬件ASID,剩余的位是软件generation计数。

当硬件ASID都分配完毕后需要刷新TLB,同时增加软件generation计数,然后重新分配ASID。asid_generation存放在mm->contextid的Bit[31:8]中,调度该进程时需要判断asid-generation是否有变化,从而判断mm->contextid存放的ASID是否有效。

2. schedule()

schedule()是调度器的核心函数,其作用是让调度器选择和切换到一个合适进程并运行。调度的时机可以分为如下3种:

  • 阻塞操作中,如使用互斥量(mutex)、信号量(semaphore)、等待队列(waitqueue)等
  • 在中断返回前和系统调用返回用户空间时,检查TIF_NEED_RESCHED标志位以判断是否需要调度
  • 要被唤醒的进程不会马上调用schedule(),而是会被添加到CFS就绪队列中,并且设置了TIF_NEED_RESCHED标志位

那么被唤醒的进程什么时候被调度呢?这要根据内核是否具有可抢占功能(CONFIG_PREEMT=y)分两种情况:

  • 如果内核可抢占,则根据情况执行不同操作
    • 如果唤醒动作发生在系统调用或者异常处理上下文中,在下一次调用preempt_enable()时会检查是否需要抢占调度
    • 如果唤醒动作发生在硬中断处理上下文中,硬件中断处理返回前会检查是否要抢占当前进程
  • 如果内核不可抢占,则执行以下操作
    • 当前进程调用cond_resched()时会检查是否要调度
    • 主动调用schedule()

在Linux内核里schedule()是内部使用的接口函数,有不少其他函数会直接调用该函数。除此之外,schedule()函数还有不少变种的封装:

  • preempt_schedule() 用于可抢占内核的调度
  • preempt_schedule_irq() 用于可抢占内核的调度,从中断结束返回时调用该函数
  • schedule_timeout(signed long timeout) 用于使进程睡眠,直到超时为止

下面从源码的角度来剖析schedule() 函数:

schedule():

asmlinkage __visible void __sched schedule(void)
{
	struct task_struct *tsk = current;

	sched_submit_work(tsk);
	do {
		// 关闭内核抢占
		preempt_disable();
		// 核心实现
		__schedule(false);
		// 打开内核抢占
		sched_preempt_enable_no_resched();
	} while (need_resched());
}

schedule()->__schedule():

// 调度器的核心函数,作用是选择和切换到一个合适的进程并运行
// preempt 是布尔类型变量,用于表示本次调度是否为抢占调度
// 抢占调度延迟的计算:记录所有进程的开始调度时间,根据 preempt 值(挂载点:trace_sched_switch)只计算抢占调度延迟的时间
static void __sched notrace __schedule(bool preempt)
{
    // 获取当前 CPU
	cpu = smp_processor_id();
	// 由当前 CPU 获取数据结构 rq
	rq = cpu_rq(cpu);
	// prev 指向当前进程
	prev = rq->curr;

	// 用于判断当前进程是否处于 atomic 上下文中
	// 所谓 atomic 上下文包含硬件中断上下文、软中断上下文等
	// 若此时处于 atomic 上下文中,这是一个 bug,那么内核会发出警告并且输出内核函数调用栈(发出的警告是BUG:scheduling while atomic)
	schedule_debug(prev);

	...

	// 关闭本地 CPU 中断
	local_irq_disable();
	...
	// 申请一个自旋锁
	rq_lock(rq, &rf);
	...
	// 用于判断当前进程是否主动请求调度
	// preempt 用于判断本次调度是否为抢占调度,即是否中断返回前夕或者系统调用返回用户空间
	// 前夕发生的抢占调度
	// prev->state 表示当前进程的运行状态,如果当前进程处于运行状态(0),说明此刻正在发生
	// 抢占调度,如果当前进程处于其他状态,说明它主动请求调度,如主动调用 schedule()。通常
	// 主动请求调用之前会提前设置当前进程的运行状态为 TASK_UNINTERRUPTIBLE 或者 TASK_INTERRUPTIBLE
	if (!preempt && prev->state) {
		if (signal_pending_state(prev->state, prev)) {
			prev->state = TASK_RUNNING;
		} else {
			// 把当前进程移出就绪队列
			deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);
			prev->on_rq = 0;

			...
			// 对工作线程池的处理
			if (prev->flags & PF_WQ_WORKER) {
				struct task_struct *to_wakeup;

				// 当一个工作线程要被调度器换出时,调用 wq_worker_sleeping() 看看是否需要唤醒同一个工作线程池中的其他工作线程
				to_wakeup = wq_worker_sleeping(prev);
				// 在唤醒一个工作线程时,需要增加 worker_pool->nr_running 计数值来告诉工作队列机制现在有一个工作线程要唤醒了
				if (to_wakeup)
					try_to_wake_up_local(to_wakeup, &rf);
			}
		}
		switch_count = &prev->nvcsw;
	}

	// 从就绪队列中选择一个最合适的进程 next
	next = pick_next_task(rq, prev, &rf);
	// 清除当前进程的 TIF_NEED_RESCHED 标识位,表示它接下来不会被调度
	clear_tsk_need_resched(prev);
	clear_preempt_need_resched();

	// 若选出的下一个进程和当前进程不是同一个进程,那么可以进行进程的切换
	if (likely(prev != next)) {
		...
		// 一个不错的 tracepoint
		trace_sched_switch(preempt, prev, next);

		// 进行上下文切换
        rq = context_switch(rq, prev, next, &rf);
	} else {
		...
	}

	...
}

schedule()->__schedule()->context_switch():

在这里插入图片描述

// rq 表示进程切换所在的就绪队列
// prev 指将要被换出的进程
// next 指将要被换入的进程
// rf 表示 rq_flags
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
	       struct task_struct *next, struct rq_flags *rf)
{
	struct mm_struct *mm, *oldmm;

	// 设置 next 进程描述符中的 on_cpu 成员为 1,表示 next 进程即将进入执行状态
	prepare_task_switch(rq, prev, next);

	mm = next->mm;
	oldmm = prev->active_mm;
	...
	// 若 next 进程的 mm 成员为空,则说明这是一个内核线程,需要借用 prev 进程的活跃内存描述符 active_mm
	if (!mm) {
		next->active_mm = oldmm;
		// mmgrab() 增加 prev->active_mm 的 mm_count
		mmgrab(oldmm);
		enter_lazy_tlb(oldmm, next);
	} else
		// 对于普通进程,需要做一些进程地址空间切换的处理
		// 实质上是把新进程的页表基地址设置到页表基地址寄存器
		switch_mm_irqs_off(oldmm, mm, next);

	// 处理 prev 进程也是一个内核线程的情况,prev 进程马上就要被换出,因此设置 prev->active_mm 为 NULL
	if (!prev->mm) {
		prev->active_mm = NULL;
		rq->prev_mm = oldmm;
	}

	...
	// 新旧进程的切换点
	// 切换到 next 进程的内核态栈和硬件上下文
	switch_to(prev, next, prev);
	barrier();

	// finish_task_switch 函数是由 next 进程去执行
	// 在 finish_task_switch() 函数中,会递减 mm_count,设置 prev 的 on_cpu 成员为 0,表示 prev 进程已退出执行
	// 状态,相当于由 next 进程来收拾 prev 进程的“残局”
	return finish_task_switch(prev);
}

context_switch()函数可以总结为如下两步:

  • 切换进程的进程地址空间,也就是切换next进程的页表到硬件页表中,这是由switch_mm()函数实现的
  • 切换到next进程的内核态栈和硬件上下文,这是由switch_to()函数实现的。硬件上下文提供了内核执行next进程所需要的所有硬件信息

schedule()->__schedule()->context_switch()->switch_mm_irqs_off()->switch_mm()->__switch_mm->check_and_switch_context():

// 完成与 ARM 架构相关的硬件设置,如刷新 TLB 和设置硬件页表等
void check_and_switch_context(struct mm_struct *mm, unsigned int cpu)
{
	...

	// 通过原子操作读取软件的 ASID
	asid = atomic64_read(&mm->context.id);

	// 读取 Per-CPU 变量的 active_asids
	old_active_asid = atomic64_read(&per_cpu(active_asids, cpu));
	// 判断全局原子变量 asid_generation 存储的软件 generation 计数和进程内存描述符存储的软件 generation 计数是否相同
	// 另外还需要通过 atomic64_cmpxchg() 原子交换指令来设置新的 asid 到 Per-CPU 变量 active_asids 中
	if (old_active_asid &&
	    !((asid ^ atomic64_read(&asid_generation)) >> asid_bits) &&
	    atomic64_cmpxchg_relaxed(&per_cpu(active_asids, cpu),
				     old_active_asid, asid))
		goto switch_mm_fastpath;

	raw_spin_lock_irqsave(&cpu_asid_lock, flags);
	// 重新做一次软件 generation 计数的比较,如果还不相同,说明至少发生了一次 ASID 硬件溢出,需要分配一个新的软件 ASID 计数
	// 并设置到 mm->context.id 中
	asid = atomic64_read(&mm->context.id);
	if ((asid ^ atomic64_read(&asid_generation)) >> asid_bits) {
		asid = new_context(mm);
		atomic64_set(&mm->context.id, asid);
	}

	// 硬件 ASID 发生溢出时,需要刷新本地的 TLB
	if (cpumask_test_and_clear_cpu(cpu, &tlb_flush_pending))
		local_flush_tlb_all();

	...

// switch_mm_fastpath 标签表示换入进程的 ASID 依然属于同一个批次,也就是说还没有发生 ASID 硬件溢出
switch_mm_fastpath:
	arm64_apply_bp_hardening();
	if (!system_uses_ttbr0_pan())
		// 进行页表的切换
		cpu_switch_mm(mm->pgd, mm);
}

ARM64架构的ASID机制如下图所示:

在这里插入图片描述

schedule()->__schedule()->context_switch()->switch_to()切换到 next 进程的内核态栈和硬件上下文的过程如下:

在这里插入图片描述

3. 调度节拍

每当时钟中断发生时,Linux调度器的scheduler_tick()函数会被调用,执行和调度相关的一些操作,如检查是否有进程需要调度和切换。当一个系统时钟中断发生后,函数调用流程如下图所示:

在这里插入图片描述

scheduler_tick()

void scheduler_tick(void)
{
	...

	// 更新当前 CPU 就绪队列(rq)中的时钟计数 clock 和 clock_task 成员
	update_rq_clock(rq);
	// task_tick() 用于处理时钟节拍到来时与调度器相关的事情
	curr->sched_class->task_tick(rq, curr, 0);
	// 用于更新运行队列中的 cpu_load[] 数组
	cpu_load_update_active(rq);
	calc_global_load_tick(rq);
	psi_task_tick(rq);

	...

#ifdef CONFIG_SMP
	rq->idle_balance = idle_cpu(cpu);
	// 触发 SMP 负载均衡机制
	trigger_load_balance(rq);
#endif
}

task_tick()方法在CFS的调度类中的实现函数是task_tick_fair()

static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
	...
	struct sched_entity *se = &curr->se;

	// 如果系统没有实现组调度机制(CONFIG_FAIR_GROUP_SCHED),那么 for_each_sched_entity() 宏仅仅
	// 遍历当前进程的调度实体;如果实现了组调度机制,那么需要遍历进程调度实体及其上一级调度实体
	for_each_sched_entity(se) {
		cfs_rq = cfs_rq_of(se);
		// 检查是否需要调度
		entity_tick(cfs_rq, se, queued);
	}

	...
}

task_tick_fair()->entity_tick:

在这里插入图片描述

  • 22
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值