背景
Read the fucking source code!
--By 鲁迅A picture is worth a thousand words.
--By 高尔基
说明:
- Kernel版本:4.14
- ARM64处理器,Contex-A53,双核
- 使用工具:Source Insight 3.5, Visio
1. 概述
从这篇文章开始,将开始Linux调度器的系列研究了。
本文也会从一些基础的概念及数据结构入手,先打造一个粗略的轮廓,后续的文章将逐渐深入。
2. 概念
2.1 进程
- 从教科书上,我们都能知道:进程是资源分配的最小单位,而线程是CPU调度的的最小单位。
- 进程不仅包括可执行程序的代码段,还包括一系列的资源,比如:打开的文件、内存、CPU时间、信号量、多个执行线程流等等。而线程可以共享进程内的资源空间,线程具备独立的栈空间和寄存器资源。
- 在Linux内核中,进程和线程都使用
struct task_struct
结构来进行抽象描述。 - 进程的虚拟地址空间分为用户虚拟地址空间和内核虚拟地址空间,所有进程共享内核虚拟地址空间,没有用户虚拟地址空间的进程称为内核线程。
Linux内核使用task_struct
结构来抽象,该结构包含了进程的各类信息及所拥有的资源,比如进程的状态、打开的文件、地址空间信息、信号资源等等。task_struct
结构很复杂,下边只针对与调度相关的某些字段进行介绍。
struct task_struct {
/* ... */
/* 进程状态 */
volatile long state;
/* 调度优先级相关,策略相关 */
int prio;
int static_prio;
int normal_prio;
unsigned int rt_priority;
unsigned int policy;
/* 调度类,调度实体相关,任务组相关等 */
const struct sched_class *sched_class;
struct sched_entity se;
struct sched_rt_entity rt;
#ifdef CONFIG_CGROUP_SCHED
struct task_group *sched_task_group;
#endif
struct sched_dl_entity dl;
/* 进程之间的关系相关 */
/* Real parent process: */
struct task_struct __rcu *real_parent;
/* Recipient of SIGCHLD, wait4() reports: */
struct task_struct __rcu *parent;
/*
* Children/sibling form the list of natural children:
*/
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
/* ... */
}
2.2 进程状态
- 上图中左侧为操作系统中通俗的进程三状态模型,右侧为Linux对应的进程状态切换。每一个标志描述了进程的当前状态,这些状态都是互斥的;
- Linux中的
就绪态
和运行态
对应的都是TASK_RUNNING
标志位,就绪态
表示进程正处在队列中,尚未被调度;运行态
则表示进程正在CPU上运行;
内核中主要的状态字段定义如下
/* Used in tsk->state: */
#define TASK_RUNNING 0x0000
#define TASK_INTERRUPTIBLE 0x0001
#define TASK_UNINTERRUPTIBLE 0x0002
/* Used in tsk->exit_state: */
#define EXIT_DEAD 0x0010
#define EXIT_ZOMBIE 0x0020
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_PARKED 0x0040
#define TASK_DEAD 0x0080
#define TASK_WAKEKILL 0x0100
#define TASK_WAKING 0x0200
#define TASK_NOLOAD 0x0400
#define TASK_NEW 0x0800
#define TASK_STATE_MAX 0x1000
/* Convenience macros for the sake of set_current_state: */
#define TASK_KILLABLE (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)
#define TASK_STOPPED (TASK_WAKEKILL | __TASK_STOPPED)
#define TASK_TRACED (TASK_WAKEKILL | __TASK_TRACED)
#define TASK_IDLE (TASK_UNINTERRUPTIBLE | TASK_NOLOAD)
2.3 scheduler 调度器
- 所谓调度,就是按照某种调度的算法,从进程的就绪队列中选取进程分配CPU,主要是协调对CPU等的资源使用。进程调度的目标是最大限度利用CPU时间。
内核默认提供了5个调度器,Linux内核使用struct sched_class
来对调度器进行抽象:
Stop调度器, stop_sched_class
:优先级最高的调度类,可以抢占其他所有进程,不能被其他进程抢占;Deadline调度器, dl_sched_class
:使用红黑树,把进程按照绝对截止期限进行排序,选择最小进程进行调度运行;RT调度器, rt_sched_class
:实时调度器,为每个优先级维护一个队列;包含FIFO和RR两种统一优先级调度。CFS调度器, cfs_sched_class
:完全公平调度器,采用完全公平调度算法,使用红黑树,把进程按照虚拟时间实现完全公平调度;IDLE-Task调度器, idle_sched_class
:空闲调度器,每个CPU都会有一个idle线程,当没有其他进程可以调度时,调度运行idle线程;
Linux内核提供了一些调度策略供用户程序来选择调度器,其中Stop调度器
和IDLE-Task调度器
,仅由内核使用,用户无法进行选择:
SCHED_DEADLINE
:限期进程调度策略,使task选择Deadline调度器
来调度运行;SCHED_RR
:实时进程调度策略,时间片轮转,进程用完时间片后加入优先级对应运行队列的尾部,把CPU让给同优先级的其他进程;SCHED_FIFO
:实时进程调度策略,先进先出调度没有时间片,没有更高优先级的情况下,只能等待主动让出CPU;SCHED_NORMAL
:普通进程调度策略,使task选择CFS调度器
来调度运行;SCHED_BATCH
:普通进程调度策略,批量处理,使task选择CFS调度器
来调度运行;SCHED_IDLE
:普通进程调度策略,使task以最低优先级选择CFS调度器
来调度运行;
2.4 runqueue 运行队列
- 每个CPU都有一个运行队列,运行队列包含5种调度器完成本CPU的任务调度;
- 分配给CPU的task,作为调度实体加入到运行队列中;
- task首次运行时,如果可能,尽量将它加入到父task所在的运行队列中(分配给相同的CPU,缓存affinity会更高,性能会有改善);
Linux内核使用struct rq
结构来描述运行队列,关键字段如下:
/*
* This is the main, per-CPU runqueue data structure.
*
* Locking rule: those places that want to lock multiple runqueues
* (such as the load balancing or the thread migration code), lock
* acquire operations must be ordered by ascending &runqueue.
*/
struct rq {
/* runqueue lock: */
raw_spinlock_t lock;
/*
* nr_running and cpu_load should be in the same cacheline because
* remote CPUs use both these fields when doing load calculation.
*/
unsigned int nr_running;
/* 三个调度队列:CFS调度,RT调度,DL调度 */
struct cfs_rq cfs;
struct rt_rq rt;
struct dl_rq dl;
/* stop指向迁移内核线程, idle指向空闲内核线程 */
struct task_struct *curr, *idle, *stop;
/* ... */
}
2.5 task_group 任务分组
- 利用任务分组的机制,可以设置或限制任务组对CPU的利用率,比如将某些任务限制在某个区间内,从而不去影响其他任务的执行效率;
- 引入
task_group
后,调度器的调度对象不仅仅是进程了,Linux内核抽象出了sched_entity/sched_rt_entity/sched_dl_entity
描述调度实体,调度实体可以是进程或task_group
; - 使用数据结构
struct task_group
来描述任务组,任务组在每个CPU上都会维护一个CFS调度实体、CFS运行队列,RT调度实体,RT运行队列
;
Linux内核使用struct task_group
来描述任务组,关键的字段如下:
/* task group related information */
struct task_group {
/* ... */
/* 为每个CPU都分配一个CFS调度实体和CFS运行队列 */
#ifdef CONFIG_FAIR_GROUP_SCHED
/* schedulable entities of this group on each cpu */
struct sched_entity **se;
/* runqueue "owned" by this group on each cpu */
struct cfs_rq **cfs_rq;
unsigned long shares;
#endif
/* 为每个CPU都分配一个RT调度实体和RT运行队列 */
#ifdef CONFIG_RT_GROUP_SCHED
struct sched_rt_entity **rt_se;
struct rt_rq **rt_rq;
struct rt_bandwidth rt_bandwidth;
#endif
/* task_group之间的组织关系 */
struct rcu_head rcu;
struct list_head list;
struct task_group *parent;
struct list_head siblings;
struct list_head children;
/* ... */
};
3. 调度程序
调度程序分为主动调度和被动调度,主动调度直接调度schedule完成;被动调度首先通过程序表示抢占标志位,在内核态切换到用户态的是进行标志位判定完成抢占调度。
3.1 主动调度 - schedule()
schedule()
函数,是进程调度的核心函数,大体的流程如上图所示。- 核心的逻辑:选择另外一个进程来替换掉当前运行的进程。进程的选择是通过进程所使用的调度器中的
pick_next_task
函数来实现的,不同的调度器实现的方法不一样;进程的替换是通过context_switch()
来完成切换的,具体的细节后续的文章再深入分析。
3.2 被动调度
周期调度 - schedule_tick()
- 时钟中断处理程序中,调用
schedule_tick()
函数; - 时钟中断是调度器的脉搏,内核依靠周期性的时钟来处理器CPU的控制权;
- 时钟中断处理程序,检查当前进程的执行时间是否超额,如果超额则设置重新调度标志(
_TIF_NEED_RESCHED
); - 时钟中断处理函数返回时,被中断的进程如果在用户模式下运行,需要检查是否有重新调度标志,设置了则调用
schedule()
调度;
高精度时钟调度 - hrtick()
- 高精度时钟调度,与周期性调度类似,不同点在于周期调度的精度为ms级别,而高精度调度的精度为ns级别;
- 高精度时钟调度,需要有对应的硬件支持;
进程唤醒时调度 - wake_up_process()
- 唤醒进程时调用
wake_up_process()
函数,被唤醒的进程可能抢占当前的进程;
创建新进程 - fork()
修改进程优先级 - nice()
进程迁移处理 - migrate-swap()
上述讲到的几个函数都是常用于调度时调用。此外,在创建新进程时,或是在内核抢占时,也会出现一些调度点。
本文只是粗略的介绍了一个大概,后续将针对某些模块进行更加深入的分析,敬请期待。
1. linux进程分类
当涉及有关调度的问题时, 传统上把进程分类为”I/O受限(I/O-dound)”或”CPU受限(CPU-bound)”.
类型 | 别名 | 描述 | 示例 |
I/O消耗性 | I/O-Bound | 频繁进行I/O处理,并花很多时间等待I/O操作完成 | 键盘等待输入、写磁盘 |
CPU消耗性 | CPU-Bound | 花费大量时间进行数字计算,一直占用CPU | 大量数学计算 |
另外一种分法把进程区分成三类
类型 | 描述 | 示例 |
交互式进程 | 此进程经常需要与用户进行交互,因此需要花费很多时间等待键盘、鼠标、触摸屏等操作,当接受了用户的输入后,进程必须被很快唤醒,否则用户就会抱怨系统卡顿,这类进程的特点就是响应时间越短越好 | shll,文本编辑程序和图形应用程序 |
批处理进程 | 此类进程不必与用户交互,因此经常在后台运行,因为这类进程不必很快响应,因此常受调度器程序的怠慢,这类进程的特点是会占用比较多的系统资源 | 科学计算、编译代码 |
实时进程 | 这样的进程对整体时延有严格的要求,不能被低优先级的进程阻塞 | 视频播放等 |
1.2 linux优先级和分类
操作系统的经典的进程调度算法是基于优先级调度的,优先级的核心思想就是把进程按照优先级进行分类,紧急的进程优先级高,不紧急、不重要的进程优先级低。调度器总是从就绪队列中选择优先级高的进程进行调度,而且优先级高的进程分配时间片会比优先级低的进程长,这体现了一种等级制度。
所以进程优先级代表了进程需要运行的紧急程度和需要更多的运行时间片,linux操作系统最早开始采用nice值来调整进程的优先级。nice值的思想是要对其他进程友好,就需要降低优先级来支持其他进程消耗更多的处理器时间。nice值的范围是 【-20 , 19】, 默认值是0,nice值越大,优先级反而越低,nice值越小,优先级越高。nice值为-20表明这个进程非常重要,优先级最高;而nice值是19,表明允许其他进程比这个进程优先享有宝贵的CPU资源。
内核使用0-139数值表示进程优先级,数值越小,优先级越高。优先级099给实时进程使用,100139给普通进程使用。另外,在用户空间中有一个传统的变量nice,用于映射普通进程的优先级,即100~139
linux按照优先级分为两类进程
- 实时进程: 要求最快被响应,比如视频、工业机器控制程序,其优先级为0 ~ 99
- 非实时进程:即普通进程,我们大部分程序使用的,其优先级为100 ~ 139其还可以细分为两类
- 交互式进程,需要响应前台请求
- 后台批处理进程
linux内核中task_struct数据结构中使用了4个成员描述进程的优先级
struct task_struct {
int load_weight; nice权重调度
int prio, static_prio, normal_prio;
//prio是动态调度优先级,static_prio静态优先级进程启动是分配,内核不使用nice值使用静态优先级。normal_prio是基于static_prio和调度策略计算出来的优先级
unsigned int rt_priority;实时进程优先级
}
字段 | 含义 |
prio | 进程的动态优先级,唯一被调度器使用的优先级字段,其依赖于normal_prio字段,调度器在运行期间可调整改值 |
static_prio | 1. 普通进程的静态优先级,仅能通过nice值修改,取值为【100,139】,值越小优先级越高 2. 启动时分配,不会随着时间改变,用户可以通过Nice()或sched_setscheduler()等系统调用来修改 |
normal_prio | 在创建进程时,会继承父进程的normal_prio,对于普通进程来说,改值等同于static_prio,对于实时进程,会根据rt_priorty重新计算normal_prio |
rt_priority | 实时进程的优先级,取值为[0,99], |
1.3 linux权重
linux内核除了使用优先级来表示进程的轻重缓急之外,在实际调度器中还是用了权重的概念来表示进程的优先级,为了计算方便,内核约定nice值为0的进程权重为1024,其他的nice值对应的进程权重值可以通过查表的方式来获取,内核预先计算好了一个表sched_prio_to_weight,表的下标对应Nice值为[-20 ~ 19]
2. linux调度时机
对于调度器,一个很重要的时调度时机问题,在什么情况下,什么时候发生调度?也就是说在什么情况下,什么时候,把现在占用CPU的进程替换下来,根据进程生命周期的图示,
内核运行调度程序的条件为
进程从运行状态切换到就绪状态或者等待状态
进程被终结了,就需要从就绪队列中选择下一个进程进行运行
同时哪些场景不能发生调度呢?
在中断处理过程中: 中断处理过程,与硬件密切相关,很难做到中断处理过程进行进程切换
在内核程序临界区:
在原子操作过程中:
2.1 非抢占内核和抢占内核
在不支持内核抢占的系统中,进程/线程一旦运行于内核空间,就可以一直运行,直到他主动放弃或者耗尽时间片为止,这样就会导致非常紧急的进程或线程长时间得不到运行。
例如,当外部来了一个中断,中断程序程序处理完后,需要一个用户程序B对此进程进行进一步处理。而此时进程A正在使用系统调用进入内核态,那么只有等到A从系统调用返回,内核进行调度,B才有可能被运行。假设A的系统调用占用CPU的时间为T,这个T大于用户要求的响应时间,那么这个系统就不够实时
为了提高Linux的实时性。在linux2.6中引入了“Kernel preemption”(内核抢占调度模式)。并很好的解决了这个问题。一句话就是抢占式内核可以在进程处于内核态时,进行抢占。
为保证Linux内核在以上情况下不会被抢占,抢占式内核使用了一个变量preempt_count,称为内核抢占计数。这一变量被设置在进程的thread_info结构中。每当内核要进入以上几种状态时,变量preempt_count就加1,指示内核不允许抢占。每当内核从以上几种状态退出时,变量preempt_count就减1,同时进行可抢占的判断与调度。
抢占式内核什么时候能发生调度呢?
中断请求被服务历程响应完成,在返回之前被中断的进程时,可以根据需要进行调度
一系统抢占式的调度器函数:preempt_schedule;preempt_schedule_irq。它们都是调用schedule来完成调度的
同样的当时间片用完,进程处于就绪等待状态的时候,会发生调度
在支持内核抢占的系统中,某些特例下是不允许内核抢占的:
内核正进行中断处理。进程调度函数schedule()会对此作出判断,如果是在中断中调用,会打印出错信息。
内核正在进行中断上下文的Bottom Half(中断的底半部)处理。硬件中断返回前会执行软中断,此时仍然处于中断上下文中。
进程正持有spinlock自旋锁、writelock/readlock读写锁等,当持有这些锁时,不应该被抢占,否则由于抢占将导致其他CPU长期不能获得锁而死等。
内核正在执行调度程序Scheduler。抢占的原因就是为了进行新的调度,没有理由将调度程序抢占掉再运行调度程序。
3. linux调度策略
根据什么准则挑选下一个进入运行的进程。从running的进程队列中找出一个进程,来占用CPU,让它运行。
linux调度依赖于调度策略,Linux内核把相同的调度策略抽象成调度类(schedule class)。不同类型的进程采用不同的调度策略,目前Linux内核默认实现了5中调度类,分别是stop、deadline、realtime、CFS和idle,他们分别使用sched_class来实现,并且通过next指针串联在一起
调度类 | 调度策略 | 使用范围 | 说明 |
stop | 无 | 最高优先级的进程,比deadline进程优先级高 | 1. 可以抢占任何进程 2. 在每个CPU上实现一个名为"migration/N"的内核线程,说明该线程的优先级最高,可以抢占任何进程的运行,一般用来运行特殊的功能 3. 用于负载均衡机制中的进程迁移、softlockup检测、cpu热插拔、RCU等 |
deadline | SCHED_DEADLINE | 最高优先级的实时进程,优先级为-1 | 用于调度有严格要求的实时进程,如视频编解码 |
realtime | SCHED_FIFO SCHED_RR | 普通实时进程,优先级为0 ~ 99 | 用于普通的实时进程 |
CFS | SCHED_NORMAL SCHED_BATCH | 普通进程,优先级为100 ~ 139 | 由CFS来调度 |
idle | 无 | 最低优先级的进程 | 当就绪队列中没有其他的进程时,进入idle调度类,idle调度器会让CPU进入低功耗模式 |
用户空间程序 可以使用调度策略接口函数(sched_setscheduler())来设定用户进程的调度策略,linux内核对于调度策略定义如下:
4. 总结