何时需要调度执行一个任务?
第一:当任务创建的时候,需要决定是继续执行父进程,还是调度执行子进程
第二:在一个任务退出时,需要做出调度决策,需要从 TASK_RUNNING
状态的所有任务中选择一个任务来执行
第三:当一个任务阻塞在 I/O 上,或者因为其他原因阻塞,必须调度另一个任务执行
第四:在一个 I/O 中断发生时,必须做出调度决策。
I/O 中断来源于 I/O 设备,说明 I/O 的工作结束了,需要唤醒正在阻塞在这个 I/O 上的进程,这个时候,调度程序要决定是否调度这个被唤醒的任务。
第五:时钟中断发生的时候
问题:如何实现对响应时间敏感的调度程序?
轮转调度(Round-Robin, RR)
基本思想:在一个时间片内运行一个任务,时间片结束,然后切换到下一个任务,而不是运行一个任务直到结束。这样反复执行,直到所有任务完成。
RR 有时被称为时间切片,时间片长度必须是时钟中断周期的倍数。如果时钟中断是每 10ms
中断一次,则时间片可以是 10ms
、20ms
或 10ms
的任何倍数。
什么是信号?
例如 kill -9 2334
,内核先找到pid = 2334
的进程,并杀掉这个进程以及 tgid = 2334
的线程。
给进程2334
发送9
号信号:SIGKILL
一共有 64
个信号:kill -l
信号是很短的消息,可以被发送到一个进程或一组进程
每个信号,本质上就是一个数字而已
信号处理大体流程:
信号发送:
kill(pid, sig)
:向 pid
所在的线程组发送一个sig
号信号
tkill(pid, sig)
:向 pid
进程(或线程)发送一个sig
号信号
tgkill(pid, sig, tgid)
:向 pid
进程(或线程)发送一个sig
号信号(检查下这个进程的tgid
是否等于参数中的 tgid
)
给一个线程组发送的信号,称为共享信号,给一个进程/线程发送的信号,称为私有信号。
信号已发送,但未处理的信号称为挂起信号,存储在 task_struct
中 。
信号处理:
每个信号都有默认的处理方式:
① Terminate:终止进程(杀死)
② Dump:终止进程(杀死),将进程运行的上下文信息保存到文件中,方便查询进程相关信息
③ Ignore:信号被忽略
④ Stop:停止进程,将进程的状态设置为TASK_STOPPED
⑤ Continue:如果进程的状态是TASK_STOPPED
,那么把它 设置为 TASK_RUNNIND
在 CPU 执行程序指令的时候,需要一系列的 CPU 寄存器来存储 CPU 计算时要用到的指令、临时数据等。
第一个是指令指针寄存器 (eip 或者 rip),这个其实就是我们平时说的 程序计数器 (PC),它是 CPU 中最重要的寄存器了
e
开头r
开头它里面存储的是:下一条需要执行的指令在内存中的虚拟地址。CPU 的工作就是不断从内存中取出它指向指令,然后执行这一条指令,同时将下一条指令在内存的地址存放到指令寄存器中。如此不断重复,这就是 CPU 的工作了。
第二个是通用寄存器,一般用于存储 CPU 执行指令过程中产生的中间数据,一般有下面的寄存器:
这些通用寄存器是程序执行时最常用的,也是最基础的寄存器,程序执行过程中,绝大部分时间都是在操作这些寄存器来实现指令的功能。
第三个是标志寄存器 (flags),里面有众多标记位,记录了 CPU 执行指令过程中的一系列状态,这些状态大都由 CPU 自动设置。
我们在【操作系统一:程序是如何运行的?】中的第 11 小节 中,讲的 if
语句的汇编指令中,就使用了标记寄存器中的零标志条件码 (Zero Flag) 这一位标志码,来实现程序指令的跳转功能。
第四个是段寄存器,段寄存器用于分段寻址,虽然 Linux 内核采用分页寻址,但是为了保持兼容,段寄存器有些地方仍然在使用,所以,我们还需要关心,段寄存器有 6 个:
总结:
指令指针寄存器、通用寄存器、标志位寄存器以及段寄存器,这四组寄存器共同构成了一个基本的指令执行环境,也可以称为 CPU 上下文。
每个寄存器,CPU 中只有一个,比如指令指针寄存器,在 CPU 中只有一个
每个进程在执行的时候,都会有各自的 CPU 上下文信息,也就是说每个进程执行的时候,CPU 中的寄存器的值都有可能不同的
知道了什么是 CPU 上下文,就很容易理解 CPU 上下文切换,CPU 上下文切换就是 CPU 把前一个进程的 CPU 上下文保存起来,然后再加载新的进程的 CPU 上下文,这样,CPU 就可以根据指令指针中存放的新的指令内存地址,执行新的进程了。
其实,CPU 上下文切换就是修改 CPU 中的寄存器的值而已。
保存下来的 CPU 上下文,会存储在系统内核中,但是具体被保存在哪里呢?这个需要看具体的场景,我们先来看看系统调用。
操作系统将进程的运行空间分为内核空间和用户空间:
内核空间具有最高的权限,可以直接访问所有的资源
用户空间只能访问受限资源,不能直接访问磁盘等硬件设备,必须通过系统调用陷入到内核中,才能访问这些特权资源。
进程在用户空间运行时,也就是 CPU 执行用户程序代码,被称为进程的用户态。
而陷入内核空间的时候,那么 CPU 将执行内核程序代码,被称为进程的内核态。
从用户态陷入内核态,需要通过系统调用来完成。在系统调用的过程中会发生 两次 CPU 上下文切换:
pt_regs
中,然后,为了执行内核的代码指令,操作系统需要将 CPU 寄存器的值更新为内核态相关的值,然后开始执行内核态程序pt_regs
中的用户态的 CPU 上下文,恢复到 CPU 寄存器中,然后切换用户空间,继续运行进程注意:系统调用过程中一直是在同一个进程中进行的。
一个进程的上下文信息包含:
mm_struct
,这个里面包含了进程页表在一个进程里,所有的线程共享进程的资源,比如虚拟内存、磁盘文件、信号处理等,不过线程也有自己的数据,一个线程的上下文包含:
在 Linux 中线程是 CPU 任务调度的最小单位。
一个 CPU 同一时刻只能调度执行一个线程,所以,多线程运行的时候,肯定会出现线程切换。
线程切换又分为三种情况:
接下来我们分别来看下以上三种情况。
第一种情况:切换的两个线程在同一个进程内。 这种情况下需要做下面的几件事:
第二种情况:切换的两个线程不在同一个进程内。 这种情况下需要做下面的几件事:
可以看出切换两个不在同一个进程的线程,其实就是切换进程上下文了,比切换在同一个进程中的两个线程开销要大。
第三种情况:切换的两个线程中有一个是内核线程。 这种情况下需要做下面的几件事:
因为内核线程只运行在内核,没有用户空间的虚拟内存,所以不需要切换用户态虚拟内存、不需要切换页表,也就不需要刷新 TLB。这样的话,内核线程切换的开销也不大。
为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。
而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。
跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。
所以,即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、磁盘文件、信号处理等用户态资源。
中断上下文,其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。
对同一个 CPU 来说,中断处理比进程拥有更高的优先级,所以中断上下文切换并不会与进程上下文切换同时发生。
同样道理,由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便尽可能快的执行结束。
另外,跟进程上下文切换一样,中断上下文切换也需要消耗 CPU,切换次数过多也会耗费大量的 CPU,甚至严重降低系统的整体性能。
所以,当你发现中断次数过多时,就需要注意去排查它是否会给你的系统带来严重的性能问题。
CPU 上下文指一组 CPU 寄存器,包括:指令指针寄存器、通用寄存器、标志位寄存器以及段寄存器,不同进程切换时切换 CPU 上下文就是指切换 CPU 寄存器中的值。
系统调用会发生 2 次 CPU 上下文切换,从用户态陷入内核态, 首先将进程的用户态的 CPU 上下文保存到内核栈中,然后操作系统需要将 CPU 寄存器的值更新为内核态相关的值,开始执行内核态程序代码指令,当系统调用结束后,将内核栈中的用户态的 CPU 上下文,恢复到 CPU 寄存器中,然后切换用户空间,继续运行进程
进程 / 线程上下文切换时,也会发生 CPU 上下文切换:
1)如果是在同一个进程内的两个线程切换,则需要切换CPU上下文、用户栈和内核栈
2)如果是在不同进程的两个线程切换,则需要切换CPU上下文、用户栈和内核栈,还需切换页表,刷新TLB页表项缓存,此时就是切换两个进程
3)如果切换的两个线程中有一个是内核线程,则只需要切换CPU上下文和内核栈,因为内核线程只运行在内核,没有用户空间的虚拟内存,所以不需要切换用户态虚拟内存、不需要切换页表
中断上下文切换不会涉及进程的用户态,只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等