Linux 学习笔记——第二章 进程管理和调度(5)

Linux 学习笔记——第二章 进程管理和调度(5)

《深入 Linux 内核架构》阅读笔记。书籍参考的内核版本较老,文章参考的 Linux 内核版本为 5.4.103,并根据新版内核调整了一些代码片段

启动新程序

Linux 使用 execve 系统调用启动新程序,用新代码替换现存程序,该系统调用会调用体系结构无关的 do_execve 函数。filename 代表可执行文件的名称,argv 和 envp 分别是指向程序参数和系统环境变量的指针数组。

// fs/exec.c
int do_execve(struct filename *filename,
	const char __user *const __user *__argv,
	const char __user *const __user *__envp)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };
	return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

static int do_execveat_common(int fd, struct filename *filename,
			      struct user_arg_ptr argv,
			      struct user_arg_ptr envp,
			      int flags)
{
	return __do_execve_file(fd, filename, argv, envp, flags, NULL);
}

__do_execve_file 函数负责具体的执行流程:

  • do_open_execat:打开要执行的文件。内核找到相关的 inode 并生成一个文件描述符,用于寻址该文件。
  • bprm_mm_init:生成一个新的 mm_struct 实例来管理进程地址空间。
  • prepare_binprm:将 inode 里的数据赋值给 bprm。
  • copy_strings: 将程序参数和系统环境变量赋值给 bprm。
  • exec_binprm:查找一种适当的二进制格式来执行文件。

调度器概览

调度器的任务是在程序之间共享 CPU 时间,创造并行执行的错觉。该任务分为两个不同部分:一个涉及调度策略,另一个涉及上下文切换。内核必须提供一种方法,在各个进程之间尽可能公平地共享 CPU 时间,而同时又要考虑不同的任务优先级。

schedule 函数是理解调度操作的起点。该函数定义在 kernel/sched/core.c 中,是内核代码中最常调用的函数之一。Linux 使用的调度器主要是完全公平调度器,它的一个杰出特性是,它不需要时间片概念,至少不需要传统的时间片。调度器只考虑进程的等待时间,即进程在就绪队列(run-queue)中已经等待了多长时间。对CPU时间需求最严格的进程被调度执行。

调度器的一般原理是,按所能分配的计算能力,向系统中的每个进程提供最大的公正性。如果通过轮流运行各个进程来模拟多任务,那么当前运行的进程,其待遇显然好于那些等待调度器选择的进程,即等待的进程受到了不公平的对待。不公平的程度正比于等待时间。每次调用调度器时,它会挑选具有最高等待时间的进程,把 CPU 提供给该进程。如果经常发生这种情况,那么进程的不公平待遇不会累积,不公平会均匀分布到系统中的所有进程。

在这里插入图片描述

所有的可运行进程都按时间在一个红黑树中排序,所谓时间即其等待时间。等待 CPU 时间最长的进程是最左侧的项,调度器下一次会考虑该进程。等待时间稍短的进程在该树上从左至右排序。

除了红黑树外,就绪队列还装备了虚拟时钟。该时钟的时间流逝速度慢于实际的时钟,依赖于当前等待调度器挑选的进程的数目。假定该队列上有 4 个进程,那么虚拟时钟将以实际时钟四分之一的速度运行。

假定就绪队列的虚拟时间由 fair_clock 给出,而进程的等待时间保存在 wait_runtime。为排序红黑树上的进程,内核使用差值 fair_clock - wait_runtime。fair_clock 是完全公平调度的情况下进程将会得到的 CPU 时间的度量,而 wait_runtime 直接度量了实际系统的不足造成的不公平。在进程允许运行时,将从 wait_runtime 减去它已经运行的时间。这样,在按时间排序的树中它会向右移动到某一点,另一个进程将成为最左边,下一次会被调度器选择。在进程运行时 fair_clock 中的虚拟时钟会增加。

调度器还必须考虑其他问题:

  • 进程的不同优先级(即,nice 值)必须考虑,更重要的进程必须比次要进程更多的 CPU 时间份额。
  • 进程不能切换得太频繁,因为上下文切换,即从一个进程改变到另一个,是有一定开销的。
  • 两次相邻的任务切换之间,时间也不能太长,否则会累积比较大的不公平值。

在编译时可以激活调度器统计,这会在运行时生成文件 /proc/sched_debug,其中包含了调度器当前状态所有方面的信息。

调度器相关数据结构

在这里插入图片描述

  • 通用调度器:一个分配器,与其他两个组件交互。可以用两种方法激活调度。一种是直接的,比如进程打算睡眠或出于其他原因放弃 CPU(主调度器);另一种是通过周期性机制,以固定的频率运行,不时检测是否有必要进行进程切换(周期性调度器)。
  • 调度器类:用于判断接下来运行哪个进程。内核支持不同的调度策略(完全公平调度、实时调度、在无事可做时调度空闲进程),调度器类使得能够以模块化方法实现这些策略,即一个类的代码不需要与其他类的代码交互。在调度器被调用时,它会查询调度器类,得知接下来运行哪个进程。每个进程都刚好属于某一调度器类,各个调度器类负责管理所属的进程。通用调度器自身完全不涉及进程管理,其工作都委托给调度器类。
  • 上下文切换:在选中将要运行的进程之后,必须执行底层任务切换。这需要与 CPU 的紧密交互。
task_struct 的成员

各进程的 task_struct 有几个成员与调度相关。

// include/linux/sched.h
struct task_struct {
	int							prio;
	int							static_prio;
	int							normal_prio;
	unsigned int				rt_priority;
    const struct sched_class	*sched_class;
	struct sched_entity			se;
	struct sched_rt_entity		rt;
    unsigned int				policy;
	int							nr_cpus_allowed;
	const cpumask_t				*cpus_ptr;
	cpumask_t					cpus_mask;
}
  • task_struct 采用了 3 个成员来表示进程的优先级:prio 和 normal_prio 表示动态优先级,static_prio 表示进程的静态优先级。静态优先级是进程启动时分配的优先级。它可以用 nice 和 sched_setscheduler 系统调用修改。normal_priority 表示基于进程的静态优先级和调度策略计算出的优先级。调度器考虑的优先级则保存在 prio。由于在某些情况下内核需要暂时提高进程的优先级,因此需要第 3 个成员来表示。由于这些改变不是持久的,因此静态和普通优先级不受影响。
  • rt_priority 表示实时进程的优先级。该值不会代替先前讨论的那些值!最低的实时优先级为 0,而最高的优先级是 99。值越大,表明优先级越高。这里使用的惯例不同于 nice 值。
  • sched_class 表示该进程所属的调度器类。
  • 调度器不限于调度进程,还可以处理更大的实体。这可以用于实现组调度:可用的 CPU 时间可以首先在一般的进程组(例如,所有进程可以按所有者分组)之间分配,接下来分配的时间在组内再次分配。这种一般性要求调度器不直接操作进程,而是处理可调度实体。一个实体由 sched_entity 的一个实例表示。task_struct 中内嵌了一个 sched_entity 实例 se,调度器可据此操作各个task_struct。
  • policy 保存了对该进程应用的调度策略。Linux 支持 5 个可能的值:
    • SCHED_NORMAL 用于普通进程,通过完全公平调度器来处理。
    • SCHED_BATCH 用于非交互、CPU使用密集的批处理进程,通过完全公平调度器来处理。
    • SCHED_IDLE 保留值,还未实现。
    • SCHED_RR 和 SCHED_FIFO 用于实现软实时进程。SCHED_RR 实现了一种循环方法,而 SCHED_FIFO 则使用先进先出机制,由 RT 调度器类处理。
    • SCHED_DEADLINE 通过 Deadline 调度器来处理,DL 调度器也是一种实时调度器。
  • cpus_allowed 是一个位域,在多处理器系统上使用,用来限制进程可以在哪些 CPU 上运行。
调度器类

调度器类提供了通用调度器和各个调度方法之间的关联,它由几个函数指针表示。

// kernel/sched/sched.h
struct sched_class {
	const struct sched_class *next;

	void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
	void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
	void (*yield_task)   (struct rq *rq);
	bool (*yield_to_task)(struct rq *rq, struct task_struct *p, bool preempt);

	void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);

	struct task_struct * (*pick_next_task)(struct rq *rq,
					       struct task_struct *prev,
					       struct rq_flags *rf);
	void (*put_prev_task)(struct rq *rq, struct task_struct *p);
	void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);
	void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
	void (*task_fork)(struct task_struct *p);
	void (*task_dead)(struct task_struct *p);

	void (*switched_from)(struct rq *this_rq, struct task_struct *task);
	void (*switched_to)  (struct rq *this_rq, struct task_struct *task);
	void (*prio_changed) (struct rq *this_rq, struct task_struct *task,
			      int oldprio);
	unsigned int (*get_rr_interval)(struct rq *rq,
					struct task_struct *task);
	void (*update_curr)(struct rq *rq);
};

各个调度器类都必须提供 struct sched_class 的一个实例。调度类之间的层次结构是平坦的:实时进程最重要,在完全公平进程之前处理;而完全公平进程则优先于空闲进程;空闲进程只有 CPU 无事可做时才处于活动状态。next 成员将不同调度类的 sched_class 实例,按上述顺序连接起来。这个层次结构在编译时已经建立。

下面是各个调度类可以提供的操作:

  • enqueue_task 向就绪队列添加一个新进程。在进程从睡眠状态变为可运行状态时,即发生该操作。
  • dequeue_task 提供逆向操作,将一个进程从就绪队列去除。尽管使用了术语就绪队列,各个调度类无须用简单的队列来表示其进程,完全公平调度器对此使用了红黑树。
  • 在进程想要自愿放弃对处理器的控制权时,可使用 sched_yield 系统调用。这导致内核调用 yield_task。
  • 在必要的情况下,会调用 check_preempt_curr,用一个新唤醒的进程来抢占当前进程。例如,在用 wake_up_new_task 唤醒新进程时,会调用该函数。
  • pick_next_task 用于选择下一个将要运行的进程,而 put_prev_task 则在用另一个进程代替当前运行的进程之前调用。
  • 在进程的调度策略发生变化时,需要调用 set_curr_task。
  • task_tick 在每次激活周期性调度器时,由周期性调度器调用。
  • task_fork 用于建立 fork 系统调用和调度器之间的关联。每次新进程建立后,则用 task_fork 通知调度器。

用户层应用程序无法直接与调度类交互。fair_sched_class、 rt_sched_class 和 dl_sched_class 都是 struct sched_class 的实例。

就绪队列

各个 CPU 都有自身的就绪队列,各个活动进程只出现在一个就绪队列中。但发源于同一进程的各线程可以在不同处理器上执行,因为进程管理对进程和线程不作重要的区分。各个就绪队列中嵌入了特定于调度器类的子就绪队列。

// kernel/sched/sched.h
struct rq {
	unsigned int		nr_running; // 队列上可运行进程的数目,不考虑其优先级或调度类
	unsigned long		nr_load_updates;
	u64			nr_switches;

	struct cfs_rq		cfs; // 用于完全公平调度器的子就绪队列
	struct rt_rq		rt;  // 用于 RT 调度器的子就绪队列
	struct dl_rq		dl;  // 用于 DL 调度器的子就绪队列

	struct task_struct	*curr; // 指向当前运行的进程的 task_struct 实例
	struct task_struct	*idle;
	struct task_struct	*stop;
	unsigned long		next_balance;
	struct mm_struct	*prev_mm;

	unsigned int		clock_update_flags;
	u64		clock; // 用于实现就绪队列自身的时钟。每次调用周期性调度器时都会更新
};

系统的所有就绪队列都在 runqueues 数组中,该数组的每个元素分别对应于系统中的一个 CPU。

// kernel/sched/sched.h
DECLARE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);
调度实体
// include/linux/sched.h
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;
};
  • load 指定了权重,决定了各个实体占队列总负荷的比例。
  • run_node 是标准的树结点,使得实体可以在红黑树上排序。
  • on_rq 表示该实体当前是否在就绪队列上接受调度。
  • sum_exec_runtime 用于记录消耗的 CPU 时间。update_curr 函数计算当前时间和 exec_start 之间的差值,exec_start更新到当前时间,差值则被加到 sum_exec_runtime。
  • vruntime 统计进程执行期间虚拟时钟上流逝的时间。
  • 在进程被撤销 CPU 时,其当前 sum_exec_runtime 值保存到 prev_exec_runtime。原值保存下来,而 sum_exec_runtime 则持续单调增长。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值