Linux是一个多用户多任务的操作系统,其中多用户,是指多个用户可以在同一时间使用计算机系统;多任务,是指Linux可以在同一时间内运行多个应用程序,每个正在执行的应用程序被称为一个任务。
但我们知道单核CPU在某一时刻只能执行一个任务,所以Linux将CPU的时间分片,时间片很短大概几十到上百毫秒,调度器轮流分配给各个任务使用,因此形成多任务"同时运行"的错觉。当任务执行时,即占用CPU,其时间片会递减,OS会在当前任务的时间片用完时,切换任务,让CPU去执行其他任务(Linux是任务抢占调度机制)。
所以怎么去衡量和维护这些CPU的时间片?Linux是事先定义的节拍率,来处理时间中断,并使用全局变量Jiffies记录了开机以来的节拍数,即每发生一次时间中断,Jiffies的值就加1。
进程调度
timer_interrupt
还记得我们在任务调度初始化sched_init中费了很大功夫来初始化8253定时器,经过设置,它会每10毫秒,产生一次时间中断信号,通知CPU来调用对应的中断服务程序timer_interrupt,其中断号0x20。
在Linux0.12中,进程调度的核心驱动动力,来源于时间中断,定时器每10毫秒,就产生1次时间中断信号,来驱动系统进程调度。
下图为主要流程:
图片
我们先来看一下timer_interrupt的源码:
注意这里pushl $-1,这里把-1压入栈中,表明不是系统调用。其中incl _jiffies表示jiffies值加1,jiffies则记录着,系统开机之后的时钟滴答值;另一个核心函数_do_timer,用来执行任务切换、计时等功能。
do_timer
我们接着看下do_timer的源码:
do_timer中与屏幕、硬盘处理、发生器处理、软盘处理,我们暂时忽略。其中current全局变量,表示当前任务指针,永远指向当前的任务。当初始化的时候struct task_struct *current = &(init_task.task);,current是指向0号进程的。
current->counter表示当前进程的运行时间片,用来计时的,在Linux0.12中每经过一次时钟中断(10ms), counter就会减去1。
如果当前进程的运行时间片大于0,时间片没用完,就直接退出该函数,继续执行当前进程;如果时间片用完了,就重置为0,且当前程序运行在用户态,去执行任务调度函数(任务切换),这就是典型的时间片轮转策略。
其中在执行任务调度之前,还会判断当前任务的特权级,如果当前特权级如果表示发生中断时正在内核态运行,哪怕其时间片用完了,也直接返回不进行任务切换,来表示内核态任务不可被抢占。
schedule
我们接着看schedule函数的源码:
schedule任务调度函数,非常简短但很优雅, Linux0.12这里采用了基于优先级排队的调度策略 ,主要是在循环中找到系统中处于就绪态的且时间片最大的任务,进行调度。
退出循环并执行任务切换,主要有2种情况:
- 一种是找到处于就绪态的且时间片最大的任务。
- 另一种就是系统中没有一个可运行的任务存在(c=-1,next=0);其他情况则重新循环任务数组,更新任务的运行时间值counter = counter/2 + priority,继续进行循环。
父子进程的调度的顺序是由调度器决定的,与所谓进程的创建顺序无关。另外我们可以发现随着循环往后,哪些任务的优先级越高,分配到的时间片就会越大,即优先级高的任务优先运行。
switch_to
我们再来看下switch_to源码,又是内联汇编写法:
switch_to主要功能是,切换当前任务到任务n,也就是schedule函数中的next,这个函数还是比较复杂的,我们来讲解一下其实现任务切换的流程:
- 定义8字节结构体__tmp,只用到了其中的六个字节,来作为后面ljmp的操作数。
- je 1f首先比较n是否是当前任务current,如果是就什么都不做,直接跳转到标号1处。
- movw %%dx,%1新任务TSS选择符(16位)赋值给第一个参数__tmp.b,也就是__tmp.b存放的是进程n的tss段选择符。
- xchgl %%ecx,_current交换两个操作数的值,等同于current = task[n] ,ecx = 被切换出去的任务(即原任务)。
- ljmp %0,这步非常重要,ljmp跳转指令表示跳转到进程n的TSS描述符处(__tmp.b存放的是进程n的tss段选择符,_tmp.a存放的是偏移地址0)。当ljmp识别描述符为TSS时,会告诉CPU进行任务切换,CPU会自动将当前任务的现场信息保存到当前任务私有的TSS中,然后将进程n的TSS中信息保存到对应的寄存器中,CPU会根据这些寄存器的值来跳转到新的进程的代码段执行任务。
- cmpl %%ecx,_last_task_used_math判断是否使用了协处理器,需要注意的是,只有当任务切换回来后才会继续执行该行,因为在切换前,EIP指向引起任务切换指令ljmp的下一条指令,当保存进程现场信息时,EIP的值夜会保存到原任务的TSS中;直到当任务切换回来后,原任务的TSS中进程现场信息,重新恢复到对应的寄存器中,CPU继续从EIP指向的指令开始执行任务。
- jne 1f、clts,如果使用了协处理器,就复位控制寄存器cr0中的TS标志,不然就跳转到标号1处直接退出。
图片
当此时完成任务切换后,会返回到时间处理函数_timer_interrupt中,继续执行ret_from_sys_call,主要是参与信号处理,我们本文就不再细讲了,后面有机会再详细聊聊。
休眠与唤醒
我们接着趁热打铁,了解一下进程的休眠与唤醒。在linux0.12中进程的休眠,主要是通过sleep_on函数来实现的,它是一个关键的调度函数,用于将当前进程置于等待状态,直到某个资源可用。
当多个进程在调用sleep_on函数时,会隐式构建一个等待队列,通过每个进程在内核栈中的临时变量tmp,形成了"链表"结构,它并不是一个真正的链表。每个调用sleep_on的进程会被插入到等待队列的头部。随着sleep_on函数的执行,使得tmp指针指向队列中队列头指针指向的原等待任务,而队列头指针p则指向本次新加入的等待任务。
这里还是比较特殊的,大家可以参考下面笔者吐血画的一张等待队列示意图:
图片
sleep_on函数将指定的进程进行休眠,其实就是将进程的状态设置为可中断等待状态(TASK_INTERRUPTIBLE 1)或不可中断等待状态(TASK_UNINTERRUPTIBLE 2),那么反过来唤醒的话,就直接将进程的状态重新设置为TASK_RUNNING 0 运行态
sleep_on函数在将当前进程置于等待状态后,它还会调用schedule()函数,让CPU切换到其他可运行的进程去执行。
另外我们还需知道这里可中断等待状态和不可中断等待状态的区别,可中断的等待状态的进程可以被信号或其他中断方式手段唤醒;而不可中断的等待状态,必须通过wake_up函数来显式唤醒,即使有信号也无法唤醒!
如果是操作系统的0号进程的话,当其尝试调用sleep_on函数时,会进行特殊处理,0号进程不允许进入睡眠状态,系统会触发一个恐慌panic。
接着再来看看wake_up唤醒函数:
这个函数还是非常简单的,核心就是将进程的状态再设置为就绪状态(0)。需要注意的是, 调用该函数唤醒的是最后进入等待队列的任务,即等待队列中的队头任务。被唤醒的进程会重新进入调度队列task[NR_TASKS],等待再次被调度执行。
参考资料:
https://elixir.bootlin.com/linux/0.12/source/kernel/sched.c
《Linux内核完全注释5.0》
《Understanding Linux Kernel and its Impact on System Efficiency》