Linux 进程调度-基于 ARM64
进程
在 Linux 内核中,进程一般称为任务(task), 进程的虚拟地址空间在内存管理模块中被分为用户虚拟地址空间和内核虚拟地址空间,所有的进程共享内核虚拟地址空间, 每一个进程有独立的用户虚拟地址空间. 在内核中,进程有两种特殊形式,没有使用用户虚拟地址空间的进程称为内核线程,共享用户虚拟地址空间的进程称为用户线程.
我们通常开发过程中提及的进程与线程在 Linux 内核中并没有明确的区别,它们都拥有数据结构task_struct
作为描述符,我们通常所讲的进程与线程的主要区别即是否共享用户虚拟空间.
本文着重介绍了 CFS 公平调度算法,它的公平性主要体现在按照优先级将一个完整的调度周期分配给不同的进程, 尽管每个进程因为优先级分得的时间片不同,但保证在一个调度周期内所有的进程都会被运行一次.
进程描述符task_struct
(task_struct
数据成员较多,仅列出重要的数据成员)
1 | struct task_struct { |
进程优先级
- 限期进程的优先级比实时进程高,实时进程比普通用户进程优先级高
- 限期进程的优先级是-1
- 实时进程的优先级是1~99, 优先级数值越大,优先级越高
- 普通进程的优先级是100~139, 优先级数值越小,优先级越高
调度策略
Linux 内核支持以下调度策略:
- 停机进程使用停机调度策略:
停机进程是优先级最高的进程,停机就是我们通常理解的使处理器停下来,做更紧急的任务.
- 限期进程使用限期调度策略:
限期进程使用最早期限优先算法,使用红黑书把进程按照绝对截止期限从小到大排序,每次调度时选择绝对截止期限最小的进程.
实时进程支持两种调度策略: 先进先出调度和轮流调度
普通进程支持两种调度策略, 标准轮流分时和空闲调度
处理器上的空闲进程使用空闲调度策略:
每个处理器上有一个空闲进程,即0号进程. 空闲进程的优先级最低,只有当没有其他进程可以调度的时候,才会调度空闲进程.
完全公平调度算法CFS
我们这里介绍相对重要的普通用户进程的调度策略: 完全公平调度策略 CFS(Completely Fair Scheduler):
普通进程使用完全公平调度(CFS)算法. 为了保证在一个周期内所有的进程都能被调度, 完全公平调度算法引入了虚拟运行时间vruntime
的概念:
1 | 虚拟运行时间 = 实际运行时间 * nice0 对应的权重 / 进程的权重( nice 值对应的权重) |
- 实际运行时间
实际运行时间就是字面意思,进程在 CPU 上运行的实际时间. 每一个进程(task_struct
)的调度信息结构体sched_entity
都记录了进程调度开始时间点(exec_start), 实际运行时间(sum_exec_runtime), 虚拟运行时间(vruntime), 上一次时间运行时间(prev_sum_exec_runtime).
调度进程的时候,选中 next 进程, 并开始记录 next 进程的开始运行时间点,运行结束后计算时间差即为进程的实际运行时间.
- nice0 对应的权重
在kernel/sched/core.c
中定义了 nice 值与权重的对应关系,nice0 的值为1024.
- 进程的权重
普通进程的 nice 值的取值范围是-20~19, 以下是 nice 值与权重的对应关系如下:
1 | const int sched_prio_to_weight[40] = { |
nice 值越小,进程的权重也就越大.
完全公平调度算法利用 rb-tree 将进程按虚拟运行时间从小到大的排序,每次调度选择虚拟运行时间最小的进程. nice0 对应的权重为常量,即可以理解在实际运行时间相同的情况下,进程的权重( nice 值对应的权重值)越大,被调度的机会就越大.
- 调度最小粒度
内核设置了调度最小粒度,默认为 0.75 毫秒,可以通过文件/proc/sys/kernel/sched_min_granularity_ns
调整. 调度最小粒度表示进程在 CPU 至少运行的时间长度.
- 调度周期
在某个时间长度可以保证运行队列的每个进程都至少运行一次, 这个时间长度称为调度周期,如果运行队列的进程数量大于 8, 那么调度周期等于调度最小粒度 * 进程数量,否则调度周期为6ms
- 进程的时间片
进程的时间片公式如下:
1 | 进程的时间片(实际运行时间) = (调度周期 * 进程权重 / 运行队列中所有进程的权重总和) |
介绍了CFS的基本概念, 我们来举例来分析为什么 CFS 算法是一个公平调度算法:
假如有两个进程A和B, 进程的 nice 值0和1, 即 A 进程的权重为1024, B 进程的权重为820. 以6ms的调度周期来计算, 根据进程的时间片公式,两个进程分别的运行时间片为A进程6 * 1024 / (1024+820) = 3.33ms
, B进程6 * 820 / (1024+820) = 2.66ms
. 通过进程时间片公式计算我们可以看到不同的优先级的进程运行时间片不同,但为了保证在 CPU 选择进程调度时,尽可能保证每个进程被选择的可能性是相同的,这里就要反推我们上面提到的虚拟运行时间. A 进程的虚拟运行时间为3.33 * 1024(nice 0) / 1024 = 3.33
, B 进程的虚拟运行时间为2.6 * 1024(nice 0) / 820 = 3.33
. 通过虚拟运行时间的公式我们得出A 进程和B 进程尽管优先级不同,但是在 rb-tree 的位置是接近的, 即被调度的优先级是相同的.
CFS调度算法的公平性体现在哪里?
我们可以先通过公式推导发现:
1 | 进程的时间片(实际运行时间) = (调度周期 * 进程权重 / 运行队列中所有进程的权重总和) |
CFS 调度算法利用虚拟运行时间保证在一个调度周期每个进程被调度的优先级尽可能的一样.
新进程的vruntime的初始值
对于新创建的进程我们如何设置虚拟运行时间, 假如设为0, 则调度器会因为vruntime
较小频繁的调度新建的进程直到它的虚拟运行时间追上就绪队列里其他的进程. 这个现象则违背了 CFS 调度算法的公平性. 所以在有一个数据字段min_vruntime
, 当新进程创建时,我们将其vruntime
初始化为就绪队列里的min_vruntime
, 则确保新进程与大部分的进程之间的虚拟运行时间的 GAP 不会过大, 从而避免被频繁调度.
进程调度源码分析
运行队列
每一个处理器都有一个运行队列,定义如下:
1 | /* kernel/sched/sched.h */ |
调度进程
调度进程的核心函数是__schedule
, 函数__schedule()
的处理流程如下:
- 调用
pick_next_task()
以选择下一个进程 - 调用
context_switch()
以切换进程
pick_next_task()
1 | static inline struct task_struct * |
用户进程属于公平调度类,即调用pick_next_task_fair()
选择下一个运行的进程, 公平调度类会从当前cfs_rq
即公平调度运行队列中选择虚拟运行时间最小的调度进程,所有的调度进程都由 rb-tree (红黑树)维护.
context_switch()
1 | /* linux/kernel/sched/core.c */ |
用户虚拟地址空间切换
1 | /* switch_mm_irqs_off() -> switch_mm() -> __switch_mm() */ |
寄存器和堆栈切换
1 | /* switch_to() -> __switch_to() */ |
进程切换与线程切换
通过上面的系统的分析,我们可以发现在Linux内核中并没有区分进程和线程,对于线程和进程,我们可以这么理解:
- 当进程只有一个线程时,可以认为进程就等于线程.
- 当进程拥有多个线程时,这些线程会共享相同的虚拟地址空间, 虚拟地址空间在上下文切换时是不需要切换的。另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要切换保存的.
查看进程上下文切换
我们通常所说的上下文切换分为CPU上下文切换和进程上下文切换, 例如C/C++中的系统调用即会执行CPU上下文切换,而系统性能分析工具vmstat
所显示的也是 CPU 上下文切换和中断的次数. 关于进程上下文切换我们可以利用工具pidstat
:
每隔5秒输出1组数据
这个结果中有两列内容是我们的重点关注对象。一个是 cswch ,表示每秒自愿上下文切换(voluntary context switches)的次数,另一个则是 nvcswch ,表示每秒非自愿上下文切换(non voluntary context switches)的次数. 所谓自愿上下文切换,是指进程无法获取所需资源,导致的上下文切换。比如说, I/O、内存等系统资源不足时,就会发生自愿上下文切换。而非自愿上下文切换,则是指进程由于时间片已到等原因,被系统强制调度,进而发生的上下文切换。比如说,大量进程都在争抢 CPU 时,就容易发生非自愿上下文.
调度时机
调度进程的时机如下:
- 进程主动调用schedule()函数.
- 周期性调度,抢占当前进程,强迫当前进程让出处理器.
- 唤醒进程的时候,被唤醒的进程可能会抢占当前进程.
- 创建新进程的时候, 新进程可能抢占当前进程.
主动调度
进程在用户模式下运行的时候,无法直接调用schedule()
函数, 只能通过系统调用进入内核模式,假如系统调用需要等待某个资源,例如互斥锁(mutex)或者信号量,会将进程的状态设为睡眠状态,然后调用schedule()
来调度进程.
进程也可以通过系统调用sched_yield()
让出处理器,这种情况下进程不会进入睡眠.
周期性调度
Linux内核依靠周期性的时钟中断抢夺处理器的控制权,时钟中断处理程序检查当前进程的执行时间有没有超过限额,如果超过了限额,设置需要重新调度的标志.
在CFS算法中,如果当前调度实体的运行时间超过了前面介绍的进程的时间片,那么会设置重新调度的标志位.
修改进程优先级
Linux系统可以通过
renice
设置进程优先级,具体使用方法可以通过man renice
.C/C++编程可以使用以下方法:
1 |
|
参考
<< Linux 内核深度解析>>