进程
基本概念
-
进程 = 程序 + 执行,进程可以说是某种类型的活动,它包含程序、输入、输出及状态等
-
线程是操作系统调度(资源及时间片)的最小单元。一个进程可以拥有多个线程。
-
进程和线程的区别在于进程拥有独立的资源空间,即进程地址空间,而同一进程的线程则共享进程地址空间。在Linux内核中,对于进程及线程都使用相同的数据结构
task_struct
。 -
进程是资源分配的最小单位,而线程是CPU调度的的最小单位。
-
进程的虚拟地址空间分为用户虚拟地址空间和内核虚拟地址空间,所有进程共享内核虚拟地址空间,没有用户虚拟地址空间的进程称为内核线程。
task_struct
Linux内核使用task_struct
结构来抽象,该结构包含了进程的各类信息及所拥有的资源,是进程管理中最重要的数据结构。task_struct
结构很复杂,定义在include/linux/sched.h
中,深入了解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; //进程的实时调度实体
struct sched_dl_entity dl; //进程的限时调度实体
/* 进程之间的关系相关 */
struct task_struct __rcu *real_parent;
struct task_struct __rcu *parent;
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
cpumask_t cpus_mask; //描述进程得CPU亲和性得位掩码,标识进程可以在哪些CPU上运行
}
进程运行状态
内核中主要的状态字段主要有state和exit_state,定义如下:
/*活动时状态:exit_state = 0, state取值如下:*/
#define TASK_RUNNING 0x0000
#define TASK_INTERRUPTIBLE 0x0001
#define TASK_UNINTERRUPTIBLE 0x0002
#define TASK_PARKED 0x0040
#define TASK_WAKEKILL 0x0100
#define TASK_WAKING 0x0200
#define TASK_NOLOAD 0x0400
#define TASK_NEW 0x0800
#define TASK_STATE_MAX 0x1000
#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)
/*死亡后状态 state=TASK_DEAD exit_state取值如下:*/
#define EXIT_DEAD 0x0010
#define EXIT_ZOMBIE 0x0020
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
进程调度
所谓调度,就是按照某种调度的算法,从进程的就绪队列中选取进程分配CPU,主要是协调对CPU等的资源使用。进程调度的目标是最大限度利用CPU时间。进程调度的本质是让进程更好地分时复用处理器的资源。因此,可以说,进程调度是包括两个部分的,一个是调度策略,另一个是进程的切换。
基本概念
- 时间片:指的是分时复用过程中每个进程允许持续运行的最大时间配额单位。
- 优先级:指的是在所有进程中,谁更有资格优先获得CPU资源,优先运行。
- static_prio:一般在进程创建的时候确定,取值范围100-139,也可以通过nice或sched_setscheduler()来改变,值越小,优先级越高。
- nomal_prio:对普通进程来说,nomal_prio初始值 = 静态优先级,对于实时进程来说,值得范围在99 - rt_priority。nomal_prio得取值范围为0-139,值随着进程的运行不断调整改变,值越小,优先级越高。调度策略选择进程时考虑的是动态优先级
- rt_priority:实时进程的静态实时优先级,取值范围0-99,值越大,优先级越高。
- 抢占调度:指的是高优先级进程是否可以强行夺取低优先级进程的处理器资源。如果可以,则为抢占调度。
scheduler 调度器
内核默认提供了5个调度器,Linux内核使用struct sched_class
来对调度器进行抽象
-
Stop调度器, stop_sched_class
:优先级最高的调度类,可以抢占其他所有进程,不能被其他进程抢占,同一时刻同一逻辑CPU仅有一个本类内核线程 -
Deadline调度器, dl_sched_class
:使用红黑树,把进程按照绝对截止期限进行排序,选择最小进程进行调度运行; -
RT调度器, rt_sched_class
:实时调度器,为每个优先级维护一个队列; -
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调度器
来调度运行;
sched_entity调度实体
调度实体是CFS调度器中得一个重要得数据结构,内容如下:
struct sched_entity {
struct load_weight load; // 负载权重,用于负载均衡
unsigned long runnable_weight; // 可运行的权重
struct rb_node run_node; // 运行队列的红黑树节点,将调度实体组织在红黑树中
struct list_head group_node; // 组队列的链表节点
unsigned int on_rq; // 标记实体是否在运行队列上
u64 exec_start; // 实体开始执行的时间
u64 sum_exec_runtime; // 总执行时间
u64 vruntime; // 虚拟运行时间
u64 prev_sum_exec_runtime; // 上一次的总执行时间
u64 nr_migrations; // 迁移次数
struct sched_statistics statistics; // 调度统计信息
};
当每个调度实体获得CPU资源开始运行时,exec_start更新为当前时间,记录最近一次调度实体获得CPU得起始运行时间
runqueue 运行队列
-
每个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;
/* ... */
}
主动调度
调度器得主函数是schedule()/__schedule()
schedule
asmlinkage __visible void __sched schedule(void)
{
// 获取当前运行的任务的task_struct结构体指针
struct task_struct *tsk = current;
// 提交工作到内核
sched_submit_work(tsk);
// 循环执行调度
do {
// 禁用抢占,防止递归
preempt_disable();
// 执行真正的调度函数,false表示是主动调度
__schedule(false);
// 重新启用抢占,但不重新调度
sched_preempt_enable_no_resched();
// 打开抢占,并检查是否需要重新调度,但不立即重调度
} while (need_resched());
// 更新工作队列中的任务
sched_update_worker(tsk);
}
sched_submit_work将工作项提交给内核工作队列,由于当前进程执行schedule后,有可能会进入休眠,所以在休眠之前需要处理当前进程的工作,防止发生死锁。为了确保当前进程在进入休眠状态之前不会被抢占,禁止内核抢占,让进程处于不中断情况下,如果有合适进程那么CPU会进行选择并且执行,调用__schedule()函数进行调度。启用内核抢占但不会立即进行进程调度。
__schedule精简代码
static void __sched notrace __schedule(bool preempt)
{
cpu = smp_processor_id(); // 获取当前CPU的ID
rq = cpu_rq(cpu); // 获取当前CPU的运行队列
prev = rq->curr; // 获取当前运行的任务,即切换前的进程
update_rq_clock(rq); // 更新运行队列的时钟
switch_count = &prev->nivcsw; // 获取前一个任务的非虚拟上下文切换计数器
// 如果不是由于抢占并且前一个任务的状态为非运行状态,说明本次调度为主动调度
if (!preempt && prev->state) {
// 如果前一个任务有信号待处理
if (signal_pending_state(prev->state, prev)) {
prev->state = TASK_RUNNING; // 设置prev进程的状态为运行
} else {
// 否则,从运行队列中移除prev进程
deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);
}
}
// 从运行队列中选择下一个要运行的任务
next = pick_next_task(rq, prev, &rf);
clear_tsk_need_resched(prev); // 清除前一个任务的重新调度标志
clear_preempt_need_resched(); // 清除抢占的重新调度标志
// 如果要切换的两个任务不同
if (likely(prev != next)) {
rq->nr_switches++; // 增加运行队列的上下文切换计数器
RCU_INIT_POINTER(rq->curr, next);// 更新运行队列的当前任务指针
++*switch_count; // 增加前一个任务的上下文切换计数器
trace_sched_switch(preempt, prev, next); // 跟踪调度切换事件
rq = context_switch(rq, prev, next, &rf); // 执行上下文切换
}
balance_callback(rq); // 调用平衡回调函数
}
上图是该函数的一个大概树形图。该函数的核心函数分别为:pick_next_task()和context_switch()
,__schedule
的大概流程是:
- 首先获取当前cpu、该cpu的运行队列、当前进程等一些信息
- 然后更新当前cpu运行队列的时钟
- 接着判断该调度是否是主动调度
- 被动调度:执行
pick_next_task()
来选择运行队列中最高优先级的任务 - 主动调度:将当前进程从运行队列中移除,并执行
pick_next_task()
- 被动调度:执行
- 判断选择的下一个任务和前一个任务是否相同,不同的话则执行
context_switch()
来切换进程
下面分析一下几个重要的函数,来理解__schedule函数
pick_next_task
该函数位于kernel/sched/core.c
文件中,其功能是在运行队列当中挑选一个最高优先级的任务。该函数的参数为本地运行队列rq,以及当前的进程prev,和rq的标志位
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
/*检查前一个任务是否属于idle或cfs完全公平调度类,如果前一个是fair调度类,则该cpu整个运行队列中的进程数量 = cfs就绪队列中的进程数量,说明该CPU就绪队列中只有普通进程没有其它调度类进程*/
if (likely((prev->sched_class == &idle_sched_class ||
prev->sched_class == &fair_sched_class) &&
rq->nr_running == rq->cfs.h_nr_running)) {
// 使用cfs调度类的操作集进行pick_next_task
p = fair_sched_class.pick_next_task(rq, prev, rf);
// 如果pick_next_task返回RETRY_TASK,重新尝试任务选择
if (unlikely(p == RETRY_TASK))
goto restart; // 如果任务选择失败,重新开始循环。
// 如果没有从cfs调度类选择到任务,尝试从idle调度类选择。
p = idle_sched_class.pick_next_task(rq, prev, rf);
// 使用idle调度类的操作集进行pick_next_task
return p;
}
restart:
// 释放前一个任务。
put_prev_task(rq, prev);
// 遍历所有调度类以查找下一个任务。
for_each_class(class) {
p = class->pick_next_task(rq, NULL, NULL);
if (p)
return p; // 如果找到选定的任务,则返回。
}
}
该函数大概功能是:先判断prev进程所属调度类,如果是idle或fair调度类,并且cpu整个运行队列中的进程数量 = cfs就绪队列中的进程数量,则执行该调度类的pick_next_task()回调函数,如果没有找到下一个task,则执行put_prev_task()回调函数,试图停止对prev进程的引用。再按照优先级遍历每个调度类,并调用他们的pick_next_task()回调函数。
context_switch进程切换
该函数位于kernel/sched/core.c
文件中,其功能是完成上下文的切换。该函数的参数为本地运行队列rq,切换前的进程prev,切换后的进程next,和rq的标志位。
注意:
- 一个内存描述符代表一个地址空间,进程有独立的地址空间而同一进程内部的线程共享同一个地址空间
- 每个进程描述符拥有一个mm字段和一个active_mm字段,前者是拥有的内存描述符而后者是实际有效的内存描述符
- 用户进程的mm和active_mm取值相同,而内核线程的mm为空, active_mm 沿用之前进程的内存描述符。
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
// 准备任务切换
prepare_task_switch(rq, prev, next);
// 启动上下文切换
arch_start_context_switch(prev);
//若next->mm为空,则说明next进程为内核线程,则需要借用进程地址空间
if (!next->mm) {
//启用懒惰tlb模式,可以减少无用的tlb刷新
enter_lazy_tlb(prev->active_mm, next);
//借用进程地址空间
next->active_mm = prev->active_mm;
// 如果前一个任务的mm不为空,说明前一个任务是普通进程,则从用户空间切换到内核
if (prev->mm) {
// 增加前一个任务的active_mm引用计数
mmgrab(prev->active_mm);
} else {
// prev和next都是内核线程,prev要被换出,因此prev的active_mm置空
prev->active_mm = NULL;
}
// next为普通进程,代表要切换到用户空间
} else {
// 切换内存管理器
membarrier_switch_mm(rq, prev->active_mm, next->mm);
switch_mm_irqs_off(prev->active_mm, next->mm, next);
if (!prev->mm) { // 前一个任务是内核线程,则从内核空间切换到用户空间
// 保存前一个任务active_mm,并将前一个任务的active_mm置空
rq->prev_mm = prev->active_mm;
prev->active_mm = NULL;
}
}
// 准备锁切换
prepare_lock_switch(rq, next, rf);
// 执行切换
switch_to(prev, next, prev);
// 完成任务切换
return finish_task_switch(prev);
}
switch_to
该函数定义在<include/asm-generic/switch to.h>
中,具体是将相关重要寄存器中的值进行保存和切换,该函数具体是通过调用__switch_to来实现的,这个函数当中引入了thread_info的概念。switch_to函数中prev和next为输入参数,而last为输出的参数。prev是当前进程,next为要切换的进程,而last为切换到当前进程的进程。
#define switch_to(prev, next, last) \
do { \
((last) = __switch_to((prev), (next))); \
} while (0)
#endif /* __ASM_GENERIC_SWITCH_TO_H */