前面两节讨论了 linux 中进程的睡眠与唤醒机制,也介绍了 linux 内核的 cfs 进程调度方法,知道了哪些进程会被挂起,哪些进程会被投入运行。

不过,我们还不知道 linux 内核在进程调度时,是如何切换进程的的。例如,原本进程 A 正在睡眠,进程 B正在运行,现在要将 A 投入运行,将 B 设置为睡眠状态,这一过程是如何实现的呢?本节将讨论这个问题。

抢占和上下文切换

我们将进程运行时的资源(栈信息、寄存器信息等)称作“上下文”,这么一来,任务抢占就可看作是 linux 内核在切换上下文,上下文切换完毕时,内核也就完成了从一个可执行进程切换到另一个可执行进程。

“上下文”这个名字其实挺贴切的,内核在执行进程时,可以看做是内核在阅读一篇文章,如果有进程需要调度,就相当于内核换了一篇文章读。

那么,linux 内核是如何实现上下文切换的呢?这其实可以从进程调度获得入口,因为上下文切换一定发生在进程调度时,查看 schedule() 函数的 C语言代码:

4141 asmlinkage void __sched schedule(void)
– 4142 {
| 4143 struct task_struct *prev, *next;
| 4144 unsigned long *switch_count;
| 4145 struct rq *rq;
| 4146 int cpu;
| 4147
| 4148 need_resched:
| 4149 preempt_disable();
| 4150 cpu = smp_processor_id();
| 4151 rq = cpu_rq(cpu);
| 4152 rcu_qsctr_inc(cpu);
| 4153 prev = rq->curr;
| 4154 switch_count = &prev->nivcsw;
| 4155
| 4156 release_kernel_lock(prev);
….
|- 4190 if (likely(prev != next)) {
|| 4191 sched_info_switch(prev, next);
|| 4192
|| 4193 rq->nr_switches++;
|| 4194 rq->curr = next;
|| 4195 ++*switch_count;
|| 4196
|| 4197 context_switch(rq, prev, next); /* unlocks the rq */

发现 context_switch() 函数,显然它就是负责上下文切换的函数。它的 C语言代码如下:

2447 static inline void
2448 context_switch(struct rq *rq, struct task_struct *prev,
2449 struct task_struct *next)
– 2450 {
| 2451 struct mm_struct *mm, *oldmm;
| 2452
| 2453 prepare_task_switch(rq, prev, next);
| 2454 mm = next->mm;
| 2455 oldmm = prev->active_mm;

|- 2463 if (unlikely(!mm)) {
|| 2464 next->active_mm = oldmm;
|| 2465 atomic_inc(&oldmm->mm_count);
|| 2466 enter_lazy_tlb(oldmm, next);
|| 2467 } else
| 2468 switch_mm(oldmm, mm, next);

| 2484 /* Here we just switch the register state and the stack. */
| 2485 switch_to(prev, next, prev);

| 2493 finish_task_switch(this_rq(), prev);
| 2494 }

容易看出,核心就是 switch_mm() 函数和 switch_to() 函数。switch_mm() 函数的 C语言代码如下,请看:

35 static inline void switch_mm(struct mm_struct *prev,
36 struct mm_struct *next,
37 struct task_struct *tsk)
– 38 {
| 39 int cpu = smp_processor_id();
| 40
|- 41 if (likely(prev != next)) {

|| 51 load_cr3(next->pgd);
|| 56 if (unlikely(prev->con != next->con))
|| 57 load_LDT_nolock(&next->context);
|| 58 }

| 73 }

switch_mm() 函数切换了页表,它的主要作用就是把虚拟内存(前面的文章曾经介绍过,linux 中的进程都是运行在虚拟系统中的)从上一个进程映射到新进程中。

switch_to() 函数负责切换新进程的栈和寄存器,因为涉及到寄存器的操作,C语言无法方便的完成,所以 linux 内核是使用汇编代码(而且比C语言代码效率更高)实现该函数的,请看:

从汇编代码也能看出,switch_to() 函数在切换新进程之前,将上一个进程的信息都压栈保存了,所以以后再切换回来的时候,进程能够接着上一次的状态继续运行。

进一步调度

现在已经清楚 linux 内核是如何切换进程的了。之前我们说过 linux 的进程是有优先级的概念的,高优先级的进程总是优先运行。假设某次调度后,进程 A 即将被投入运行,但是这时优先级比进程 A 更高的进程 B 也处于可运行状态了,linux 内核如何处理这种情况呢?

事实上,内核提供了 need_resched 标志位来表明是否需要重新执行一次调度。

该标志位可以通过以下三个C语言 inline 函数修改和查询:

进一步跟踪,发现 need_resched 标志位其实记录在进程的 thread_info 结构体的 flag 成员里,该结构体之前有文章专门介绍过。

现在就清楚了,当优先级较高的进程进入可执行状态的时候,linux 内核会设置 need_resched 标志位。而内核在进程调度后返回用户空间时,会检查 need_resched 标志,如果已被设置,则内核会重新执行一次调度。

那么,linux 内核什么时候重新调度才是安全的呢?只要进程没有持有锁,内核就可以抢占它。所以每个进程的 thread_info 结构体有一个 preempt_count 计数器,它的初始值为 0,每使用一次锁就加 1,释放一次锁就减 1,当该值为 0 的时候,linux 内核就能够抢占它。

linux 的实时调度策略

再啰嗦一下 linux 的两种实时调度策略:SCHED_FIFO 和 SCHED_RR。不特殊指定调度策略的进程一般都是 SCHED_NORMAL 策略。

SCHED_FIFO 调度策略不使用时间片,它使用先进先出的调度算法,处于可运行状态的 SCHED_FIFO 进程会比任何 SCHED_NORMAL 几的进程都先得到调度。一旦一个 SCHED_FIFO 级的进程处于可执行状态,就会一直运行,除非执行完毕或者它自己主动让出 cpu,否则就只有优先级更高的 SCHED_FIFO 和 SCHED_RR 级进程才能抢占它。

SCHED_RR 调度策略与 SCHED_FIFO 调度策略总体相同,只不过 SCHED_RR 调度策略也使用时间片,SCHED_RR级进程消耗完自己的时间片时,由同优先级的其他实时进程抢占。

SCHED_FIFO 和 SCHED_RR 调度策略,高优先级的进程总是立刻抢占低优先级的进程。低优先级进程不会抢占 SCHED_RR 进程,即使它的时间片已经使用完毕。

欢迎在评论区一起讨论,质疑。文章都是手打原创(本文部分参考linux内核原理和设计),每天最浅显的介绍C语言、linux等嵌入式开发,喜欢我的文章就关注一波吧,可以看到最新更新和之前的文章哦。

相关推荐