2进程管理和调度

2进程管理和调度

所有的现代操作系统都能够同时运行若干进程,这是通过以很短的间隔在系统运行的应用程序之间不停切换而做到的。由于切换间隔如此之短,使得用户无法注意到短时间内的停滞,从而在感观上觉得计算机能够同时做几件事情。

这种系统管理方式引起了几个问题,内核必须解决这些问题,其中最重要的问题如下所示。

  1. 除非明确地要求,否则应用程序不能彼此干扰。例如,应用程序A的错误不能传播到应用程序B。由于Linux是一个多用户系统,它也必须确保程序不能读取或修改其他程序的内存,否则就很容易访问其他用户的私有数据。
  2. CPU时间必须在各种应用程序之间尽可能公平地共享,其中一些程序可能比其他程序更重要。

第一个需求——存储保护。在本章中,主要讲解内核共享CPU时间的方法,以及如何在进程之间切换。这里有两个任务,其执行是相对独立的。

  1. 内核必须决定为各个进程分配多长时间,何时切换到下一个进程。这又引出了哪个进程是下一个的问题。此类决策是平台无关的。
  2. 在内核从进程A切换到进程B时,必须确保进程B的执行环境与上一次撤销其处理器资源时完全相同。例如,处理器寄存器的内容和虚拟地址空间的结构必须与此前相同。

这两个任务是称之为调度器的内核子系统的职责。CPU时间如何分配取决于调度器策略,这与用于在各个进程之间切换的任务切换机制完全无关。

2.1 进程优先级

并非所有进程都具有相同的重要性。除了进程优先级之外,进程还有不同的关键度类别,以满足不同需求。首先进行比较粗糙的划分,进程可以分为实时进程和非实时进程

  1. 硬实时进程有严格的时间限制,某些任务必须在指定的时限内完成.如飞机系统.Linux不支持硬实时处理,至少在主流的内核中不支持。但有一些修改版本如RTLinux、Xenomai、RATI提供了该特性。在这些修改后的方案中,Linux内核作为独立的“进程”运行来处理次重要的软件,而实时的工作则在内核外部完成。只有当没有实时的关键操作执行时,内核才会运行。
    由于Linux是针对吞吐量优化,试图尽快地处理常见情形,其实很难实现可保证的响应时间。2007年我们在降低内核整体延迟(指向内核发出请求到完成之间的时间间隔)方面取得了相当多的进展。相关工作包括:可抢占的内核机制、实时互斥量以及本书将要讨论的完全公平的新调度器。
  2. 软实时进程是硬实时进程的一种弱化形式。尽管仍然需要快速得到结果,但稍微晚一点不会造成世界末日。软实时进程的一个例子是对CD的写入操作。CD写入进程接收的数据必须保持某一速率,因为数据是以连续流的形式写入介质的。如果系统负荷过高,数据流可能会暂时中断,这可能导致CD不可用,但比坠机好得多。不过,写入进程在需要CPU时间时应该能够得到保证,至少优先于所有其他普通进程。
  3. 大多数进程是没有特定时间约束的普通进程,但仍然可以根据重要性来分配优先级。例如,冗长的编译或计算只需要极低的优先级,因为计算偶尔中断一两秒根本不会有什么后果,用户不太可能注意到。相比之下,交互式应用则应该尽快响应用户命令,因为用户很容易不耐烦。

图2-1给出了CPU时间分配的一个简图。进程的运行按时间片调度,分配给进程的时间片份额与其相对重要性相当。系统中时间的流动对应于圆盘的转动,而CPU则由圆周旁的“扫描器”表示。最终效果是,尽管所有的进程都有机会运行,但重要的进程会比次要的得到更多的CPU时间。

在这里插入图片描述

这种方案称之为抢占式多任务处理(preemptive multitasking),各个进程都分配到一定的时间段可以执行。时间段到期后,内核会从进程收回控制权,让一个不同的进程运行,而不考虑前一进程所执行的上一个任务。被抢占进程的运行时环境,即所有CPU寄存器的内容和页表,都会保存起来,因此其执行结果不会丢失。在该进程恢复执行时,其进程环境可以完全恢复。时间片的长度会根据进程重要性(以及因此而分配的优先级)的不同而变化。图2-1中分配给各个进程的时间片长度各有不同,即说明了这一点。

这种简化模型没有考虑几个重要问题。例如,进程在某些时间可能因为无事可做而无法立即执行。为使CPU时间的利益回报尽可能最大化,这样的进程决不能执行。这种情况在图2-1中看不出来,因为其中假定所有的进程都是可以立即运行的。另外一个忽略的事实是Linux支持不同的调度类别(在进程之间完全公平的调度和实时调度),调度时也必须考虑到这一点。此外,在有重要的进程变为就绪状态可以运行时,有一种选项是抢占当前的进程,图中也没有反映出这一点。

调度器的代码近年来已经重写了两次。

  1. 在2.5系列内核开发期间,所谓的 O(1)调度器代替了前一个调度器。该调度器一个特别的性质是,它可以在常数时间内完成其工作,不依赖于系统上运行的进程数目。该设计从根本上打破了先前使用的调度体系结构。
  2. 完全公平调度器(completely fair scheduler)在内核版本2.6.23开发期间合并进来。新的代码再一次完全放弃了原有的设计原则,例如,前一个调度器中为确保用户交互任务响应快速,需要许多启发式原则。该调度器的关键特性是,它试图尽可能地模仿理想情况下的公平调度。此外,它不仅可以调度单个进程,还能够处理更一般性的调度实体(scheduling entity)。例如,该调度器分配可用时间时,可以首先在不同用户之间分配,接下来在各个用户的进程之间分配。

在关注内核如何实现调度之前,我们首先来讨论进程可能拥有的状态。

2.2 进程生命周期

进程并不总是可以立即运行。有时候它必须等待来自外部信号源、不受其控制的事件,例如在文本编辑器中等待键盘输入。在事件发生之前,进程无法运行。

进程可能有以下几种状态:

  1. 运行:该进程此刻正在执行
  2. 等待:进程能够运行,但没有得到许可,因为CPU分配给另一个进程。调度器可以在下一次任务切换时选择该进程
  3. 睡眠:进程正在睡眠无法运行,因为它在等待一个外部事件。调度器无法在下一次任务切换时选择该进程

系统将所有进程保存在一个进程表中,无论其状态是运行、睡眠或等待。但睡眠进程会特别标记出来,调度器会知道它们无法立即运行(具体实现,请参考2.3节)。睡眠进程会分类到若干队列中,因此它们可在适当的时间唤醒,例如在进程等待的外部事件已经发生时。

图2-2描述了进程的几种状态及其转换。
在这里插入图片描述

对于一个排队中的可运行进程,我们来考察其各种可能的状态转换。该进程已经就绪,但没有运行,因为CPU分配给了其他进程(因此该进程的状态是“等待”)。在调度器授予CPU时间之前,进程会一直保持该状态。在分配CPU时间之后,其状态改变为“运行”(路径4)。

在调度器决定从该进程收回CPU资源时(可能的原因稍后讲述),过程状态从“运行”改变为“等待”(路径2),循环重新开始。实际上根据是否可以被信号中断,有两种“睡眠”状态。现在这种差别还不重要,但在更仔细地考察具体实现时,其差别就相对重要了。

如果进程必须等待事件,则其状态从“运行”改变为“睡眠”(路径1)。但进程状态无法从“睡眠”直接改变为“运行”。在所等待的事件发生后,进程先变回到“等待”状态(路径3),然后重新回到正常循环。

在程序执行终止(例如,用户关闭应用程序)后,过程状态由“运行”变为“终止”(路径5)。

上文没有列出的一个特殊的进程状态是所谓的“僵尸”状态。顾名思义,这样的进程已经死亡,但仍然以某种方式活着。实际上,说这些进程死了,是因为其资源(内存、与外设的连接,等等)已经释放,因此它们无法也决不会再次运行。说它们仍然活着,是因为进程表中仍然有对应的表项。

僵尸是如何产生的?其原因在于UNIX操作系统下进程创建和销毁的方式。在两种事件发生时,程序将终止运行。第一,程序必须由另一个进程或一个用户杀死(通常是通过发送SIGTERM或SIGKILL信号来完成,这等价于正常地终止进程);进程的父进程在子进程终止时必须调用或已经调用wait4(读做wait for)系统调用。 这相当于向内核证实父进程已经确认子进程的终结。该系统调用使得内核可以释放为子进程保留的资源。

只有在第一个条件发生(程序终止)而第二个条件不成立的情况下(wait4),才会出现“僵尸”状态。在进程终止之后,其数据尚未从进程表删除之前,进程总是暂时处于“僵尸”状态。有时候(例如,如果父进程编程极其糟糕,没有发出wait调用),僵尸进程可能稳定地寄身于进程表中,直至下一次系统重启。从进程工具(如ps或top)的输出,可以看到僵尸进程。因为残余的数据在内核中占据的空间极少,所以这几乎不是问题。

抢占式多任务处理

Linux进程管理的结构中还需要另外两种进程状态选项:用户状态和核心态。这反映了所有现代CPU都有(至少)两种不同执行状态的事实,其中一种具有无限的权利,而另一种则受到各种限制。例如,可能禁止访问某些内存区域。这种区别是建立封闭“隔离罩”的一个重要前提,它维持着系统中现存的各个进程,防止它们与系统其他部分相互干扰。

进程通常都处于用户状态,只能访问自身的数据,无法干扰系统中的其他应用程序,甚至也不会注意到自身之外其他程序的存在。

如果进程想要访问系统数据或功能(后者管理着所有进程之间共享的资源,例如文件系统空间),则必须切换到核心态。系统调用是在状态之间切换的一种方法。从用户状态切换到核心态的第二种方法是通过中断,此时切换是自动触发的。系统调用是由用户应用程序有意调用的,中断则不同,其发生或多或少是不可预测的。处理中断的操作,通常与中断发生时执行的进程无关。

内核的抢占调度模型建立了一个层次结构,用于判断哪些进程状态可以由其他状态抢占。

  1. 普通进程总是可能被抢占,甚至是由其他进程抢占。在一个重要进程变为可运行时,例如编辑器接收到了等待已久的键盘输入,调度器可以决定是否立即执行该进程,即使当前进程仍然在正常运行。对于实现良好的交互行为和低系统延迟,这种抢占起到了重要作用。
  2. 如果系统处于核心态并正在处理系统调用,那么系统中的其他进程是无法夺取其CPU时间的。调度器必须等到系统调用执行结束,才能选择另一个进程执行,但中断可以中止系统调用(在进行重要的内核操作时,可以停用几乎所有的中断)
  3. 中断可以暂停处于用户状态和核心态的进程。中断具有最高优先级,因为在中断触发后需要尽快处理

在内核2.5开发期间,一个称之为内核抢占(kernel preemption)的选项添加到内核。 该选项支持在紧急情况下切换到另一个进程,甚至当前是处于核心态执行系统调用(中断处理期间是不行的)。尽管内核会试图尽快执行系统调用,但对于依赖恒定数据流的应用程序来说,系统调用所需的时间仍然太长了。内核抢占可以减少这样的等待时间,因而保证“更平滑的”程序执行。但该特性的代价是增加内核的复杂度,因为接下来有许多数据结构需要针对并发访问进行保护,即使在单处理器系统上也是如此。2.8.3节会讨论该技术。

2.3 进程表示

Linux内核涉及进程和程序的所有算法都围绕一个名为task_struct的数据结构建立,该结构定义在include/sched.h中。这是系统中主要的一个结构。

task_struct定义如下,当然,这里是简化版本:

//include/linux/sched.h
//进程描述符(PCB:Process Control Block进程控制块)
struct task_struct {
	volatile long state;	/* -1 unrunnable, 0 runnable, >0 stopped *//*进程状态*/
	void *stack;/*内核栈,是union thread_union结构体,stack的内容就是thread_info结构体*/
	atomic_t usage;
	unsigned int flags;	/* per process flags, defined below *//*进程标志*/
	unsigned int ptrace;

	int lock_depth;		/* BKL lock depth *//*上下文切换时系统内核锁的深度*/

	/* 
	 * 进程优先级越小,优先级越高
	 * 优先级使用0-139,其中0-99给实时进程,100-139给普通进程,普通进程nice 0优先级对应120
     * prio:动态优先级,创建子进程时该值会被设为父进程的normal_prio
     * static_prio:静态优先级,是进程启动时分配的优先级,可以用 nice
     *             和 sched_setscheduler 系统调用修改,否则一直不变
	 * 			   创建子进程是会继承父进程的static_prio
     * normal_prio:普通优先级,也是动态优先级,基于进程的静态优先级和调度策略计算出的优先级
     *             子进程会复制
     **/
	int prio, static_prio, normal_prio;
	/* 链表成员,表头为struct rq->rt->active->queue[prio],用于维护包含各进程的一个运行链表
	 * run_list和time_slice是循环实时调度器所需要的,但不用于完全公平调度器
	 */
	struct list_head run_list;
	/*该进程所属的调度器类*/
	/*
	 * 												   									  上下文切换
	 * 主调度器(schedule函数)     周期性调度器(scheduler_tick函数)(ps:调度器不限于调度进程)      <--------->   cpu
	 * 			^选择进程
	 * 			1	
	 * 			v
	 * 调度器类(完全公平调度类fair_sched_class)->调度器类(实时进程调度类rt_sched_class)->调度器类(多个调度器类相连)
	 * 			^
	 * 			1
	 * 			进程(每个进程对应一个调度器类)
	 **/
	const struct sched_class *sched_class;
	/* 调度器操作的实例,最后放入就绪队列的红黑树中, se->run_node节点放入rq->cfs_rq->tasks_timeline中 */
	struct sched_entity se;

#ifdef CONFIG_PREEMPT_NOTIFIERS
	/* list of struct preempt_notifier: */
	struct hlist_head preempt_notifiers;
#endif

	unsigned short ioprio;

	/* 保存了对该进程应用的调度策略 
	 * SCHED_NORMAL用于普通进程,通过完全公平调度器来处理
	 * SCHED_BATCH 和 SCHED_IDLE 也通过完全公平调度器来处理,不过可用于次要的进程
	 * SCHED_BATCH 用于非交互、CPU使用密集的批处理进程。调度决策对此类进程给予“冷处
		理”:它们决不会抢占CF调度器处理的另一个进程,因此不会干扰交互式进程。如果不
		打算用nice降低进程的静态优先级,同时又不希望该进程影响系统的交互性,此时最适
		合使用该调度类。
	 * SCHED_IDLE进程权重最低
	 * SCHED_RR和SCHED_FIFO用于实现软实时进程。SCHED_RR实现了一种循环方法,而SCHED_FIFO
	 * 则使用先进先出机制。这些不是由完全公平调度器类处理,而是由实时调度器类处理
	 * 辅助函数rt_policy用于判断给出的调度策略是否属于实时类(SCHED_RR和SCHED_FIFO)。
	 * task_has_rt_policy用于对给定进程判断该性质
	 * */
	unsigned int policy;
	/* 是一个位域,在多处理器系统上使用,用来限制进程可以在哪些CPU上运行,
	 * 每一位对应一个处理器,默认都被设置为0,表示可以在任何cpu上运行
	 * sched_setaffinity 系统调用,可修改进程与CPU的现有分配关系
	 * */
	cpumask_t cpus_allowed;
	/* 指定进程可使用CPU的剩余时间段,run_list和time_slice是循环实时调度器所需要的,但不用于完全公平调度器 */
	unsigned int time_slice;

#if defined(CONFIG_SCHEDSTATS) || defined(CONFIG_TASK_DELAY_ACCT)
	struct sched_info sched_info;
#endif

	struct list_head tasks;
	/*
	 * ptrace_list/ptrace_children forms the list of my children
	 * that were stolen by a ptracer.
	 */
	struct list_head ptrace_children;//链表头,结点为 task_struct->ptrace_list,ptrace将被跟踪进程添加到跟踪者的ptrace_children链表
	struct list_head ptrace_list;//链表结点,表头为 task_struct->ptrace_children,ptrace将被跟踪进程添加到跟踪者的ptrace_children链表

	/* mm:虚拟地址空间的用户空间部分,每当内核执行上下文切换时,
	 *    虚拟地址空间的用户层部分都会切换,以便与当前运行的进程匹配.
	 *    线程的mm指向NULL,强调用户空间部分不能访问,
	 *    但由于内核必须知道用户空间当前包含了什么,
	 *    所以在 active_mm 中保存了指向 mm_struct 的一个指针来描述它
	 * active_mm:指向线程向进程借来的用户空间地址,线程的mm指向NULL
	 **/
	struct mm_struct *mm, *active_mm;

/* task state */
	/*指向全局执行文件格式结构,a.out,script,elf,java等*/
	struct linux_binfmt *binfmt;
	int exit_state;
	/*引起进程退出的返回码,引起出错的信号名*/
	int exit_code, exit_signal;
	int pdeath_signal;  /*  The signal sent when the parent dies  */
	/* ??? */
	/*与运行 iBCS2 标准程序有关*/
	unsigned int personality;
	/*用于区分新老程序代码,POSIX 要求的布尔量*/
	unsigned did_exec:1;
	/*真正的进程id,每个进程不同,全局命名空间中的id,不是局部id*/
	pid_t pid;
	/*
      * thread group id 线程组id,
      * 当创建线程时,线程的tgid=主进程的pid,进程的tgid=自己的pid
      * 应用层pthread_create函数创建的线程,编程中获取的id是线程库生成的id,与这里无关
      * */
	pid_t tgid;

	struct task_struct *real_parent; /* real parent process (when being debugged) *///ptrace将跟踪者进程变为目标进程的父进程(真正的父进程保存在real_parent)
	struct task_struct *parent;	/* parent process */
	/*
	 * children/sibling forms the list of my children plus the
	 * tasks I'm ptracing.
	 */
	struct list_head children;	/* list of my children *///链表头,成员为子进程的 sibling
	struct list_head sibling;	/* linkage in my parent's children list *///链表成员,链表头为父进程的 children
	struct task_struct *group_leader;	/* threadgroup leader *///当前进程的进程组进程

	/* PID/PID hash table linkage. */
	/* 
      * 该结构将进程结构体与pid结构体相连struct pid
      * enum pid_type
      * {
      *   PIDTYPE_PID,
      *   PIDTYPE_PGID, //独立进程可以合并成进程组
      *   PIDTYPE_SID, //会话session id几个进程组可以合并成一个会话。会话中的所有进程都有同样的会话ID
      *   PIDTYPE_MAX
      *   };
      * */
	struct pid_link pids[PIDTYPE_MAX];
	struct list_head thread_group;

	struct completion *vfork_done;		/* for vfork() *///用于vfork完成机制,使用vfork函数时,父子进程共享数据段,子进程要在父进程之前启动,父进程会在该变量上进入睡眠,在子进程退出之后(子进程调用exec或exit)开始执行
	int __user *set_child_tid;		/* CLONE_CHILD_SETTID */
	int __user *clear_child_tid;		/* CLONE_CHILD_CLEARTID */

	/* 
	 * 实时进程的优先级,最低的实时优先级为0,而最高的优先级是99。值越大,表明优先级越高,这里使用的惯例不同于nice值
	 * 实时进程的权重是普通进程的两倍,权重结构体struct load_weight
	 * */
	unsigned int rt_priority;
	/*utime stime:进程在用户态,内核态的运行时间*/
	cputime_t utime, stime, utimescaled, stimescaled;
    /*系统使用资源的限制,资源当前最大数和资源可有的最大数*/
	struct rlimit rlim[RLIM_NLIMITS];
	unsigned long nvcsw, nivcsw; /* context switch counts */
	/*创建进程的时间*/
	struct timespec start_time; 		/* monotonic time */
	struct timespec real_start_time;	/* boot based time */
/* mm fault and swap info: this can arguably be seen as either mm-specific or thread-specific */
	unsigned long min_flt, maj_flt;

  	cputime_t it_prof_expires, it_virt_expires;
	unsigned long long it_sched_expires;
	struct list_head cpu_timers[3];

/* process credentials */
	uid_t uid,euid,suid,fsuid;
	gid_t gid,egid,sgid,fsgid;
	struct group_info *group_info;
	kernel_cap_t   cap_effective, cap_inheritable, cap_permitted;
	unsigned keep_capabilities:1;
	struct user_struct *user;//创建该进程的用户的各资源使用计数器

	/* 任务名,进程正在运行的可执行文件的文件名 */
	char comm[TASK_COMM_LEN]; /* executable name excluding path
				     - access with [gs]et_task_comm (which lock
				       it with task_lock())
				     - initialized normally by flush_old_exec */
/* file system info */
	//link_count文件链的数目,用于防止递归循环,而total_link_count限制路径名中连接的最大数目
	int link_count, total_link_count;
#ifdef CONFIG_SYSVIPC
/* ipc stuff */
	struct sysv_sem sysvsem;
#endif
/* CPU-specific state of this task */
	struct thread_struct thread;//进程上下文,与体系结构相关,各种寄存器,进程切换时要保存和恢复的进程内容
/* filesystem information */
	struct fs_struct *fs;//文件系统相关的信息,保存进程与 VFS 的关系信息,如当前工作目录和chroot限制有关的信息
/* open file information */
	struct files_struct *files;//打开文件的信息,系统打开文件表,包含进程打开的所有文件.内核中每个打开的文件由一个文件描述符表示,该描述符在files->fd_array中当索引
/* namespaces */
	/*指针使多个进程可以共享一组子命名空间*/
	struct nsproxy *nsproxy;
/* signal handlers */
/*信号处理程序*/
	//会话和进程组ID保存在用于信号处理的结构中,>signal->__session表示全局SID,而全局PGID则保存在task_struct->signal->__pgrp。辅助函数set_task_session和set_task_pgrp可用于修改这些值
	struct signal_struct *signal;
	struct sighand_struct *sighand;//用于管理设置的信号处理程序的信息

	//阻塞(屏蔽)信号的位图,进程收到阻塞的信号会一直保存在待处理的位图(pending)中,但不会被处理,直到该位图中阻塞被去除
	sigset_t blocked, real_blocked;
	sigset_t saved_sigmask;		/* To be restored with TIF_RESTORE_SIGMASK */
	struct sigpending pending;//一个链表头和待处理信号位图,位图中存着待处理的信号编号,信号编号的信息存在链表中,发送信号时将信号存入目标进程的该结构中

	//通常,信号处理程序使用所述进程在用户状态下的栈。但POSIX强制要求提供一种选项,在专门用于信号处理的栈上运行信号处理程序(使用sigaltstack系统调用)。这个附加的栈(必须通过用户应用程序显式分配),其地址和长度分别保存在sas_ss_sp和sas_ss_size
	unsigned long sas_ss_sp;//专门用于信号处理的栈地址
	size_t sas_ss_size;//专门用于信号处理的栈长度
	int (*notifier)(void *priv);//信号通知者函数,收到信号时执行该回调函数,返回0时屏蔽此信号
	void *notifier_data;//notifier函数的参数
	sigset_t *notifier_mask;
#ifdef CONFIG_SECURITY
	void *security;
#endif

/* Thread group tracking */
   	u32 parent_exec_id;
   	u32 self_exec_id;

/* journalling filesystem info */
	//系统日志句柄,用于文件系统的日志
	void *journal_info;

/* VM state */
	struct reclaim_state *reclaim_state;

	struct backing_dev_info *backing_dev_info;

	struct io_context *io_context;

	unsigned long ptrace_message;
	siginfo_t *last_siginfo; /* For ptrace use.  */
    ...
};

//include/linux/resource.h
//进程资源限制,调用setrlimit来增减当前限制,getrlimits用于检查当前限制
struct rlimit {
	unsigned long	rlim_cur;//进程当前的资源限制,也称之为软限制(soft limit)。
	unsigned long	rlim_max;//rlim_max是该限制的最大容许值,因此也称之为硬限制(hard limit)。
};

rlim数组中的位置标识了受限制资源的类型,这也是内核需要定义预处理器常数,将资源与位置关联起来的原因。表2-1列出了可能的常数及其含义。关于如何最佳地运用各种限制,系统程序设计方面的教科书提供了详细的说明,而setrlimit(2)的手册页详细描述了所有的限制。
在这里插入图片描述
由于Linux试图建立与特定的本地UNIX系统之间的二进制兼容性,因此不同体系结构的数值可能不同

如果某一类资源没有使用限制(几乎所有资源的默认设置),则将rlim_max设置为RLIM_INFINITY。例外情况包括下面所列举的

  1. 打开文件的数目(RLIMIT_NOFILE,默认限制在1 024)。
  2. 每用户的最大进程数(RLIMIT_NPROC),定义为max_threads/2。max_threads是一个全局变量,指定了在把八分之一可用内存用于管理线程信息的情况下,可以创建的线程数目。在计算时,提前给定了20个线程的最小可能内存用量

init进程的限制在系统启动时即生效,定义在include/asm-generic-resource.h中的 INIT_RLIMITS。

读者可以关注一下内核版本2.6.25,在本书编写时仍然在开发中,该版本的内核在proc文件系统中对每个进程都包含了对应的一个文件,这样就可以查看当前的rlimit值:

wolfgang@meitner> cat /proc/self/limits 
Limit Soft Limit Hard Limit Units 
Max cpu time unlimited unlimited ms 
Max file size unlimited unlimited bytes 
Max data size unlimited unlimited bytes 
Max stack size 8388608 unlimited bytes 
Max core file size 0 unlimited bytes
Max resident set unlimited unlimited bytes 
Max processes unlimited unlimited processes 
Max open files 1024 1024 files 
Max locked memory unlimited unlimited bytes 
Max address space unlimited unlimited bytes 
Max file locks unlimited unlimited locks 
Max pending signals unlimited unlimited signals 
Max msgqueue size unlimited unlimited bytes 
Max nice priority 0 0 
Max realtime priority 0 0 
Max realtime timeout unlimited unlimited us

内核版本2.6.24已经包含了用于生成该信息的大部分代码,但与/proc文件系统的关联可能只有后续的内核发布版本才会完成。

2.3.1 进程类型

典型的UNIX进程包括:由二进制代码组成的应用程序、单线程(计算机沿单一路径通过代码,不会有其他路径同时运行)、分配给应用程序的一组资源(如内存、文件等)。新进程是使用fork和exec系统调用产生的

  1. fork生成当前进程的一个相同副本,该副本称之为子进程。原进程的所有资源都以适当的方式复制到子进程,因此该系统调用之后,原来的进程就有了两个独立的实例。这两个实例的联系包括:同一组打开文件、同样的工作目录、内存中同样的数据(两个进程各有一份副本),等等。此外二者别无关联(道Linux使用了写时复制机制,直至新进程对内存页执行写操作才会复制内存页面,这比在执行fork时盲目地立即复制所有内存页要更高效。父子进程内存页之间的联系,只有对内核才是可见的,对应用程序是透明的。)
  2. exec从一个可执行的二进制文件加载另一个应用程序,来代替当前运行的进程。换句话说,加载了一个新程序。因为exec并不创建新进程,所以必须首先使用fork复制一个旧的程序,然后调用exec在系统上创建另一个应用程序。

上述两个调用在所有UNIX操作系统变体上都是可用的,其历史可以追溯到很久之前,除此之外Linux还提供了clone系统调用。clone的工作原理基本上与fork相同,但新进程不是独立于父进程的,而可以与其共享某些资源。可以指定需要共享和复制的资源种类,例如,父进程的内存数据、打开文件或安装的信号处理程序。

clone用于实现线程,但仅仅该系统调用不足以做到这一点,还需要用户空间库才能提供完整的实现。线程库的例子,有Linuxthreads和Next Generation Posix Threads等。

2.3.2 命名空间

命名空间提供了虚拟化的一种轻量级形式,使得我们可以从不同的方面来查看运行系统的全局属性。该机制类似于Solaris中的zone或FreeBSD中的jail。对该概念做一般概述之后,我将讨论命名空间框架所提供的基础设施。

  1. 概念
    传统上,在Linux以及其他衍生的UNIX变体中,许多资源是全局管理的。例如,系统中的所有进程按照惯例是通过PID标识的,这意味着内核必须管理一个全局的PID列表。而且,所有调用者通过uname系统调用返回的系统相关信息(包括系统名称和有关内核的一些信息)都是相同的。用户ID的管理方式类似,即各个用户是通过一个全局唯一的UID号标识。

    全局ID使得内核可以有选择地允许或拒绝某些特权。虽然UID为0的root用户基本上允许做任何事,但其他用户ID则会受到限制。例如UID为n的用户,不允许杀死属于用户m的进程(m≠ n)。但这不能防止用户看到彼此,即用户n可以看到另一个用户m也在计算机上活动。只要用户只能操纵他们自己的进程,这就没什么问题,因为没有理由不允许用户看到其他用户的进程。

    但有些情况下,这种效果可能是不想要的。如果提供Web主机的供应商打算向用户提供Linux计算机的全部访问权限,包括root权限在内。传统上,这需要为每个用户准备一台计算机,代价太高。使用KVM或VMWare提供的虚拟化环境是一种解决问题的方法,但资源分配做得不是非常好。计算机的各个用户都需要一个独立的内核,以及一份完全安装好的配套的用户层应用。

    命名空间提供了一种不同的解决方案,所需资源较少。在虚拟化的系统中,一台物理计算机可以运行多个内核,可能是并行的多个不同的操作系统。而命名空间则只使用一个内核在一台物理计算机上运作,前述的所有全局资源都通过命名空间抽象起来。这使得可以将一组进程放置到容器中,各个容器彼此隔离。隔离可以使容器的成员与其他容器毫无关系。但也可以通过允许容器进行一定的共享,来降低容器之间的分隔。例如,容器可以设置为使用自身的PID集合,但仍然与其他容器共享部分文件系统。

    本质上,命名空间建立了系统的不同视图。此前的每一项全局资源都必须包装到容器数据结构中,只有资源和包含资源的命名空间构成的二元组仍然是全局唯一的。虽然在给定容器内部资源是自足的,但无法提供在容器外部具有唯一性的ID。图2-3给出了此情况的一个概述。

    在这里插入图片描述

    考虑系统上有3个不同命名空间的情况。命名空间可以组织为层次,我会在这里讨论这种情况。一个命名空间是父命名空间,衍生了两个子命名空间。假定容器用于虚拟主机配置中,其中的每个容器必须看起来像是单独的一台Linux计算机。因此其中每一个都有自身的init进程,PID为0,其他进程的PID以递增次序分配。两个子命名空间都有PID为0的init进程,以及PID分别为2和3的两个进程。由于相同的PID在系统中出现多次,PID号不是全局唯一的。

    虽然子容器不了解系统中的其他容器,但父容器知道子命名空间的存在,也可以看到其中执行的所有进程。图中子容器的进程映射到父容器中,PID为4到9。尽管系统上有9个进程,但却需要15个PID来表示,因为一个进程可以关联到多个PID。至于哪个PID是“正确”的,则依赖于具体的上下文。

    如果命名空间包含的是比较简单的量,也可以是非层次的,例如下文讨论的UTS命名空间。在这种情况下,父子命名空间之间没有联系。

    请注意,Linux系统对简单形式的命名空间的支持已经有很长一段时间了,主要是chroot系统调用。该方法可以将进程限制到文件系统的某一部分,因而是一种简单的命名空间机制。但真正的命名空间能够控制的功能远远超过文件系统视图。

    新的命名空间可以用下面两种方法创建。

    1. 在用fork或clone系统调用创建新进程时,有特定的选项可以控制是与父进程共享命名空间,还是建立新的命名空间。
    2. unshare系统调用将进程的某些部分从父进程分离,其中也包括命名空间。更多信息请参见手册页unshare(2)。

    在进程已经使用上述的两种机制之一从父进程命名空间分离后,从该进程的角度来看,改变全局属性不会传播到父进程命名空间,而父进程的修改也不会传播到子进程,至少对于简单的量是这样。而对于文件系统来说,情况就比较复杂,其中的共享机制非常强大,带来了大量的可能性,具体的情况会在第8章讨论。

    在标准内核中命名空间当前仍然标记为试验性的,为使内核的所有部分都能够感知到命名空间,相关开发仍然在进行中。但就内核版本2.6.24而言,基本的框架已经建立就绪。当前的实现仍然存在一些问题,相关的信息可以参见Documentation/namespaces/compatibility-list.txt文件。

  2. 实现
    命名空间的实现需要两个部分:每个子系统的命名空间结构,将此前所有的全局组件包装到命名空间中;将给定进程关联到所属各个命名空间的机制。图2-4说明了具体情形。
    在这里插入图片描述

    子系统此前的全局属性现在封装到命名空间中,每个进程关联到一个选定的命名空间。每个可以感知命名空间的内核子系统都必须提供一个数据结构,将所有通过命名空间形式提供的对象集中起来。struct nsproxy用于汇集指向特定于子系统的命名空间包装器的指针:

    //include/linux/nsproxy.h
    //命名空间
    struct nsproxy {
        atomic_t count; //引用计数
        /*UTS命名空间包含了运行内核的名称,版本,底层体系结构类型等信息.UTS是UNIX
        Timesharing System的简称。*/
        struct uts_namespace *uts_ns;
        /*ipc命名空间保存所有与进程间通信(IPC)有关的信息。*/
        struct ipc_namespace *ipc_ns;
        /*mnt命名空间(VFS命名空间)保存已挂载的文件系统的视图*/
        struct mnt_namespace *mnt_ns;
        /*pid命名空间保存有关进程ID的信息*/
        struct pid_namespace *pid_ns;
        /*net命名空间保存含所有网络相关的命名空间参数*/
        struct user_namespace *user_ns;
        /*net命名空间保存含所有网络相关的命名空间参数*/
        struct net 	     *net_ns;
    };
    

    讨论相应的子系统时,会介绍各个命名空间容器的内容。在本章中,我们主要讲解UTS和用户命名空间。由于在创建新进程时可使用fork建立一个新的命名空间,因此必须提供控制该行为的适当的标志。每个命名空间都有一个对应的标志:

    //include/linux/sched.h
    #define CLONE_NEWUTS		0x04000000	/* New utsname group? */
    #define CLONE_NEWIPC		0x08000000	/* New ipcs */
    #define CLONE_NEWUSER		0x10000000	/* New user namespace */
    #define CLONE_NEWPID		0x20000000	/* New pid namespace */
    #define CLONE_NEWNET		0x40000000	/* New network namespace */
    

    因为使用了指针,多个进程可以共享一组子命名空间。这样,修改给定的命名空间,对所有属于该命名空间的进程都是可见的。

    init_nsproxy 定义了初始的全局命名空间,其中维护了指向各子系统初始的命名空间对象的指针:

    //kernel/nsproxy.c
    struct nsproxy init_nsproxy = INIT_NSPROXY(init_nsproxy);
    
    //include/linux/init_task.h
    #define INIT_NSPROXY(nsproxy) {						\
        .pid_ns		= &init_pid_ns,					\
        .count		= ATOMIC_INIT(1),				\
        .uts_ns		= &init_uts_ns,					\
        .mnt_ns		= NULL,						\
        INIT_NET_NS(net_ns)                                             \
        INIT_IPC_NS(ipc_ns)						\
        .user_ns	= &init_user_ns,				\
    }
    
    • UTS命名空间
      UTS命名空间几乎不需要特别的处理,因为它只需要简单量,没有层次组织。所有相关信息都汇集到下列结构的一个实例中:

      //include/linux/utsname.h
      struct uts_namespace {
          struct kref kref;//引用计数,用于跟踪内核中有多少地方使用了struct uts_namespace的实例
          struct new_utsname name;
      };
      
      struct new_utsname {
          char sysname[65];
          char nodename[65];
          char release[65];
          char version[65];
          char machine[65];
          char domainname[65];
      };
      

      各个字符串分别存储了系统的名称(Linux…)、内核发布版本、机器名,等等。使用uname工具可以取得这些属性的当前值,也可以在/proc/sys/kernel/中看到

      初始设置保存在init_uts_ns中

      //init/version.c
      struct uts_namespace init_uts_ns = {
          .kref = {
              .refcount	= ATOMIC_INIT(2),
          },
          .name = {
              .sysname	= UTS_SYSNAME,
              .nodename	= UTS_NODENAME,
              .release	= UTS_RELEASE,
              .version	= UTS_VERSION,
              .machine	= UTS_MACHINE,
              .domainname	= UTS_DOMAINNAME,
          },
      };
      

      相关的预处理器常数在内核中各处定义。例如,UTS_RELEASE在<utsrelease.h>中定义,该文件是连编时通过顶层Makefile动态生成的。

      请注意,UTS结构的某些部分不能修改。例如,把sysname换成Linux以外的其他值是没有意义的,但改变机器名是可以的。

      内核如何创建一个新的UTS命名空间呢?这属于copy_utsname函数的职责。在某个进程调用fork并通过CLONE_NEWUTS标志指定创建新的UTS命名空间时,则调用该函数。在这种情况下,会生成先前的uts_namespace实例的一份副本,当前进程的nsproxy实例内部的指针会指向新的副本。如此而已!由于在读取或设置UTS属性值时,内核会保证总是操作特定于当前进程的uts_namespace实例,在当前进程修改UTS属性不会反映到父进程,而父进程的修改也不会传播到子进程。

    • 用户命名空间
      用户命名空间在数据结构管理方面类似于UTS:在要求创建新的用户命名空间时,则生成当前用户命名空间的一份副本,并关联到当前进程的nsproxy实例。但用户命名空间自身的表示要稍微复杂一些:

      //include/linux/user_namespace.h
      struct user_namespace {
          struct kref		kref;//引用计数
          struct hlist_head	uidhash_table[UIDHASH_SZ];//对命名空间中的每个用户,都有一个struct user_struct的实例负责记录其资源消耗,各个实例可通过散列表uidhash_table访问
          struct user_struct	*root_user;
      };
      

      对命名空间中的每个用户,都有一个struct user_struct的实例负责记录其资源消耗,各个实例可通过散列表uidhash_table访问。

      只要知道该结构维护了一些统计数据(如进程和打开文件的数目)就足够了。我们更感兴趣的问题是:每个用户命名空间对其用户资源使用的统计,与其他命名空间完全无关,对root用户的统计也是如此。这是因为在克隆一个用户命名空间时,为当前用户和root都创建了新的user_struct实例:

      • clone_user_ns
        • kmalloc(sizeof(struct user_namespace), GFP_KERNEL); 申请用户命名空间内存
        • ns->root_user = alloc_uid(ns, 0); 插入新的root用户
        • new_user = alloc_uid(ns, current->uid); 申请新的当前用户
        • switch_uid 将当前进程的用户空间切换成新创建的用户空间

      alloc_uid是一个辅助函数,对当前命名空间中给定UID的一个用户,如果该用户没有对应的user_struct实例,则分配一个新的实例。在为root和当前用户分别设置了user_struct实例后,switch_uid确保从现在开始将新的user_struct实例用于资源统计。实质上就是将struct task_struct的user成员指向新的user_struct实例。

      请注意,如果内核编译时未指定支持用户命名空间,那么复制用户命名空间实际上是空操作,即总是会使用默认的命名空间。

2.3.3 进程ID号

UNIX进程总是会分配一个号码用于在其命名空间中唯一地标识它们。该号码被称作进程ID号,简称PID。用fork或clone产生的每个进程都由内核自动地分配了一个新的唯一的PID值

  1. 进程ID
    但每个进程除了PID这个特征值之外,还有其他的ID。有下列几种可能的类型。

    • 处于某个线程组(在一个进程中,以标志CLONE_THREAD来调用clone建立的该进程的不同的执行上下文,我们在后文会看到)中的所有进程都有统一的线程组ID(TGID)。如果进程没有使用线程,则其PID和TGID相同
      线程组中的主进程被称作组长(group leader)。通过clone创建的所有线程的task_struct的group_leader成员,会指向组长的task_struct实例。
    • 另外,独立进程可以合并成进程组(使用setpgrp系统调用)。进程组成员的task_struct的pgrp属性值都是相同的,即进程组组长的PID。进程组简化了向组的所有成员发送信号的操作,这对于各种系统程序设计应用(参见系统程序设计方面的文献,例如[SR05])是有用的。请注意,用管道连接的进程包含在同一个进程组中。
    • 几个进程组可以合并成一个会话。会话中的所有进程都有同样的会话ID,保存在task_struct的session成员中。SID可以使用setsid系统调用设置。它可以用于终端程序设计,但和我们这里的讨论不相干。

    命名空间增加了PID管理的复杂性。PID命名空间按层次组织。在建立一个新的命名空间时,该命名空间中的所有PID对父命名空间都是可见的,但子命名空间无法看到父命名空间的PID。但这意味着某些进程具有多个PID,凡可以看到该进程的命名空间,都会为其分配一个PID。 这必须反映在数据结构中。我们必须区分局部ID和全局ID

    • 全局ID是在内核本身和初始命名空间中的唯一ID号,在系统启动期间开始的init进程即属于初始命名空间。对每个ID类型,都有一个给定的全局ID,保证在整个系统中是唯一的。
    • 局部ID属于某个特定的命名空间,不具备全局有效性。对每个ID类型,它们在所属的命名空间内部有效,但类型相同、值也相同的ID可能出现在不同的命名空间中。

    全局PID和TGID直接保存在task_struct中,分别是task_struct的pid和tgid成员:

    //include/linux/sched.h
    //进程描述符(PCB:Process Control Block进程控制块)
    struct task_struct {
        ...
        /*真正的进程id,每个进程不同,全局命名空间中的id,不是局部id*/
        pid_t pid;
        /*
        * thread group id 线程组id,
        * 当创建线程时,线程的tgid=主进程的pid,进程的tgid=自己的pid
        * 应用层pthread_create函数创建的线程,编程中获取的id是线程库生成的id,与这里无关
        * */
        pid_t tgid;
    };
    

    会话和进程组ID不是直接包含在task_struct本身中,但保存在用于信号处理的结构中。task_struct->signal->__session表示全局SID,而全局PGID则保存在task_struct->signal->__pgrp。辅助函数set_task_sessionset_task_pgrp可用于修改这些值。

  2. 管理PID
    除了这两个字段之外,内核还需要找一个办法来管理所有命名空间内部的局部量,以及其他ID(如TID和SID)。这需要几个相互连接的数据结构,以及许多辅助函数

    • 数据结构
      一个小型的子系统称之为PID分配器(pid allocator)用于加速新ID的分配。此外,内核需要提供辅助函数,以实现通过ID及其类型查找进程的task_struct的功能,以及将ID的内核表示形式和用户空间可见的数值进行转换的功能。

      //include/linux/pid_namespace.h
      struct pid_namespace {
          ...
          struct task_struct *child_reaper;//pid命名空间的进程,发挥的作用相当于全局的init进程。init的一个目的是对孤儿进程调用wait4,命名空间局部的init变体也必须完成该工作
          ...
          int level;//当前命名空间在命名空间层次结构中的深度,从0开始.内核即可推断进程会关联到多少个ID,每个命名空间一个id
          struct pid_namespace *parent;//父命名空间
      
      };
      

      PID的管理围绕两个数据结构展开:struct pid是内核对PID的内部表示,而struct upid则表示特定的命名空间中可见的信息。两个结构的定义如下:

      //include/linux/pid.h
      //特定的命名空间中可见的信息
      struct upid {
          /* Try to keep pid_chain in the same cacheline as nr for find_pid */
          int nr;//ID的数值
          struct pid_namespace *ns;//指向该ID所属的命名空间的指针
          struct hlist_node pid_chain;//散列溢出链表成员,所有upid实例都保存在一个散列表中,表头为 pid_hash 数组中的元素
      };
      
      //内核对PID的内部表示
      struct pid
      {
          atomic_t count;//引用计数器
          /* lists of tasks that use this pid */
          struct hlist_head tasks[PIDTYPE_MAX];//数组,每个成员都是一个散列表头,对应一个ID类型,成员类型为 struct pid_link->node.所有共享同一给定ID的task_struct实例,都通过该列表连接起来
          int level;//level表示可以看到该进程的命名空间的数目(换言之,即包含该进程的命名空间在命名空间层次结构中的深度)
          struct upid numbers[1];//upid实例的数组,每个数组项都对应于一个命名空间。注意该数组形式上只有一个数组项,如果一个进程只包含在全局命名空间中,那么确实如此。由于该数组位于结构的末尾,因此只要分配更多的内存空间,即可向数组添加附加的项。命名空间的ns->level对应数组的下标.即数组中每个元素表示对应深度命名空间的pid
      };
      
      enum pid_type
      {
          PIDTYPE_PID,//进程id
          PIDTYPE_PGID,//进程组id
          PIDTYPE_SID,//会话session id几个进程组可以合并成一个会话。会话中的所有进程都有同样的会话ID
          PIDTYPE_MAX
      };
      

      在这里插入图片描述

      请注意,枚举类型中定义的ID类型不包括线程组ID!这是因为线程组ID无非是线程组组长的PID而已,因此再单独定义一项是不必要的。

      一个进程可能在多个命名空间中可见,而其在各个命名空间中的局部ID各不相同。level表示可以看到该进程的命名空间的数目(换言之,即包含该进程的命名空间在命名空间层次结构中的深度),而numbers是一个upid实例的数组,每个数组项都对应于一个命名空间。注意该数组形式上只有一个数组项,如果一个进程只包含在全局命名空间中,那么确实如此。由于该数组位于结构的末尾,因此只要分配更多的内存空间,即可向数组添加附加的项

      由于所有共享同一ID的task_struct实例都按进程存储在一个散列表中,因此需要在struct task_struct中增加一个散列表元素:

      //include/linux/sched.h
      struct task_struct { 
          ... 
          /* PID与PID散列表的联系。 */ 
          struct pid_link pids[PIDTYPE_MAX]; 
          ... 
      };
      

      辅助数据结构pid_link可以将task_struct连接到表头在struct pid中的散列表上:

      //include/linux/pid.h
      struct pid_link
      {
          struct hlist_node node;//链表成员,表头为 struct pid->tasks[id_type]
          struct pid *pid;//关联进程的pid结构体
      };
      

      为在给定的命名空间中查找对应于指定PID数值的pid结构实例,使用了一个散列表:

      //kernel/pid.c
      static struct hlist_head *pid_hash;//链表头数组,大小取决于内存配置在2^4=16到2^12=4096之间,结点为struct upid,pidhash_init函数计算并分配内存
      

      pid和进程建立关系看函数 attach_pid

    • 函数
      内核提供了若干辅助函数,用于操作和扫描上面描述的数据结构。本质上内核必须完成下面两个不同的任务。

      1. 给出局部数字ID和对应的命名空间,查找此二元组描述的task_struct。
      2. 给出task_struct、ID类型、命名空间,取得命名空间局部的数字ID。

      将task_struct实例变为PID包含下面两个步骤。

      1. 获得与task_struct关联的pid实例。辅助函数task_pid获取进程pid、task_tgid获取线程组组长PID、task_pgrp获取进程组ID和task_session分别用于取得不同类型的ID。
      2. 在获得pid实例之后,从struct pid的numbers数组中的uid信息,即可获得PID,pid_nr_ns

      因为父命名空间可以看到子命名空间中的PID,反过来却不行,内核必须确保当前命名空间的level小于或等于产生局部PID的命名空间的level。

      同样重要的是要注意到,内核只需要关注产生全局PID。因为全局命名空间中所有其他ID类型都会映射到PID,因此不必生成诸如全局TGID或SID。

      除了在第2步使用的pid_nr_ns之外,内核还可以使用下列辅助函数:

      1. pid_vnr返回该ID所属的命名空间所看到的局部PID
      2. pid_nr则获取从init进程看到的全局PID。

      这两个函数都依赖于pid_nr_ns,并自动选择适当的level:0用于获取全局PID,而pid->level则用于获取局部PID。

      内核提供了几个辅助函数,合并了前述步骤:

      //kernel/pid.c 
      pid_t task_pid_nr_ns(struct task_struct *tsk, struct pid_namespace *ns) 
      pid_t task_tgid_nr_ns(struct task_struct *tsk, struct pid_namespace *ns) 
      pid_t task_pgrp_nr_ns(struct task_struct *tsk, struct pid_namespace *ns) 
      pid_t task_session_nr_ns(struct task_struct *tsk, struct pid_namespace *ns)
      

      PID和命名空间转换为pid实例。同样需要下面两个步骤

      1. 给出进程的局部数字PID和关联的命名空间(这是PID的用户空间表示),为确定pid实例(这是PID的内核表示),内核必须采用标准的散列方案。首先,根据PID和命名空间指针计算在pid_hash数组中的索引(为达到该目的,内核使用了乘法散列法,用的是与机器字所能表示的最大数字成黄金分割比率的一个素数。具体细节可参见[Knu97]),然后遍历散列表直至找到所要的元素。这是通过辅助函数find_pid_ns处理的

        //kernel/pid.c 
        struct pid * fastcall find_pid_ns(int nr, struct pid_namespace *ns)
        

        struct upid的实例保存在散列表中,由于这些实例直接包含在struct pid中,内核可以使用container_of机制(参见附录C)推断出所要的信息。

      2. pid_task取出pid->tasks[type]散列表中的第一个task_struct实例。

      这两个步骤可以通过辅助函数find_task_by_pid_type_ns完成

      其他辅助函数基于find_task_by_pid_type_ns

      //kernel/pid.c
      //根据给出的数字PID和进程的命名空间来查找task_struct实例
      struct task_struct *find_task_by_pid_ns(pid_t nr, struct pid_namespace *ns)
      //通过局部数字PID查找进程
      struct task_struct *find_task_by_vpid(pid_t vnr)
      //通过全局数字PID查找进程
      struct task_struct *find_task_by_pid(pid_t nr)
      

      内核源代码中许多地方都需要find_task_by_pid,因为很多特定于进程的操作(例如,使用kill发送一个信号)都通过PID标识目标进程。

  3. 生成唯一的PID
    除了管理PID之外,内核还负责提供机制来生成唯一的PID(尚未分配)。在这种情况下,可以忽略各种不同类型的PID之间的差别,因为按一般的UNIX观念,只需要为PID生成唯一的数值即可。所有其他的ID都可以派生自PID,在下文讨论fork和clone时会看到这一点。在随后的几节中,名词PID还是指一般的UNIX进程ID(PIDTYPE_PID)。

    为跟踪已经分配和仍然可用的PID,内核使用一个大的位图,其中每个PID由一个比特标识。PID的值可通过对应比特在位图中的位置计算而来

    因此,分配一个空闲的PID,本质上就等同于寻找位图中第一个值为0的比特,接下来将该比特设置为1。反之,释放一个PID可通过将对应的比特从1切换为0来实现。这些操作使用下述两个函数实现:

    //kernel/pid.c
    //从位图中分配空闲的pid
    static int alloc_pidmap(struct pid_namespace *pid_ns)
    //从位图中释放使用的pid
    static fastcall void free_pidmap(struct pid_namespace *pid_ns, int pid)
    //为到当前命名空间深度中所有的命名空间分配局部空闲的pid,新建进程时
    struct pid *alloc_pid(struct pid_namespace *ns)
    

2.3.4 进程关系

除了源于ID连接的关系之外,内核还负责管理建立在UNIX进程创建模型之上“家族关系”。

在这里插入图片描述

task_struct数据结构提供了两个链表表头,用于实现这些关系:

//include/linux/sched.h
struct task_struct { 
    ... 
    struct list_head children;	/* list of my children *///链表头,成员为子进程的 sibling
	struct list_head sibling;	/* linkage in my parent's children list *///链表成员,链表头为父进程的 children
    ... 
}

2.4 进程管理相关的系统调用

在本节中,我将讨论fork和exec系列系统调用的实现。通常这些调用不是由应用程序直接发出的,而是通过一个中间层调用,即负责与内核通信的C标准库。

从用户状态切换到核心态的方法,依不同的体系结构而各有不同。在附录A中,详细讲述了用于在这两种状态之间切换的机制,并解释了用户空间和内核空间之间如何交换参数。就目前而言,将内核视为由C标准库使用的“程序库”即可,我在第1章简要地提到过这一点。

2.4.1 进程复制

传统的UNIX中用于复制进程的系统调用是fork。但它并不是Linux为此实现的唯一调用,实际上Linux实现了3个。

  • fork是重量级调用,因为它建立了父进程的一个完整副本,然后作为子进程执行。为减少与该调用相关的工作量,Linux使用了写时复制(copy-on-write)技术,下文中会讨论。
  • vfork类似于fork,但并不创建父进程数据的副本。相反,父子进程之间共享数据。这节省了大量CPU时间(如果一个进程操纵共享数据,则另一个会自动注意到)。
    vfork设计用于子进程形成后立即执行execve系统调用加载新程序的情形。在子进程退出或开始新程序之前,内核保证父进程处于堵塞状态。
    由于fork使用了写时复制技术,vfork速度方面不再有优势,因此应该避免使用它
  • clone产生线程,可以对父子进程之间的共享、复制进行精确控制。
  1. 写时复制
    内核使用了写时复制(Copy-On-Write,COW)技术,以防止在fork执行时将父进程的所有数据复制到子进程。该技术利用了下述事实:进程通常只使用了其内存页的一小部分(进程访问最频繁的页的集合被称为工作区(working set)。)。在调用fork时,内核通常对父进程的每个内存页,都为子进程创建一个相同的副本。这有两种很不好的负面效应。

    1. 使用了大量内存。
    2. 复制操作耗费很长时间。

    如果应用程序在进程复制之后使用exec立即加载新程序,那么负面效应会更严重。这实际上意味着,此前进行的复制操作是完全多余的,因为进程地址空间会重新初始化,复制的数据不再需要了。

    内核可以使用技巧规避该问题。并不复制进程的整个地址空间,而是只复制其页表。这样就建立了虚拟地址空间和物理内存页之间的联系,我在第1章简要地讲过,具体过程请参见第3章和第4章。因此,fork之后父子进程的地址空间指向同样的物理内存页。

    当然,父子进程不能允许修改彼此的页,这也是两个进程的页表对页标记了只读访问的原因,即使在普通环境下允许写入也是如此。

    假如两个进程只能读取其内存页,那么二者之间的数据共享就不是问题,因为不会有修改。
    只要一个进程试图向复制的内存页写入,处理器会向内核报告访问错误(此类错误被称作缺页异常)。内核然后查看额外的内存管理数据结构(参见第4章),检查该页是否可以用读写模式访问,还是只能以只读模式访问。如果是后者,则必须向进程报告段错误。读者会在第4章看到,缺页异常处理程序的实际实现要复杂得多,因为还必须考虑其他方面的问题,例如换出的页。

    如果页表项将一页标记为“只读”,但通常情况下该页应该是可写的,内核可根据此条件来判断该页实际上是COW页。因此内核会创建该页专用于当前进程的副本,当然也可以用于写操作。直至第4章我们才会讨论复制操作的实现方式,因为这需要内存管理方面广泛的背景知识。

    COW机制使得内核可以尽可能延迟内存页的复制,更重要的是,在很多情况下不需要复制。这节省了大量时间。

  2. 系统调用
    fork、vfork和clone系统调用的入口点分别是sys_fork、sys_vfork和sys_clone函数。其定义依赖于具体的体系结构,因为在用户空间和内核空间之间传递参数的方法因体系结构而异(更多细节请参见第13章)。上述函数的任务是从处理器寄存器中提取由用户空间提供的信息,调用体系结构无关的do_fork函数,后者负责进程复制。

    不同的fork变体,主要是标志集参数不同

  3. do_fork的实现
    3个fork机制最终都调用了kernel/fork.c中的do_fork(一个体系结构无关的函数)
    在这里插入图片描述

    • do_fork
      • copy_process 处理3个系统调用(fork、vfork和clone)的主要工作,进程创建,各资源创建,进程相关数据结构建立关联
      • clone_flags & CLONE_NEWPID 生成新的pid命名空间则调用 task_pid_nr_ns 在当前进程的pid命名空间中分配pid,如果pid命名空间没改变,返回局部pid 调用 task_pid_vnr
      • p->ptrace & PT_PTRACED 如果将要使用Ptrace(参见第13章)监控新的进程,那么在创建新进程后会立即向其发送SIGSTOP信号,以便附接的调试器检查其数据
      • wake_up_new_task 唤醒新创建的子进程,即将其task_struct添加到调度器队列
      • clone_flags & CLONE_VFORK 如果使用vfork机制,vfork与父进程共享数据段,子进程先于父进程被启动
        • wait_for_completion 休眠等待子进程结束,子进程结束后内核自动调用complete(vfork_done)。这会唤醒父进程
  4. 复制进程 copy_process
    在do_fork中大多数工作是由copy_process函数完成的,其代码流程如图2-8所示。请注意,该函数必须处理3个系统调用(fork、vfork和clone)的主要工作。
    在这里插入图片描述

    由于内核必须处理许多特别和具体的情形,这里只讲述该函数的一个略微简化的版本,免得迷失于无数的细节而忽略最重要的方面。

    父子进程只有一个成员不同即内核栈:

    //include/linux/sched.h
    //保存了线程所有特定于处理器的底层信息,即进程的内核栈
    union thread_union {
        struct thread_info thread_info;
        unsigned long stack[THREAD_SIZE/sizeof(long)];
    };
    

    只要设置了预处理器常数__HAVE_THREAD_FUNCTIONS,那么各个体系结构可以随意在stack数组中存储什么数据。在这种情况下,它们必须自行实现task_thread_infotask_stack_page,这两个函数用于获取给定task_struct实例的线程信息和核心态栈。另外,它们必须实现dup_task_struct中调用的函数setup_thread_stack,以便确定stack成员的具体内存布局。当前只有IA-64和m68k不依赖于内核的默认方法

    在大多数体系结构上,使用一两个内存页来保存一个thread_union的实例。在IA-32上,两个内存页是默认设置,因此可用的内核栈长度略小于8 KiB,其中一部分被thread_info实例占据。不过要注意,配置选项4KSTACKS会将栈长度降低到4 KiB,即一个页面。如果系统上有许多进程在运行,这样做是有利的,因为每个进程可以节省一个页面。另一方面,对于经常趋向于使用过多栈空间的外部驱动程序来说,这可能导致问题。标准发布版所提供的内核,其所有核心部分都已经设计为能够在4 KiB栈长度配置下运转流畅,但一旦需要只提供二进制代码的驱动程序,就可能引发问题(糟糕的是,过去已经发生过这类问题),此类驱动通常习于向可用的栈空间乱塞数据。

    thread_info保存了特定于体系结构的汇编语言代码需要访问的那部分进程数据。尽管该结构的定义因不同的处理器而不同,大多数系统上该结构的内容类似于下列代码

    <asm-arch/thread_info.h> 
    struct thread_info { 
        struct task_struct *task; /* 当前进程task_struct指针 */ 
        struct exec_domain *exec_domain; /* 执行区间,用于实现执行区间(execution domain),后者用于在一类计算机上实现多种的ABI(Application Binary Interface,应用程序二进制接口)。例如,在AMD64系统的64bit模式下运行32bit应用程序。 */
        unsigned long flags; /* 保存各种特定于进程的标志,如果进程有待决信号则置位TIF_SIGPENDING,TIF_NEED_RESCHED表示该进程应该或想要调度器选择另一个进程替换本进程执行 */ 
        unsigned long status; /* 线程同步标志 */ 
        __u32 cpu; /* 当前CPU */ 
        int preempt_count; /* 0 => 可抢占, <0 => BUG */ 
        mm_segment_t addr_limit; /* 指定了进程可以使用的虚拟地址的上限.该限制适用于普通进程,但内核线程可以访问整个虚拟地址空间,包括只有内核能访问的部分。这并不意味着限制进程可以分配的内存数量。 */ 
        struct restart_block restart_block;/*用于实现信号机制*/ 
    }
    

    图2-9给出了task_struct、thread_info和内核栈之间的关系。在内核的某个特定组件使用了过多栈空间时,内核栈会溢出到thread_info部分,这很可能会导致严重的故障。此外在紧急情况下输出调用栈回溯时将会导致错误的信息出现,因此内核提供了kstack_end函数,用于判断给出的地址是否位于栈的有效部分之内

    在这里插入图片描述

    dup_task_struct会复制父进程task_struct和thread_info实例的内容,但stack则与新的thread_info实例位于同一内存区域。这意味着父子进程的task_struct此时除了栈指针之外是完全相同的,但子进程的task_struct实例会在copy_process过程中修改。

    1. current_thread_info可获得指向当前执行进程的thread_info实例的指针。其地址可以根据内核栈指针确定,因为thread_info实例总是位于栈顶(指向内核栈的指针通常保存在一个特别保留的寄存器中。有些体系结构特别是IA-32和AMD64使用了不同的解决方案)。因为每个进程分别使用各自的内核栈,进程到栈的映射是唯一的。
    2. current给出了当前进程task_struct实例的地址。该函数在源代码中出现非常频繁。该地址可以使用current_thread_info()确定:current = current_thread_info()->task。

    由于子进程的task_struct是从父进程的task_struct精确复制而来,因此相关的指针最初都指向同样的资源,或者说同样的具体资源实例,如图2-10所示。
    在这里插入图片描述

    • copy_process
      • dup_task_struct 创建新进程和新内核栈,将当前进程的数据复制到新进程中
      • 检查创建该进程的用户可创建的进程数目是否超出限制
      • sched_fork 初始化进程的调度器相关变量,使调度器有机会执行新进程
      • 调用多个 copy_xyz 函数,复制或共享父进程特定的内核子系统的资源
      • 分配pid

2.4.2 内核线程

内核线程经常称之为(内核)守护进程。它们用于执行下列任务:

  1. 周期性地将修改的内存页与页来源块设备同步(例如,使用mmap的文件映射)。
  2. 如果内存页很少使用,则写入交换区。
  3. 管理延时动作(deferred action)。
  4. 实现文件系统的事务日志。

基本上,有两种类型的内核线程。

  1. 类型1:线程启动后一直等待,直至内核请求线程执行某一特定操作。
  2. 类型2:线程启动后按周期性间隔运行,检测特定资源的使用,在用量超出或低于预置的限制值时采取行动。内核使用这类线程用于连续监测任务

调用 kernel_thread 函数可启动一个内核线程。其定义是特定于体系结构的,但原型总是相同的

//arch/x86/kernel/process_32.c
//创建线程,线程将执行用fn指针传递的函数,arg为传递给该函数的参数。flags指定创建线程的CLONE标志
int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)

内核线程注意两点:

  1. 它们在CPU的内核态(supervisor mode)执行,而不是用户状态
  2. 它们只可以访问虚拟地址空间的内核部分(高于TASK_SIZE的所有地址),但不能访问用户空间(0-TASK_SIZE)。
//include/linux/sched.h
struct task_struct {
    ...
    struct mm_struct *mm, *active_mm;
    ...
};

大多数计算机上系统的全部虚拟地址空间分成两个部分:底部可以由用户层程序访问,上部则专供内核使用。在用户进程执行内核函数时(例如,执行系统调用),虚拟地址空间的用户空间部分由mm指向的mm_struct实例描述。每当内核执行上下文切换时,虚拟地址空间的用户层部分都会切换,以便与当前运行的进程匹配

内核线程不能修改用户空间部分。由于内核线程之前可能是任何用户层进程在执行,因此用户空间部分的内容本质上是随机的,内核线程绝不能修改其内容。因此为强调用户空间部分不能访问,mm设置为空指针。但由于内核必须知道用户空间当前包含了什么,所以在active_mm中保存了指向mm_struct的一个指针来描述它

没有mm指针的进程称作惰性TLB进程.因为假如内核线程之后运行的进程与之前是同一个。在这种情况下,内核并不需要修改用户空间地址表,地址转换后备缓冲器(即TLB)中的信息仍然有效。只有在内核线程之后执行的进程是与此前不同的用户层进程时,才需要切换(并对应清除TLB数据)

当内核在进程上下文下运转时,mm和active_mm的值相同

内核线程可以用两种方法实现:

  1. kernel_thread ,该函数创建的父进程为调用该函数的进程,是比较老的函数,该函数接下来负责帮助内核调用daemonize以转换为守护进程。这依次引发下列操作

    1. 该函数从内核线程释放其父进程(用户进程)的所有资源(例如,内存上下文、文件描述符,等等),不然这些资源会一直锁定到线程结束,这是不可取的,因为守护进程通常运行到系统关机为止。因为守护进程只操作内核地址区域,它甚至不需要这些资源。
    2. daemonize阻塞信号的接收。
    3. 将init用作守护进程的父进程
  2. 新的实现函数是 kthread_create,该函数创建的父进程为kthreadd

    //kernel/kthread.c
    //创建内核线程,threadfn:线程函数,data:线程函数的参数,namefmt:线程名
    struct task_struct *kthread_create(int (*threadfn)(void *data),
                    void *data,
                    const char namefmt[],
                    ...)
    
  3. kthread_run,调用了 kthread_create创建线程,并立即唤醒它

    //include/linux/kthread.h
    //创建线程并立即唤醒
    #define kthread_run(threadfn, data, namefmt, ...)			   \
    ({									   \
        struct task_struct *__k						   \
            = kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \
        if (!IS_ERR(__k))						   \
            wake_up_process(__k);					   \
        __k;								   \
    })
    
  4. kthread_create_on_cpu 调用kthread_create创建内核线程,使之绑定到特定的CPU,这个版本内核没实现

ps -fax 查看系统进程列表,其中内核进程会用[]包围来与普通进程区分,如果内核线程绑定到特定的CPU,CPU的编号在斜线后给出。如 [migration/0]

2.4.3 启动新程序 execve

通过用新代码替换现存程序,即可启动新程序。Linux提供的execve系统调用可用于该目的(C标准库中有其他exec变体,但最终都基于execve。在前述章节中,exec经常用于指代这些变体之一)。

  1. execve的实现
    该系统调用的入口点是体系结构相关的sys_execve函数。该函数很快将其工作委托给系统无关的do_execve例程。

    //fs/exec.c
    //exec系统调用
    int do_execve(char * filename,
        char __user *__user *argv,
        char __user *__user *envp,
        struct pt_regs * regs)
    

    通常,二进制格式处理程序执行下列操作:

    1. 释放原进程使用的所有资源
    2. 将应用程序映射到虚拟地址空间中。必须考虑下列段的处理(涉及的变量是task_struct的成员,由二进制格式处理程序设置为正确的值)。
      • text段包含程序的可执行代码。start_code和end_code指定该段在地址空间中驻留的区域
      • 预先初始化的数据(在编译时间指定了具体值的变量)位于start_data和end_data之间,映射自可执行文件的对应段。
      • 堆(heap)用于动态内存分配,亦置于虚拟地址空间中。start_brk和brk指定了其边界
      • 栈的位置由start_stack定义。几乎所有的计算机上栈都是自动地向下增长。唯一的例外是当前的PA-Risc。对于栈的反向增长,体系结构相关部分的实现必须告知内核,可通过设置配置符号STACK_GROWSUP完成。
      • 程序的参数和环境也映射到虚拟地址空间中,分别位于arg_start和arg_end之间,以及env_start和env_end之间。
    3. 设置进程的指令指针和其他特定于体系结构的寄存器,以便在调度器选择该进程时开始执行程序的main函数。
      在这里插入图片描述
      在这里插入图片描述
    • do_execve

      • open_exec 打开要执行的文件
      • bprm_mm_init 初始化要执行的文件的栈
      • search_binary_handler 查找支持的执行文件格式
    • bprm_mm_init

      • mm_alloc 生成一个新的mm_struct实例来管理进程地址空间
      • init_new_context 特定于体系结构的函数,用于初始化mm实例
      • __bprm_mm_init 建立初始的栈
      • 填充环境变量,参数列表等
      • prepare_binprm 初始化父进程UID,GID等
  2. 二进制格式表示 linux_binfmt
    在Linux内核中,每种二进制格式都表示为下列数据结构(已经简化过)的一个实例:

    //include/linux/binfmts.h
    //执行文件格式结构,a.out,script,elf,java等
    struct linux_binfmt {
        struct list_head lh;//链表成员,链表头为fs/exec.c中的全局变量 formats
        struct module *module;
        int (*load_binary)(struct linux_binprm *, struct  pt_regs * regs);//用于加载普通程序,通过读存放在可执行文件中的信息为当前进程建立一个新的执行环境
        int (*load_shlib)(struct file *);//用于加载共享库,即动态库.用于动态的把一个共享库捆绑到一个已经在运行的进程, 这是由uselib()系统调用激活的
        int (*core_dump)(long signr, struct pt_regs *regs, struct file *file, unsigned long limit);//用于在程序错误的情况下输出内存转储。该转储随后可使用调试器(例如,gdb)分析,以便解决问题。min_coredump是生成内存转储时,内存转储文件长度的下界(通常,这是一个内存页的长度)。在名为core的文件中, 存放当前进程的执行上下文. 这个文件通常是在进程接收到一个缺省操作为”dump”的信号时被创建的, 其格式取决于被执行程序的可执行类型
        unsigned long min_coredump;	/* minimal dump size */
    };
    

    每种二进制格式首先必须使用register_binfmt向内核注册。该函数的目的是向一个链表增加一种新的二进制格式,该链表的表头是fs/exec.c中的全局变量formatslinux_binfmt实例通过其lh成员彼此连接起来。

2.4.4 退出进程 exit

进程必须用exit系统调用终止。这使得内核有机会将该进程使用的资源释放回系统(程序员可以显式调用exit。但编译器会在main函数(或特定语言使用的main函数)末尾自动添加相应的调用)。该调用的入口点是sys_exit函数,需要一个错误码作为其参数,以便退出进程。其定义是体系结构无关的,见kernel/exit.c。我们对其实现没什么兴趣,因为它很快将工作委托给do_exit

简而言之,该函数的实现就是将各个引用计数器减1,如果引用计数器归0而没有进程再使用对应的结构,那么将相应的内存区域返还给内存管理模块。

2.5 调度器的实现

内存中保存了对每个进程的唯一描述,并通过若干结构与其他进程连接起来。调度器面对的情形就是这样,其任务是在程序之间共享CPU时间,创造并行执行的错觉。正如以上的讨论,该任务分为两个不同部分:一个涉及调度策略,另一个涉及上下文切换。

2.5.1 概观

内核必须提供一种方法,在各个进程之间尽可能公平地共享CPU时间,而同时又要考虑不同的任务优先级。完成该目的有许多方法,各有其利弊,我们无须在此讨论(对可能方法的概述,请参见[Tan07])。我们主要关注Linux内核采用的解决方案。

schedule函数是理解调度操作的起点。该函数定义在kernel/sched.c中,是内核代码中最常调用的函数之一。调度器的实现受若干因素的影响而稍显模糊。

  1. 在多处理器系统上,必须要注意几个细节(有一些非常微妙),以避免调度器自相干扰。
  2. 不仅实现了优先调度,还实现了Posix标准需要的其他两种软实时策略。
  3. 使用goto以生成最优的汇编语言代码。这些语句在C代码中来回地跳转,与结构化程序设计的所有原理背道而驰。但如果小心翼翼地使用它,该特性就可以发挥作用(调度器就是一个例子)。

只考虑完全公平调度器(稍后再考虑实时进程)。Linux调度器的一个杰出特性是,它不需要时间片概念,至少不需要传统的时间片。经典的调度器对系统中的进程分别计算时间片,使进程运行直至时间片用尽。在所有进程的所有时间片都已经用尽时,则需要重新计算。相比之下,当前的调度器只考虑进程的等待时间,即进程在就绪队列(run-queue)中已经等待了多长时间。对CPU时间需求最严格的进程被调度执行。

调度器的一般原理是,按所能分配的计算能力,向系统中的每个进程提供最大的公正性。或者从另一个角度来说,它试图确保没有进程被亏待。这听起来不错,但就CPU时间而论,公平与否意味着什么呢?考虑一台理想计算机,可以并行运行任意数目的进程。如果系统上有N个进程,那么每个进程得到总计算能力的1/N,所有的进程在物理上真实地并行执行。假如一个进程需要10分钟完成其工作。如果5个这样的进程在理想CPU上同时运行,每个会得到计算能力的20%,这意味着每个进程需要运行50分钟,而不是10分钟。但所有的5个进程都会刚好在该时间段之后结束其工作,没有哪个进程在此段时间内处于不活动状态!

在真正的硬件上这显然是无法实现的。如果系统只有一个CPU,至多可以同时运行一个进程。只能通过在各个进程之间高频率来回切换,来实现多任务。对用户来说,由于其思维比转换频率慢得多,切换造成了并行执行的错觉,但实际上不存在并行执行。虽然多CPU系统能改善这种情况并完美地并行执行少量进程,但情况总是CPU数目比要运行的进程数目少,这样上述问题又出现了。

如果通过轮流运行各个进程来模拟多任务,那么当前运行的进程,其待遇显然好于哪些等待调度器选择的进程,即等待的进程受到了不公平的对待。不公平的程度正比于等待时间。

每次调用调度器时,它会挑选具有最高等待时间的进程,把CPU提供给该进程。如果经常发生这种情况,那么进程的不公平待遇不会累积,不公平会均匀分布到系统中的所有进程。

图2-12说明了调度器如何记录哪个进程已经等待了多长时间。由于可运行进程是排队的,该结构称之为就绪队列。
在这里插入图片描述

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

除了红黑树外,就绪队列还装备了虚拟时钟。该时钟的时间流逝速度慢于实际的时钟,精确的速度依赖于当前等待调度器挑选的进程的数目。假定该队列上有4个进程,那么虚拟时钟将以实际时钟四分之一的速度运行。如果以完全公平的方式分享计算能力,那么该时钟是判断等待进程将获得多少CPU时间的基准。在就绪队列等待实际的20秒,相当于虚拟时间5秒。4个进程分别执行5秒,即可使CPU被实际占用20秒。

假定就绪队列的虚拟时间由fair_clock给出,而进程的等待时间保存在wait_runtime。为排序红黑树上的进程,内核使用差值fair_clock - wait_runtime。fair_clock是完全公平调度的情况下进程将会得到的CPU时间的度量,而wait_runtime直接度量了实际系统的不足造成的不公平。

在进程允许运行时,将从wait_runtime减去它已经运行的时间。这样,在按时间排序的树中它会向右移动到某一点,另一个进程将成为最左边,下一次会被调度器选择。但请注意,在进程运行时fair_clock中的虚拟时钟会增加。这实际上意味着,进程在完全公平的系统中接收的CPU时间份额,是推演自在实际的CPU上执行花费的时间。这减缓了削弱不公平状况的过程:减少wait_runtime等价于降低进程受到的不公平对待的数量,但内核无论如何不能忘记,用于降低不公平性的一部分时间,实际上属于处于完全公平世界中的进程。 再次假定就绪队列上有4个进程,而一个进程实际上已经等待了20秒。现在它允许运行10秒:此后的wait_runtime是10,但由于该进程无论如何都会得到该时间段中的10/4 = 2秒,因此实际上只有8秒对该进程在就绪队列中的新位置起了作用。

遗憾的是,该策略受若干现实问题的影响,已经变得复杂了。

  1. 进程的不同优先级(即,nice值)必须考虑,更重要的进程必须比次要进程更多的CPU时间份额。
  2. 进程不能切换得太频繁,因为上下文切换,即从一个进程改变到另一个,是有一定开销的。在切换发生得太频繁时,过多时间花费在进程切换的过程中,而不是用于实际的工作。另一方面,两次相邻的任务切换之间,时间也不能太长,否则会累积比较大的不公平值。对多媒体系统来说,进程运行太长时间也会导致延迟增大。

理解调度决策的一个好方法是,在编译时激活调度器统计。这会在运行时生成文件/proc/sched_debug,其中包含了调度器当前状态所有方面的信息。

最后要注意,Documentation/目录下包含了一些文件,涉及调度器的各个方面。但切记,其中一些仍然讲述的是旧的O(1)调度器,已经过时了!

2.5.2 数据结构

调度器使用一系列数据结构,来排序和管理系统中的进程。调度器的工作方式与这些结构的设计密切相关。几个组件在许多方面彼此交互,图2-13概述了这些组件的关联。
在这里插入图片描述

可以用两种方法激活调度。一种是直接的,比如进程打算睡眠或出于其他原因放弃CPU;另一种是通过周期性机制,以固定的频率运行,不时检测是否有必要进行进程切换。在下文中我将这两个组件称为通用调度器(generic scheduler)或核心调度器(core scheduler)。本质上,通用调度器是一个分配器,与其他两个组件交互。

(1). 调度类用于判断接下来运行哪个进程。内核支持不同的调度策略(完全公平调度、实时调度、在无事可做时调度空闲进程),调度类使得能够以模块化方法实现这些策略,即一个类的代码不需要与其他类的代码交互。
在调度器被调用时,它会查询调度器类,得知接下来运行哪个进程。
(2). 在选中将要运行的进程之后,必须执行底层任务切换。这需要与CPU的紧密交互。
每个进程都刚好属于某一调度类,各个调度类负责管理所属的进程。通用调度器自身完全不涉及进程管理,其工作都委托给调度器类。

  1. task_struct的成员

    //include/linux/sched.h
    struct task_struct { 
        ... 
        /* 
        * 进程优先级越小,优先级越高
        * 优先级使用0-139,其中0-99给实时进程,100-139给普通进程,普通进程nice 0优先级对应120
        * prio:动态优先级,创建子进程时该值会被设为父进程的normal_prio
        * static_prio:静态优先级,是进程启动时分配的优先级,可以用 nice
        *             和 sched_setscheduler 系统调用修改,否则一直不变
        * 			   创建子进程是会继承父进程的static_prio
        * normal_prio:普通优先级,也是动态优先级,基于进程的静态优先级和调度策略计算出的优先级
        *             子进程会复制
        **/
        int prio, static_prio, normal_prio;
        /* 
        * 实时进程的优先级,最低的实时优先级为0,而最高的优先级是99。值越大,表明优先级越高,这里使用的惯例不同于nice值
        * 实时进程的权重是普通进程的两倍,权重结构体struct load_weight
        * */
        unsigned int rt_priority;
        /* 链表成员,表头为struct rq->rt->active->queue[prio],用于维护包含各进程的一个运行链表
        * run_list和time_slice是循环实时调度器所需要的,但不用于完全公平调度器
        */
        struct list_head run_list;
        /*该进程所属的调度器类*/
        /*
        * 												   									  上下文切换
        * 主调度器(schedule函数)     周期性调度器(scheduler_tick函数)(ps:调度器不限于调度进程)      <--------->   cpu
        * 			^选择进程
        * 			1	
        * 			v
        * 调度器类(完全公平调度类fair_sched_class)->调度器类(实时进程调度类rt_sched_class)->调度器类(多个调度器类相连)
        * 			^
        * 			1
        * 			进程(每个进程对应一个调度器类)
        **/
        const struct sched_class *sched_class;
        /* 调度器操作的实例,最后放入就绪队列的红黑树中, se->run_node节点放入rq->cfs_rq->tasks_timeline中 */
        struct sched_entity se;
        /* 保存了对该进程应用的调度策略 
        * SCHED_NORMAL用于普通进程,通过完全公平调度器来处理
        * SCHED_BATCH 和 SCHED_IDLE 也通过完全公平调度器来处理,不过可用于次要的进程
        * SCHED_BATCH 用于非交互、CPU使用密集的批处理进程。调度决策对此类进程给予“冷处
            理”:它们决不会抢占CF调度器处理的另一个进程,因此不会干扰交互式进程。如果不
            打算用nice降低进程的静态优先级,同时又不希望该进程影响系统的交互性,此时最适
            合使用该调度类。
        * SCHED_IDLE进程权重最低
        * SCHED_RR和SCHED_FIFO用于实现软实时进程。SCHED_RR实现了一种循环方法,而SCHED_FIFO
        * 则使用先进先出机制。这些不是由完全公平调度器类处理,而是由实时调度器类处理
        * 辅助函数rt_policy用于判断给出的调度策略是否属于实时类(SCHED_RR和SCHED_FIFO)。
        * task_has_rt_policy用于对给定进程判断该性质
        * */
        unsigned int policy;
        /* 是一个位域,在多处理器系统上使用,用来限制进程可以在哪些CPU上运行,
        * 每一位对应一个处理器,默认都被设置为0,表示可以在任何cpu上运行
        * sched_setaffinity 系统调用,可修改进程与CPU的现有分配关系
        * */
        cpumask_t cpus_allowed;
        /* 指定进程可使用CPU的剩余时间段,run_list和time_slice是循环实时调度器所需要的,但不用于完全公平调度器 */
        unsigned int time_slice;
        ... 
    }
    
  2. 调度器类 sched_class
    调度器类提供了通用调度器和各个调度方法之间的关联。调度器类由特定数据结构中汇集的几个函数指针表示。全局调度器请求的各个操作都可以由一个指针表示。这使得无需了解不同调度器类的内部工作原理,即可创建通用调度器。

    除去针对多处理器系统的扩展(在后文再考虑这些),该结构如下所示:

    //include/linux/sched.h
    /* 调度器函数实现,实时进程最重要,其次是完全公平进程,其次是空闲进程 */
    struct sched_class {
        /* 将不同调度类的 sched_class 实例,按上述顺序连接起来 */
        const struct sched_class *next;
        /* 向就绪队列添加一个新进程.在进程从睡眠状态变为可运行状态时,即发生该操作,被 activate_task 调用 */
        void (*enqueue_task) (struct rq *rq, struct task_struct *p, int wakeup);
        /* 将一个进程从就绪队列去除。在进程从可运行状态切换到不可运行状态时,就会发生该操作,被 deactivate_task 调用
        * 内核有可能因为其他理由将进程从就绪队列去除,比如,进程的优先级可能需要改变
        **/
        void (*dequeue_task) (struct rq *rq, struct task_struct *p, int sleep);
        /* 在进程想要自愿放弃对处理器的控制权时调用,可使用sched_yield系统调用。这导致内核调用
        yield_task */
        void (*yield_task) (struct rq *rq);
        /* 用一个新唤醒的进程来抢占当前进程(在 try_to_wake_up 和 wake_up_new_task 中唤醒进程时)
        * 例如,在用 wake_up_new_task 唤醒新进程时,会调用该函数
        * 有进程从睡眠被唤醒判断是否切换进程
        * 完全公平调度器使用的进程切换函数
        **/
        void (*check_preempt_curr) (struct rq *rq, struct task_struct *p);
    
        /* 选择下一个将要运行的进程 */
        struct task_struct * (*pick_next_task) (struct rq *rq);
        /* 在用另一个进程代替当前运行的进程之前调用,通知调度器类当前运行的进程将要被另一个进程代替,实际执行一些簿记工作并更新统计量 */
        void (*put_prev_task) (struct rq *rq, struct task_struct *p);
    
        /* 在进程的调度策略发生变化时,需要调用 */
        void (*set_curr_task) (struct rq *rq);
        /* 在每次激活周期性调度器时,由周期性调度器调用 
        * 周期调度,即分配给进程的时间到了切换进程
        * 周期调度使用的进程切换函数
        */
        void (*task_tick) (struct rq *rq, struct task_struct *p);
        /* 用于建立 fork 系统调用和调度器之间的关联.每次新进程建立后,则用该函数通知调度器 */
        void (*task_new) (struct rq *rq, struct task_struct *p);
    };
    
    • enqueue_task 进程注册到调度器的队列中

      • p->sched_class->enqueue_task
      • p->se.on_rq = 1
    • dequeue_task 进程从调度器的队列中移除

      • p->sched_class->dequeue_task(rq, p, sleep);
      • p->se.on_rq = 0;

    用户层应用程序无法直接与调度类交互。它们只知道上文定义的常量SCHED_xyz。在这些常量和可用的调度类之间提供适当的映射,这是内核的工作。SCHED_NORMAL、SCHED_BATCH和SCHED_IDLE映射到fair_sched_class,而SCHED_RR和SCHED_FIFO与rt_sched_class关联。fair_sched_class和rt_sched_class都是struct sched_class的实例,分别表示完全公平调度器和实时调度器。

  3. 就绪队列 rq

    核心调度器用于管理活动进程的主要数据结构称之为就绪队列。各个CPU都有自身的就绪队列,各个活动进程只出现在一个就绪队列中。在多个CPU上同时运行一个进程是不可能的(但发源于同一进程的各线程可以在不同处理器上执行,因为进程管理对进程和线程不作重要的区分。)。

    就绪队列是全局调度器许多操作的起点。但要注意,进程并不是由就绪队列的成员直接管理的!这是各个调度器类的职责,因此在各个就绪队列中嵌入了特定于调度器类的子就绪队列

    就绪队列是使用下列数据结构实现的。为简明起见,省去了几个用于统计、不直接影响就绪队列工作的成员,以及在多处理器系统上所需要的成员。

    //kernel/sched.c 
    //就绪队列
    struct rq { 
        /* 记录队列上可运行进程的数目,不考虑其优先级或调度类 */
        unsigned long nr_running;
        #define CPU_LOAD_IDX_MAX 5
        /* 用于跟踪此前的负荷状态 */
        unsigned long cpu_load[CPU_LOAD_IDX_MAX];
        ... 
        /* 当前运行队列的负荷,与队列上的就绪进程数目成正比,进程优先级作为权重 */
        struct load_weight load;
        /* 子就绪队列,用于完全公平调度器,这是一个红黑树 
        * CFS(complete fair scheduler)完全公平调度器
        * */
        struct cfs_rq cfs;
        /* 子就绪队列,用于实时调度器 */
        struct rt_rq rt;
        /******************
         * curr:指向当前运行的进程的 task_struct 实例
        * idle:指向idle进程的 task_struct 实例,在无其他可运行进程时执行
        ****************/
        struct task_struct *curr, *idle;
        /*****************
         * clock 和 prev_raw_clock 用于实现就绪队列自身的时钟
        * 每次调用周期性调度器时,都会更新 clock 的值
        * 另外内核还提供了标准函数 update_rq_clock ,可在操作就绪队列的调度器中
        * 多处调用,例如,在用 wakeup_new_task 唤醒新进程时
        ****************/
        u64 clock, prev_clock_raw;
        ... 
    };
    

    系统的所有就绪队列都在runqueues数组中,该数组的每个元素分别对应于系统中的一个CPU。在单处理器系统中,由于只需要一个就绪队列,数组只有一个元素。

    //kernel/sched.c 
    /* 系统的所有就绪队列都在 runqueues 数组中,
    * 该数组的每个元素分别对应于系统中的一个CPU。
    * 在单处理器系统中,由于只需要一个就绪队列,数组只有一个元素 
    **/
    static DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);
    
    #define cpu_rq(cpu)		(&per_cpu(runqueues, (cpu)))
    #define this_rq()		(&__get_cpu_var(runqueues))
    #define task_rq(p)		cpu_rq(task_cpu(p))
    #define cpu_curr(cpu)		(cpu_rq(cpu)->curr)
    
  4. 调度实体
    由于调度器可以操作比进程更一般的实体,因此需要一个适当的数据结构来描述此类实体。其定义如下:

    //include/linux/sched.h
    /**
    * 调度器操作的结构体,对进程运行的时间记账等
    * 进程中有这个结构体实例,所以进程能被调度器调度
    * 如果其他结构体想使用调度器也要加入这个结构体实例
    */
    struct sched_entity {
        /* 用于负载均衡,指定了权重,决定了各个实体占队列总负荷的比例。计算负荷权重是调度器的一项重任,因为CFS所需的虚拟时钟的速度最终依赖于负荷 */
        struct load_weight	load;		/* for load-balancing */
        /* 标准的树结点,使得实体可以在红黑树上排序, 在完全公平调度器中的根节点为 struct rq->cfs->rb_root */
        struct rb_node		run_node;
        /* 表示该实体当前是否在就绪队列上,注册时置1 */
        unsigned int		on_rq;
    
        /* 新进程加入就绪队列时,或者周期性调度器中
            * 会调用update_curr函数,
            * 计算当前时间和exec_start之间的差值加到sum_exec_runtime中
            * 然后把当前时间更新到exec_start中
            **/
        /* 记录当前时间 */
        u64			exec_start;
        /* 该成员用于在进程运行时,记录消耗的CPU物理时间,以用于完全公平调度器.计算当前时间和exec_start之间的差值,exec_start则更新到当前时间。差值则被加到sum_exec_runtime。 */
        u64			sum_exec_runtime;
        /* 统计在进程执行期间虚拟时钟上流逝的时间数量,以ns为单位,与定时器节拍不相关,
            * CFS使用该变量来记录一个程序到底运行了多长时间以及还应该再运行多久,
            * update_curr()函数实现了该记账功能
            * 决定在红黑树上的位置,越小越靠左
            * 由进程的实际运行时间和进程的权重(weight)计算出来
            * 在CFS调度器中的权重在内核是对用户态进程的优先级nice值,
            * 通过prio_to_weight数组进行nice值和权重的转换而计算出来的
            * sum_runtime *se.weight/cfs_rq.weight
            * 队列中所有进程运行完一遍的时间*进程的权重/队列的总权重
            * 进程数 > sched_nr_latency(默认5)时,sum_runtime=sysctl_sched_min_granularity *nr_running
            * 进程数 <=sched_nr_latency(默认5)时,sum_runtime=sysctl_sched_latency = 20ms
            * 进程数大于内核规定的最大进程数(默认5)时,队列中所有进程运行完一遍的时间=4ms*队列中的进程数
            * 进程数小于等于内核规定的最大进程数(默认5)时,队列中所有进程运行完一遍的时间=20ms
            * curr.nice!=NICE_0_LOAD(120)时,vruntime += delta* NICE_0_LOAD/se.weight;
            * curr.nice=NICE_0_LOAD(120)时,vruntime += delta;
            * 当前进程优先级不是120时,要累计的虚拟时间=delta*120/进程当前的权重
            * 当前进程优先级为120时,要累计的虚拟时间=delta
            * */
        u64			vruntime;
        /* 在进程被撤销CPU时,其当前 sum_exec_runtime 值保存到 prev_sum_exec_runtime.在进程抢占时又需要该数据。但请注意,在prev_sum_exec_runtime中保存sum_exec_runtime的值,并不意味着重置sum_exec_runtime!原值保存下来,而sum_exec_runtime则持续单调增长 */
        u64			prev_sum_exec_runtime;
        ...
    };
    

    如果编译内核时启用了调度器统计,那么该结构会包含很多用于统计的成员。如果启用了组调度,那么还会增加一些成员。

    由于每个task_struct都嵌入了sched_entity的一个实例,所以进程是可调度实体。但请注意,其逆命题一般是不正确的,因为可调度的实体不见得一定是进程。但在下文中我们只关注进程调度,因此我们暂时将调度实体和进程视为等同。不过要记住,这在一般意义上是不正确的!

2.5.3 处理优先级

  1. 优先级的内核表示
    在用户空间可以通过nice命令设置进程的静态优先级,这在内部会调用nice系统调用(setpriority是另一个用于设置进程优先级的系统调用。它不仅能够修改单个线程的优先级,还能修改线程组中所有线程的优先级,或者通过指定UID来修改特定用户的所有进程的优先级。)。进程的nice值在20和+19之间(包含)。值越低,表明优先级越高。为什么选择这个诡异的范围,真相已经淹没在历史中。

    内核使用一个简单些的数值范围,从0到139(包含),用来表示内部优先级。同样是值越低,优先级越高。从0到99的范围专供实时进程使用。nice值[20, +19]映射到范围100到139,如图2-14所示。实时进程的优先级总是比普通进程更高。
    在这里插入图片描述

    下列宏用于在各种不同表示形式之间转换(MAX_RT_PRIO指定实时进程的最大优先级,而MAX_PRIO则是普通进程的最大优先级数值):

    //include/linux/sched.h
    /* 进程优先级越小优先级越高 */
    /* 实时进程的优先级使用0-99 */
    #define MAX_USER_RT_PRIO	100
    #define MAX_RT_PRIO		MAX_USER_RT_PRIO
    
    /* 普通进程优先级使用100-139,即nice值从-20到+19对应100到139 */
    #define MAX_PRIO		(MAX_RT_PRIO + 40)
    /* nice值为0时的优先级 */
    #define DEFAULT_PRIO		(MAX_RT_PRIO + 20)
    
    //kernel/sched.c
    #define NICE_TO_PRIO(nice)	(MAX_RT_PRIO + (nice) + 20)
    #define PRIO_TO_NICE(prio)	((prio) - MAX_RT_PRIO - 20)
    #define TASK_NICE(p)		PRIO_TO_NICE((p)->static_prio)
    
  2. 计算优先级
    回想一下,可知只考虑进程的静态优先级是不够的,还必须考虑下面3个优先级。即动态优先级(task_struct->prio)、普通优先级(task_struct->normal_prio)和静态优先级(task_struct->static_prio)。

    辅助函数effective_prio执行了下列操作,计算了普通优先级,并保存在normal_priority。这个副效应使得能够用一个函数调用设置两个优先级(prio和normal_prio)。另一个辅助函数rt_prio,会检测普通优先级是否在实时范围中,即是否小于RT_RT_PRIO。请注意,该检测与调度类无关,它只涉及优先级的数值。

    现在假定我们在处理普通进程,不涉及实时调度。在这种情况下,normal_prio只是返回静态优先级。结果很简单:所有3个优先级都是同一个值,即静态优先级!

    实时进程的情况有所不同。

    普通优先级需要根据普通进程和实时进程进行不同的计算。__normal_prio的计算只适用于普通进程。而实时进程的普通优先级计算,则需要根据其rt_priority设置。由于更高的rt_priority值表示更高的实时优先级,内核内部优先级的表示刚好相反,越低的值表示的优先级越高。因此,实时进程在内核内部的优先级数值,正确的算法是MAX_RT_PRIO - 1 - p->rt_priority。这一次请注意,与effective_prio相比,实时进程的检测不再基于优先级数值,而是通过task_struct中设置的调度策略来检测。

    __normal_priority函数只是返回静态优先级

    为什么对此增加一个额外的函数。这是有历史原因的:在原来的O(1)调度器中,普通优先级的计算涉及相当多技巧性的工作。必须检测交互式进程并提高其优先级,而必须“惩罚”非交互进程,以便使系统获得良好的交互体验。这需要大量的启发式计算,它们可能完成得很好,也可能不工作。

    但还有一个问题:为什么内核在effective_prio中检测实时进程是基于优先级数值,而非task_has_rt_policy?对于临时提高至实时优先级的非实时进程来说,这是必要的,这种情况可能发生在使用实时互斥量(RT-Mutex)时(实时互斥量能够保护内核的一些部分,防止多处理器并发访问。但有一种现象会发生,称作优先级反转(priority。其中一个低优先级进程在执行,而较高优先级的进程则在等待CPU。这可以通过临时提高进程的优先inversion)级解决。有关该问题的更多细节,请参考5.2.8节的讨论。)。

    表2-3综述了针对不同类型进程上述计算的结果
    在这里插入图片描述

    在新建进程用wake_up_new_task唤醒时,或使用nice系统调用改变静态优先级时,则用上文给出的方法设置p->prio。

    在进程分支出子进程时,子进程的静态优先级继承自父进程。子进程的动态优先级,即task_struct->prio,则设置为父进程的普通优先级。这确保了实时互斥量引起的优先级提高不会传递到子进程

  3. 计算负荷权重
    进程的重要性不仅是由优先级指定的,而且还需要考虑保存在task_struct->se.load的负荷权重。set_load_weight负责根据进程类型及其静态优先级计算负荷权重

    负荷权重包含在数据结构load_weight中:

    //include/linux/sched.h
    /* 进程的重要性不仅由优先级决定,还与负荷权重有关
    * task_struct->se.load
    * set_load_weight函数负责根据进程类型及其静态优先级计算负荷权重
    * 进程每降低一个 nice 值,则多获得10%的CPU时间
    * 计算被负荷权重除的结果保存在数组prio_to_weight[40]中
    *  */
    struct load_weight {
        unsigned long weight, inv_weight;
    };
    

    内核不仅维护了负荷权重自身,而且还有另一个数值,用于计算被负荷权重除的结果

    一般概念是这样,进程每降低一个nice值,则多获得10%的CPU时间,每升高一个nice值,则放弃10%的CPU时间。为执行该策略,内核将优先级转换为权重值。我们首先看一下转换表:

    //kernel/sched.c
    /* nice 级别为0的进程,其权重查表可知为1024
    * 如果有两个进程AB,每个进程的cpu份额为1024/(1024+1024)=0.5
    * B优先级+1,cpu份额减少10%,A为55%,B为45%,1024/1.25≈820
    * A的份额:1024/(1024+820)≈0.55
    * B的份额:820/(1024+820)≈0.45
    * 实时进程的权重是普通进程的两倍
    **/
    static const int prio_to_weight[40] = {
    /* -20 */     88761,     71755,     56483,     46273,     36291,
    /* -15 */     29154,     23254,     18705,     14949,     11916,
    /* -10 */      9548,      7620,      6100,      4904,      3906,
    /*  -5 */      3121,      2501,      1991,      1586,      1277,
    /*   0 */      1024,       820,       655,       526,       423,
    /*   5 */       335,       272,       215,       172,       137,
    /*  10 */       110,        87,        70,        56,        45,
    /*  15 */        36,        29,        23,        18,        15,
    };
    

    对内核使用的范围[0, 39]中的每个nice级别,该数组中都有一个对应项。各数组之间的乘数因子是1.25。要知道为何使用该因子,可考虑下列例子。两个进程A和B在nice级别0运行,因此两个进程的CPU份额相同,即都是50%。nice级别为0的进程,其权重查表可知为1024。每个进程的份额是1024/(1024+1024)=0.5,即50%

    如果进程B的优先级加1,那么其CPU份额应该减少10%。换句话说,这意味着进程A得到总的CPU时间的55%,而进程B得到45%。优先级增加1导致权重减少,即1024/1.25≈820。因此进程A现在将得到的CPU份额是1024/(1024+820)≈0.55,而进程B的份额则是820/(1024+820)≈0.45,这样就产生了10%的差值。

    执行转换的代码也需要考虑实时进程。实时进程的权重是普通进程的两倍。另一方面,SCHED_IDLE进程的权重总是非常小,看 set_load_weight 函数

    内核不仅计算出权重本身,还存储了用于除法的值。请注意,每个优先级变化关联10%的CPU时间的特征,导致了权重(和相关的CPU时间)的指数特征,见图2-15。图中上方的插图给出了对应于普通优先级的某个受限区域内的曲线图。下方的插图在Y轴上则采用了对数标度。要注意,该函数在普通到实时进程间的临界点上是不连续的。
    在这里插入图片描述

    回想一下可知,不仅进程,而且就绪队列也关联到一个负荷权重。每次进程被加到就绪队列时,内核会调用inc_nr_running。这不仅确保就绪队列能够跟踪记录有多少进程在运行,而且还将进程的权重添加到就绪队列的权重中,调用 update_load_add,inc_load,inc_nr_running

    在进程从就绪队列移除时,会调用对应的函数( dec_nr_running 、 dec_load 、 update_load_sub)。

2.5.4 核心调度器

如前所述,调度器的实现基于两个函数:周期性调度器函数和主调度器函数。这些函数根据现有进程的优先级分配CPU时间。这也是为什么整个方法称之为优先调度的原因

  1. 周期性调度器 scheduler_tick
    周期性调度器在scheduler_tick中实现。如果系统正在活动中,内核会按照频率HZ自动调用该函数。如果没有进程在等待调度,那么在计算机电力供应不足的情况下,也可以关闭该调度器以减少电能消耗。例如,笔记本电脑或小型嵌入式系统。周期性操作的底层机制将在第15章讨论。该函数有下面两个主要任务。

    1. 管理内核中与整个系统和各个进程的调度相关的统计量。其间执行的主要操作是对各种计数器加1
    2. 激活负责当前进程的调度类的周期性调度方法。
    • tick_nohz_handler 低分辨率动态时钟处理函数

      • update_process_times
        • scheduler_tick
          • __update_rq_clock 就绪队列时钟的更新,本质上就是增加struct rq当前实例的时钟时间戳
          • update_cpu_load 更新就绪队列的 cpu_load[] 数组
          • curr->sched_class->task_tick 主要工作由调度类实现的函数执行(以task_tick_fair为例)
            • entity_tick
              • update_curr 更新cfs队列和正在运行的进程的时间
              • 判断进程能抢占并调用 check_preempt_tick 进行决策
                • sched_slice 计算进程在延迟周期中确定的份额(能运行的时间)
                • resched_task 发出重调度请求,即在 task_struct 中设置 TIF_NEED_RESCHED 标志.核心调度器会在下一个合适的时间发起重调度
    • tick_sched_timer 高分辨率模式下的周期时钟仿真,也用于实现动态时钟

      • update_process_times
        • scheduler_tick

    如果当前进程应该被重新调度,那么调度器类方法会在task_struct中设置TIF_NEED_RESCHED标志,以表示该请求,而内核会在接下来的适当时机完成该请求。

  2. 主调度器 schedule
    在内核中的许多地方,如果要将CPU分配给与当前活动进程不同的另一个进程,都会直接调用主调度器函数(schedule)。在从系统调用返回之后,内核也会检查当前进程是否设置了重调度标志TIF_NEED_RESCHED,例如,前述的scheduler_tick就会设置该标志。如果是这样,则内核会调用schedule。该函数假定当前活动进程一定会被另一个进程取代。

    __sched前缀用于可能调用schedule的函数,包括schedule自身。其声明如下所示:

    void __sched some_function(...) { 
        ... 
        schedule(); 
        ... 
    }
    

    该前缀目的在于,将相关函数的代码编译之后,放到目标文件的一个特定的段中,即.sched.text中(有关ELF段的更多信息,请参见附录C)。该信息使得内核在显示栈转储或类似信息时,忽略所有与调度有关的调用。由于调度器函数调用不是普通代码流程的一部分,因此在这种情况下是没有意义的。

    • schedule 主调度器
      • 保存当前CPU所在就绪队列正在运行的进程
      • __update_rq_clock 就绪队列时钟的更新
      • clear_tsk_need_resched 清除当前运行进程 task_struct 中的重调度标志
      • 就绪队列当前进程在休眠可中断状态收到信号,提升为运行进程.否则用调度类函数(deactivate_task)停止当前进程
      • prev->sched_class->put_prev_task 通知调度类运行进程将要被替代
      • pick_next_task 选择下一个进程,可能还是当前进程
      • context_switch 进程上下文切换
      • 检测当前进程是否要重调度,如果重调度则回到上面重新找一个进程

    请注意,上述代码片段可能在两个不同的上下文中执行。在没有执行上下文切换时,它在schedule函数的末尾直接执行。但如果已经执行了上下文切换,当前进程会正好在这以前停止运行,新进程已经接管了CPU。但稍后在前一进程被再次选择运行时,它会刚好在这一点上恢复执行。在这种情况下,由于prev不会指向正确的进程,所以需要通过current和test_thread_flag找到当前线程。

  3. 与fork的交互
    每当使用fork系统调用或其变体之一建立新进程时,调度器有机会用sched_fork函数挂钩到该进程。在单处理器系统上,该函数实质上执行3个操作:初始化新进程与调度相关的字段、建立数据结构(相当简单直接)、确定进程的动态优先级。

    • copy_process
      • sched_fork 初始化调度器相关变量

    通过使用父进程的普通优先级作为子进程的动态优先级,内核确保父进程优先级的临时提高不会被子进程继承。回想一下,可知在使用实时互斥量时进程的动态优先级可以临时修改。该效应不能转移到子进程。如果优先级不在实时范围中,则进程总是从完全公平调度类开始执行。

    在使用wake_up_new_task唤醒新进程时,则是调度器与进程创建逻辑交互的第二个时机:内核会调用调度类的task_new函数。这提供了一个时机,将新进程加入到相应类的就绪队列中

  4. 上下文切换 context_switch
    内核选择新进程之后,必须处理与多任务相关的技术细节。这些细节总称为上下文切换(context switching)。辅助函数context_switch是个分配器,它会调用所需的特定于体系结构的方法。

    • context_switch 上下文切换
      • prepare_task_switch 会调用体系结构实现的 prepare_arch_switch 函数,进行切换前准备工作
      • 如果是内核进程 调用 enter_lazy_tlb 通知底层体系结构不需要切换虚拟地址空间的用户空间部分
      • 否则如果是用户进程则调用 switch_mm 进程的内存管理上下文切换
      • switch_to 切换进程的寄存器和内核栈
      • 上面执行完后已经开始新进程了,之后的代码要在当前进程下次被选择运行时执行
      • finish_task_switch 清理工作,使得能够正确地释放锁

    由于用户空间进程的寄存器内容在进入核心态时保存在内核栈上(更多细节请参见第14章),在上下文切换期间无需显式操作。而因为每个进程首先都是从核心态开始执行(在调度期间控制权传递到新进程),在返回用户空间时,会使用内核栈上保存的值自动恢复寄存器数据。

    但要记住,内核线程没有自身的用户空间内存上下文,可能在某个随机进程地址空间的上部执行。其task_struct->mm为NULL。从当前进程“借来”的地址空间记录在active_mm中

    • switch_to的复杂之处(多进程切换的情况)
      finish_task_switch的有趣之处在于,调度过程可能选择了一个新进程,而清理则是针对此前的活动进程。请注意,这不是发起上下文切换的那个进程,而是系统中随机的某个其他进程!内核必须想办法使得该进程能够与context_switch例程通信,这可以通过switch_to宏实现。每个体系结构都必须实现它,而且有一个异乎寻常的调用约定,即通过3个参数传递两个变量!这是因为上下文切换不仅涉及两个进程,而是3个进程。该情形如图2-16所示。
      在这里插入图片描述

      假定3个进程A、B和C在系统上运行。在某个时间点,内核决定从进程A切换到进程B,然后从进程B到进程C,再接下来从进程C切换回进程A。在每个switch_to调用之前,next和prev指针位于各进程的栈上,prev指向当前运行的进程,而next指向将要运行的下一个进程。为执行从prev到next的切换,switch_to的前两个参数足够了。对进程A来说,prev指向进程A而next指向进程B。

      在进程A被选中再次执行时,会出现一个问题。控制权返回至switch_to之后的点,如果栈准确地恢复到切换之前的状态,那么prev和next仍然指向切换之前的值,即next = B而prev = A。在这种情况下,内核无法知道实际上在进程A之前运行的是进程C。

      因此,在新进程被选中时,底层的进程切换例程必须将此前执行的进程提供给context_switch。由于控制流会回到该函数的中间,这无法用普通的函数返回值来做到,因此使用了一个3个参数的宏。但逻辑上的效果是相同的,仿佛switch_to是带有两个参数的函数,而且返回了一个指向此前运行进程的指针。switch_to宏实际上执行的代码如下:

      prev = switch_to(prev,next)

      其中返回的prev值并不是用作参数的prev值,而是上一个执行的进程。在上述例子中,进程A提供给switch_to的参数是A和B,但恢复执行后得到的返回值是prev = C。内核实现该行为特性的方式依赖于底层的体系结构,但内核显然可以通过考虑两个进程的核心态栈来重建所要的信息。对可以访问所有内存的内核而言,这两个栈显然是同时可用的。

    • 惰性FPU模式(浮点寄存器在进程切换中的恢复)
      由于上下文切换的速度对系统性能的影响举足轻重,所以内核使用了一种技巧来减少所需的CPU时间。浮点寄存器(及其他内核未使用的扩充寄存器,例如IA-32平台上的SSE2寄存器)除非有应用程序实际使用,否则不会保存。此外,除非有应用程序需要,否则这些寄存器也不会恢复这称之为惰性FPU技术。由于使用了汇编语言代码,因此其实现依平台而有所不同,但基本原理总是同样的。也应注意到,如果不考虑平台,浮点寄存器的内容不是保存在进程栈上,而是保存在线程数据结构中。我将通过一个例子来说明该技术。

      为简明起见,我们假定这一次系统中只有进程A和进程B。进程A在运行并使用浮点操作。在调度器切换到进程B时,进程A的浮点寄存器的内容保存到进程的线程数据结构中。但这些寄存器中的值不会立即被来自进程B的值替换。

      如果进程B在其时间片内并不执行任何浮点操作,那么在进程A下一次激活时,会看到CPU浮点寄存器内容与此前相同。内核因此节省了显式恢复寄存器值的工作量,这节省了时间。

      但如果进程B确实执行了浮点操作,该事实会报告给内核,它会用来自线程数据结构的适当值填充寄存器。因此,只有在需要的情况下,内核才会保存和恢复浮点寄存器内容,不会因为多余的操作浪费时间。

2.6 完全公平调度类 fair_sched_class

核心调度器必须知道的有关完全公平调度器的所有信息,都包含在fair_sched_class中:

//kernel/sched_fair.c
/* 完全公平调度类CFS */
static const struct sched_class fair_sched_class = {
	.next			= &idle_sched_class,
	/* 向就绪队列添加一个新进程.在进程从睡眠状态变为可运行状态时,即发生该操作 */
	.enqueue_task		= enqueue_task_fair,
	/* 将一个进程从就绪队列去除。在进程从可运行状态切换到不可运行状态时,就会发生该操作
	 * 内核有可能因为其他理由将进程从就绪队列去除,比如,进程的优先级可能需要改变
	 **/
	.dequeue_task		= dequeue_task_fair,
	/* 在进程想要自愿放弃对处理器的控制权时调用 */
	.yield_task		= yield_task_fair,

	/* 当在try_to_wake_up和wake_up_new_task中唤醒进程时,内核使用check_preempt_curr看看是否新进程可以抢占当前运行的进程
	 **/
	.check_preempt_curr	= check_preempt_wakeup,

	/* 选择下一个将要运行的进程 */
	.pick_next_task		= pick_next_task_fair,
	/* 在用另一个进程代替当前运行的进程之前调用 */
	.put_prev_task		= put_prev_task_fair,


	/* 在进程的调度策略发生变化时,需要调用 */
	.set_curr_task          = set_curr_task_fair,
	/* 在每次激活周期性调度器时,由周期性调度器(void scheduler_tick(void))调用 
	 * 周期调度,即分配给进程的时间到了切换进程
	 * 周期调度使用的进程切换函数
	 * */
	.task_tick		= task_tick_fair,
	/* 用于建立 fork 系统调用和调度器之间的关联.每次新进程建立后,则用该函数通知调度器 */
	.task_new		= task_new_fair,
};

2.6.1 数据结构

首先,需要介绍一下CFS的就绪队列。回想一下,可知主调度器的每个就绪队列中都嵌入了一个该结构的实例:

//kernel/sched.c
/* 就绪队列,用于完全公平调度器 */
struct cfs_rq {
    /* 队列上所有进程的累积负荷值(总负载) */
	struct load_weight load;
	/* 队列上可运行进程的数目 */
	unsigned long nr_running;

    /* 记录队列上所有进程的最小虚拟运行时间 
	 * 这个值是实现与就绪队列相关的虚拟时钟的基础.min_vruntime实际上可能比最左边的树结点的vruntime大些。因为它是单调递增的
	 * */
	u64 min_vruntime;

    /* 红黑树根节点,用于在按时间排序的红黑树中管理所有进程,节点为 struct task_struct->se->run_node */
	struct rb_root tasks_timeline;
	/* 总是设置为指向红黑树最左边的结点,即最需要被调度的进程,vruntime最小的进程 */
	struct rb_node *rb_leftmost;

    /* 指向当前执行进程的可调度实体 struct task_struct->se */
	struct sched_entity *curr;
}

2.6.2 CFS提供的调度函数

  1. 虚拟时钟计算 update_curr
    在2.5.1节提到,完全公平调度算法依赖于虚拟时钟,用以度量等待进程在完全公平系统中所能得到的CPU时间。但数据结构中任何地方都没找到虚拟时钟!这是由于所有的必要信息都可以根据现存的实际时钟和与每个进程相关的负荷权重推算出来。所有与虚拟时钟有关的计算都在update_curr中执行,该函数在系统中各个不同地方调用,包括周期性调度器之内。图2-17的代码流程图提供了该函数所完成工作的概述。
    在这里插入图片描述

    • update_curr 虚拟时钟计算
      • __update_curr
        • 更新当前进程在CPU上执行花费的物理时间和虚拟时间
        • 更新CFS队列min_vruntime
      • curr->exec_start = now 记录当前时间

    有趣的事情是如何使用给出的信息来模拟不存在的虚拟时钟。这一次内核的实现仍然是非常巧妙的,针对最普遍的情形节省了一些时间。对于运行在nice级别0的进程来说,根据定义虚拟时间和物理时间是相等的。在使用不同的优先级时,必须根据进程的负荷权重重新衡定时间(回想2.5.3节讨论的进程优先级与负荷权重之间的关联)

    忽略舍入和溢出检查,calc_delta_fair所作的就是根据下列公式计算:

    delta_exec_weighted = delta_exec x (NICE_0_LOAD / curr->load.weight)

    前文提到的逆向权重值,在该计算中可以派上用场了。回想一下,可知越重要的进程会有越高的优先级(即,越低的nice值),会得到更大的权重,因此累加的虚拟运行时间会小一些。图2-18给出了不同优先级的实际时间和虚拟时间之间的关系。根据公式可知,nice 0进程优先级为120,则虚拟时间和物理时间是相等的,即current->load.weight等于NICE_0_LOAD的情况。请注意图2-18的插图,其中使用了双对数坐标来对各种优先级绘图。
    在这里插入图片描述

    最后,内核需要设置min_vruntime。必须小心保证该值是单调递增的。

    完全公平调度器的真正关键点是,红黑树的排序过程是根据下列键进行的 se->vruntime -cfs_rq->min_vruntime;

    键值较小的结点,排序位置就更靠左,因此会被更快地调度。用这种方法,内核实现了下面两种对立的机制。

    1. 在进程运行时,其vruntime稳定地增加,它在红黑树中总是向右移动的。
      因为越重要的进程vruntime增加越慢,因此它们向右移动的速度也越慢,这样其被调度的机会要大于次要进程,这刚好是我们需要的。
    2. 如果进程进入睡眠,则其vruntime保持不变。因为每个队列min_vruntime同时会增加(回想一下,它是单调的!),那么睡眠进程醒来后,在红黑树中的位置会更靠左,因为其键值变得更小了

    实际上上述两种效应是同时发生作用的,但这并不影响解释。图2-19针对红黑树上不同的移动机制,作出了图解。
    在这里插入图片描述

  2. 延迟跟踪
    内核有一个固有的概念,称之为良好的调度延迟,即保证每个可运行的进程都应该至少运行一次的某个时间间隔(这与时间片无关,旧的调度器才使用时间片)。它在sysctl_sched_latency给出,可通过/proc/sys/kernel/sched_latency_ns控制,默认值为20 000 000纳秒或20毫秒。第二个控制参数sched_nr_latency,控制在一个延迟周期中处理的最大活动进程数目。如果活动进程的数目超出该上限,则延迟周期也成比例地线性扩展。sched_nr_latency可以通过sysctl_sched_min_granularity间接地控制,后者可通过/proc/sys/kernel/sched_min_granularity_ns设置。默认值是4 000 000纳秒,即4毫秒,每次sysctl_sched_latency/sysctl_sched_min_granularity之一改变时,都会重新计算sched_nr_latency

    __sched_period确定延迟周期的长度,通常就是sysctl_sched_latency,但如果有更多进程在运行,其值有可能按比例线性扩展。在这种情况下,周期长度是:

    sysctl_sched_latency x nr_running / sched_nr_latency

    通过考虑各个进程的相对权重,将一个延迟周期的时间在活动进程之间进行分配。对于由某个可调度实体表示的给定进程,分配到的时间通过 sched_slice 函数计算

    回想一下,就绪队列的负荷权重是队列上所有活动进程负荷权重的累加和。结果时间段是按实际时间给出的,但内核有时候也需要知道等价的虚拟时间。通过函数 sched_vslice 计算

    回想一下,对权重weight的进程来说,实际时间段time对应的虚拟时间长度为:

    time x NICE_0_LOAD / weight

    该公式也用于转换分配到的延迟时间间隔。

2.6.3 队列操作

enqueue_task_fair和dequeue_task_fair用来增删就绪队列的成员。首先关注如何向就绪队列放置新进程。

除了指向所述的就绪队列和task_struct的指针外,该函数还有另一个参数wakeup。这使得可以指定入队的进程是否最近才被唤醒并转换为运行状态(在这种情况下wakeup为1),还是此前就是可运行的(那么wakeup是0)。enqueue_task_fair的代码流程图如图2-20所示。
在这里插入图片描述

  • enqueue_task_fair 新进程加入就绪队列
    • enqueue_entity
      • update_curr 更新cfs队列和正在运行的进程的时间
      • if (wakeup) 加入的进程为刚被唤醒的进程
        • place_entity 计算虚拟运行时间
      • __enqueue_entity 进程的调度实体加入红黑树

2.6.4 选择下一个进程 pick_next_task_fair

选择下一个将要运行的进程由pick_next_task_fair执行。其代码流程图在图2-21给出。
在这里插入图片描述

  • pick_next_task_fair 选择下一个进程
    • pick_next_entity
      • __pick_next_entity 从红黑树中提取调度实例
      • set_next_entity 从队列红黑树中删除将运行的进程,给 cfs_rq->curr
        • __dequeue_entity 将其从红黑树中移除

2.6.5 处理周期性调度器 task_tick_fair

在处理周期调度时前述的差值很重要。形式上由函数task_tick_fair负责,但实际工作由entity_tick完成。图2-22给出了代码流程图。
在这里插入图片描述

  • task_tick_fair
    • entity_tick
      • update_curr 更新cfs队列和正在运行的进程的时间
      • 判断进程能抢占并调用 check_preempt_tick 进行决策
        • sched_slice 计算进程在延迟周期中确定的份额(能运行的时间)
        • resched_task 发出重调度请求,即在 task_struct 中设置 TIF_NEED_RESCHED 标志.核心调度器会在下一个合适的时间发起重调度

2.6.6 唤醒抢占

当在try_to_wake_upwake_up_new_task中唤醒进程时,内核使用check_preempt_curr看看是否新进程可以抢占当前运行的进程。请注意该过程不涉及核心调度器!对完全公平调度器处理的进程,则由check_preempt_wakeup函数执行该检测。

  • check_preempt_wakeup 进程从休眠中被唤醒时,判断是否抢占当前进程
    • 如果新唤醒的进程为实时进程,立即请求重调度,因为实时进程总是会抢占CFS进程,结束函数
    • SCHED_BATCH 进程不处理直接结束函数
    • 判断当前进程至少运行了 sysctl_sched_wakeup_granularity 时间后才能调用 resched_task 请求重调度,即在 task_struct 中设置 TIF_NEED_RESCHED 标志(增加的时间“缓冲”确保了进程不至于切换得太频繁,避免了花费过多的时间用于上下文切换,而非实际工作。)

2.6.7 处理新进程

对完全公平调度器需要考虑的最后一个操作是创建新进程时调用的挂钩函数:task_new_fair。该函数的行为可使用参数sysctl_sched_child_runs_first控制。顾名思义,该参数用于判断新建子进程是否应该在父进程之前运行。这通常是有益的,特别是在子进程随后会执行exec系统调用的情况下。该参数的默认设置是1,但可以通过/proc/sys/kernel/sched_child_runs_first修改。

  • task_new_fair 创建新进程时调用
    • update_curr 负载的更新
    • place_entity 计算虚拟运行时间
    • 如果子进程先运行,且子进程的虚拟时间大于父进程,则需要交换父子进程的虚拟时间 swap
    • enqueue_task_fair 将子进程加入就绪队列
    • resched_task 请求重调度

2.7 实时调度类

按照POSIX标准的强制要求,除了“普通”进程之外,Linux还支持两种实时调度类。调度器结构使得实时进程可以平滑地集成到内核中,而无需修改核心调度器,这显然是调度类带来的好处(完全公平调度器在唤醒抢占代码部分需要了解实时进程的存在,但这需要的工作量微乎其微。)。

现在比较适合于回想一些很久以前讨论过的事实。实时进程的特点在于其优先级比普通进程高,对应地,其static_prio值总是比普通进程低,如图2-14所示。rt_task宏通过检查其优先级来证实给定进程是否是实时进程,而task_has_rt_policy则检测进程是否关联到实时调度策略

2.7.1 性质

实时进程与普通进程有一个根本的不同之处:如果系统中有一个实时进程且可运行,那么调度器总是会选中它运行,除非有另一个优先级更高的实时进程。

现有的两种实时类,不同之处如下所示。

  • 循环进程(SCHED_RR)有时间片,其值在进程运行时会减少,就像是普通进程。在所有的时间段都到期后,则该值重置为初始值,而进程则置于队列的末尾。这确保了在有几个优先级相同的SCHED_RR进程的情况下,它们总是依次执行。
  • 先进先出进程(SCHED_FIFO)没有时间片,在被调度器选择执行后,可以运行任意长时间。很明显,如果实时进程编写得比较差,系统可能变得无法使用。只要写一个无限循环,循环体内不进入睡眠即可。在编写实时应用程序时,应该多加小心。

2.7.2 数据结构

实时进程的调度类定义如下:

//kernel/sched_rt.c
const struct sched_class rt_sched_class = {
	.next			= &fair_sched_class,
	.enqueue_task		= enqueue_task_rt,
	.dequeue_task		= dequeue_task_rt,
	.yield_task		= yield_task_rt,

	.check_preempt_curr	= check_preempt_curr_rt,

	.pick_next_task		= pick_next_task_rt,
	.put_prev_task		= put_prev_task_rt,

	.set_curr_task          = set_curr_task_rt,
	.task_tick		= task_tick_rt,
};

实时调度器类的实现比完全公平调度器简单。大约只需要250行代码,而CFS则需要1100行!核心调度器的就绪队列也包含了用于实时进程的子就绪队列,是一个嵌入的struct rt_rq实例:

//kernel/sched.c 
struct rq { 
    ... 
    /* 子就绪队列,用于实时调度器 */
	struct rt_rq rt;
    ... 
};

就绪队列非常简单,链表就足够了(SMP系统需要更多的结构成员,用于负载均衡)

//kernel/sched.c 
/* 实时进程链表数组 */
struct rt_prio_array {
	/* bitmap位图中每个比特为对应一个链表,链表中有进程则对应的位置1 */
	DECLARE_BITMAP(bitmap, MAX_RT_PRIO+1); /* include 1 bit for delimiter */
	/* 表头,链表成员为struct task_struct->run_list,相同优先级的实时进程都保存在一个链表中,表头为queue[prio] */
	struct list_head queue[MAX_RT_PRIO];
};

/* 就绪队列,用于实时调度器 */
struct rt_rq {
	/* 实时进程链表数组 */
	struct rt_prio_array active;
	int rt_load_balance_idx;
	struct list_head *rt_load_balance_head, *rt_load_balance_curr;
};

具有相同优先级的所有实时进程都保存在一个链表中,表头为active.queue[prio],而active.bitmap位图中的每个比特位对应于一个链表,凡包含了进程的链表,对应的比特位则置位。如果链表中没有进程,则对应的比特位不置位。图2-23说明了具体情形。
在这里插入图片描述

实时调度器类中对应于update_cur的是update_curr_rt,该函数将当前进程在CPU上执行花费的时间记录在sum_exec_runtime中。所有计算的单位都是实际时间,不需要虚拟时间。这样就简化了很多。

2.7.3 调度器操作

进程的入队和离队都比较简单。只需以p->prio为索引访问queue数组queue[p->prio],即可获得正确的链表,将进程加入链表或从链表删除即可。如果队列中至少有一个进程,则将位图中对应的比特位置位;如果队列中没有进程,则清除位图中对应的比特位。请注意,新进程总是排列在每个链表的末尾。

两个比较有趣的操作分别是,如何选择下一个将要执行的进程,以及如何处理抢占。首先考虑pick_next_task_rt,该函数放置选择下一个将执行的进程。其代码流程图在图2-24给出。
在这里插入图片描述

  • pick_next_task_rt 选择下一个将执行的进程
    • sched_find_first_bit 找到active.bitmap位图中第一个置位的比特位,因为内核优先级值低表示优先级高
    • list_entry 从优先级链表数组中获取对应优先级链表中的进程

周期调度的实现同样简单 task_tick_rt。

为将进程转换为实时进程,必须使用sched_setscheduler系统调用,它只执行了下列简单任务

  1. 使用deactivate_task将进程从当前队列移除
  2. 在task_struct中设置实时优先级和调度类。
  3. 重新激活进程

如果进程此前不在任何就绪队列上,那么只需要设置调度类和新的优先级数值。停止进程活动和重激活则是不必要的。

要注意,只有具有root权限(或等价于CAP_SYS_NICE)的进程执行了sched_setscheduler系统调用,才能修改调度器类或优先级。否则,下列规则适用。

  1. 调度类只能从SCHED_NORMAL改为SCHED_BATCH,或反过来。改为SCHED_FIFO是不可能的。
  2. 只有目标进程的UID或EUID与调用者进程的EUID相同时,才能修改目标进程的优先级。此外,优先级只能降低,不能提升。

2.8 调度器增强

到目前为止,只考虑了实时系统上的调度。事实上,Linux可以做得更好些。除了支持多个CPU之外,内核也提供其他几种与调度相关的增强功能,在以后几节里会论述。但请注意,这些增强功能大大增加了调度器的复杂性,因此主要考虑简化的情形,目的在于说明实质性的原理,而不考虑所有的边界情形和调度中出现的奇异情况。

2.8.1 SMP调度

多处理器系统上,内核必须考虑几个额外的问题,以确保良好的调度。

  1. CPU负荷必须尽可能公平地在所有的处理器上共享。如果一个处理器负责3个并发的应用程序,而另一个只能处理空闲进程,那是没有意义的。
  2. 进程与系统中某些处理器的亲合性(affinity)必须是可设置的。例如在4个CPU系统中,可以将计算密集型应用程序绑定到前3个CPU,而剩余的(交互式)进程则在第4个CPU上运行。
  3. 内核必须能够将进程从一个CPU迁移到另一个。但该选项必须谨慎使用,因为它会严重危害性能。在小型SMP系统上CPU高速缓存是最大的问题。对于真正大型系统,CPU与迁移进程此前使用的物理内存距离可能有若干米,因此对该进程内存的访问代价高昂。

进程对特定 CPU 的亲合性,定义在 task_struct的 cpus_allowed成员中。Linux 提供了sched_setaffinity系统调用,可修改进程与CPU的现有分配关系。

  1. 数据结构的扩展
    在SMP系统上,每个调度器类的调度方法必须增加两个额外的函数:

    //include/linux/sched.h
    struct sched_class {
    ... 
    /* SMP(Symmetric Multi-Processing) 对称多处理结构
    * 指在一个计算机上汇集了一组处理器(多CPU),各CPU之间共享内存子系统以及总线结构
    *  */
    #ifdef CONFIG_SMP
        //允许从最忙的就绪队列分配多个进程到当前CPU,但移动的负荷不能比max_load_move更多。
        unsigned long (*load_balance) (struct rq *this_rq, int this_cpu,
                struct rq *busiest, unsigned long max_load_move,
                struct sched_domain *sd, enum cpu_idle_type idle,
                int *all_pinned, int *this_best_prio);
    
        //从最忙碌的就绪队列移出一个进程,迁移到当前CPU的就绪队列
        int (*move_one_task) (struct rq *this_rq, int this_cpu,
                    struct rq *busiest, struct sched_domain *sd,
                    enum cpu_idle_type idle);
    #endif
    ... 
    };
    

    虽然其名字称之为load_balance,但这些函数并不直接负责处理负载均衡。每当内核认为有必要重新均衡时,核心调度器代码都会调用这些函数。特定于调度器类的函数接下来建立一个迭代器,使得核心调度器能够遍历所有可能迁移到另一个队列的备选进程,但各个调度器类的内部结构不能因为迭代器而暴露给核心调度器。load_balance函数指针采用了一般性的函数load_balance,而move_one_task则使用了iter_move_one_task。这些函数用于不同的目的。

    • iter_move_one_task从最忙碌的就绪队列移出一个进程,迁移到当前CPU的就绪队列。
    • load_balance则允许从最忙的就绪队列分配多个进程到当前CPU,但移动的负荷不能比max_load_move更多。

    负载均衡处理过程是如何发起的?在SMP系统上,周期性调度器函数scheduler_tick按上文所述完成所有系统都需要的任务之后,会调用trigger_load_balance函数。这会引发SCHEDULE_SOFTIRQ软中断softIRQ(硬件中断的软件模拟,更多细节请参见第14章),该中断确保会在适当的时机执行run_rebalance_domains。该函数最终对当前CPU调用rebalance_domains,实现负载均衡。时序如图2-25所示。
    在这里插入图片描述

    • scheduler_tick 周期性调度器

      • trigger_load_balance 触发软中断 SCHED_SOFTIRQ
    • run_rebalance_domains SCHED_SOFTIRQ软中断函数

      • rebalance_domains smp系统调度域重新均衡

    为执行重新均衡的操作,内核需要更多信息。因此在SMP系统上,就绪队列增加了额外的字段:

    //kernel/sched.c
    //运行队列
    struct rq {
        ...
        #ifdef CONFIG_SMP
        /* 所有的就绪队列组织为调度域(scheduling domain)。这可以将物理上邻近或共享高速缓存的CPU群集起来,应优先选择在这些CPU之间迁移进程。但在“普通”的SMP系统上,所有的处理器都包含在一个调度域中,该结构包含了大量参数,可以通过/proc/sys/kernel/cpuX/domainY设置 */
        struct sched_domain *sd;
    
        /* For active balancing */
        /* 主动均衡时置为非0,在内核周期性均衡就绪队列后效果不佳时设置,这时 push_cpu 则记录了从哪个处理器发起的主动均衡请求 */
        int active_balance;
        /* 记录了从哪个处理器发起的主动均衡请求 */
        int push_cpu;
        /* cpu of this runqueue: */
        //当前就绪队列所属的cpu
        int cpu;
    
        /* 迁移线程,用于完成发自调度器的迁移请求,或用于实现主动均衡 */
        struct task_struct *migration_thread;
        /* 链表头,链表元素为struct migration_req->list,内核为每个就绪队列提供了一个迁移线程,可以接收迁移请求,
        * 这些请求保存在链表 migration_queue 中 
        * */
        struct list_head migration_queue;
        #endif
        ...
    }
    

    就绪队列是特定于CPU的,因此cpu表示了该就绪队列所属的处理器。内核为每个就绪队列提供了一个迁移线程,可以接收迁移请求,这些请求保存在链表migration_queue中。这样的请求通常发源于调度器自身,但如果进程被限制在某一特定的CPU集合上,而不能在当前执行的CPU上继续运行时,也可能出现这样的请求。内核试图周期性地均衡就绪队列,但如果对某个就绪队列效果不佳,则必须使用主动均衡(active balancing)。如果需要主动均衡,则将active_balance设置为非零值,而push_cpu则记录了从哪个处理器发起的主动均衡请求。

    此外,所有的就绪队列组织为调度域(scheduling domain)。这可以将物理上邻近或共享高速缓存的CPU群集起来,应优先选择在这些CPU之间迁移进程。但在“普通”的SMP系统上,所有的处理器都包含在一个调度域中。因此我不会详细讨论该结构,要提的一点是该结构包含了大量参数,可以通过/proc/sys/kernel/cpuX/domainY设置。其中包括了在多长时间之后发起负载均衡(包括最大/最小时间间隔),导致队列需要重新均衡的最小不平衡值,等等。此外该结构还管理一些字段,可以在运行时设置,使得内核能够跟踪记录上一次均衡操作在何时执行,下一次将在何时执行。

    那么load_balance做什么呢?该函数会检测在上一次重新均衡操作之后是否已经过去了足够的时间,在必要的情况下通过调用load_balance发起一轮新的重新均衡操作。该函数的代码流程图如图2-26所示。请注意,该图中描述的是一个简化的版本,因为SMP调度器必须处理大量边边角角的情况。
    在这里插入图片描述

    • run_rebalance_domains SCHED_SOFTIRQ软中断函数
      • rebalance_domains smp系统调度域重新均衡
        • load_balance 重新均衡
          • find_busiest_group 找到调度组
          • find_busiest_queue 找到调度组所有cpu上工作量最大的队列
          • move_tasks 将该队列中适当数目的进程迁移到当前队列
            • class->load_balance 调用调度器的load_balance函数
          • 如果move_tasks返回失败,则设置主动均衡标志并调用 wake_up_process 唤醒最忙就绪队列的迁移线程

    在选择被迁移的进程时,内核必须确保所述的进程:

    1. 目前没有运行或刚结束运行,因为对运行进程而言,CPU高速缓存充满了进程的数据,迁移该进程则完全抵消了高速缓存带来的好处;
    2. 根据其CPU亲合性,可以在与当前队列关联的处理器上执行。

    如果均衡操作失败(move_tasks调用返回失败)(例如,远程队列上所有进程都有较高的内核内部优先级值,即较低的nice值),那么将唤醒负责最忙的就绪队列的迁移线程。为确保主动负载均衡执行得比上述方法更积极一点, load_balance 会设置最忙的就绪队列的 active_balance 标志,并将发起请求的CPU记录到rq->cpu。

  2. 迁移线程 migration_thread
    迁移线程用于两个目的。一个是用于完成发自调度器的迁移请求,另外一个是用于实现主动均衡。迁移线程是一个执行 migration_thread 的内核线程。该函数的代码流程图如图2-27所示。
    在这里插入图片描述

    • migration_thread 迁移线程,用于完成发自调度器的迁移请求,或用于实现主动均衡
      • if (rq->active_balance) 判断是否要主动均衡,主动均衡调用 active_load_balance 比load_balance函数更强硬,如不会进行优先级比较
        • move_one_task 从当前就绪队列移出一个进程,移至发起主动均衡请求CPU的就绪队列
          • 对所有调度器类调用 class->move_one_task 直到一个成功
      • 判断有没有迁移请求,检测 migration_queue 链表中是否有来自调度器的待决迁移请求,如果没有请求则调用 schedule 切换进程
      • 如果有请求则调用 __migrate_task 处理请求,该函数会直接移出所要求的进程
      • 然后调用 complete 唤醒发起请求的进程
  3. 核心调度器中smp系统的代码区别
    除了上述增加的特性之外,在SMP系统上还需要对核心调度器的现存方法作一些修改。虽然到处都是一些小的细节变化,与单处理器系统相比最重要的差别如下所示。

    • 在用exec系统调用启动一个新进程时,是调度器跨越CPU移动该进程的一个良好的时机。事实上,该进程尚未执行,因此将其移动到另一个CPU不会带来对CPU高速缓存的负面效应。exec系统调用会调用挂钩函数sched_exec,其代码流程图如图2-28所示。
      sched_balance_self挑选当前负荷最少的CPU(而且进程得允许在该CPU上运行)。如果不是当前CPU,那么会使用sched_migrate_task,向迁移线程发送一个迁移请求。

    • 完全公平调度器的调度粒度与CPU的数目是成比例的。系统中处理器越多,可以采用的调度粒度就越大。sysctl_sched_min_granularity和sysctl_sched_latency都乘以校正因子1+log2(nr_cpus) , 其 中 nr_cpus 表示现有的 CPU 的数目。但它们不能超出 200 毫秒。sysctl_sched_wakeup_granularity也需要乘以该因子,但没有上界。
      在这里插入图片描述

    • do_execve exec系统调用

      • sched_exec 用于将新进程迁移到负荷最少的cpu上,如果负荷最少的cpu不是当前cpu则向迁移线程发起迁移请求
        • sched_balance_self 挑选当前负荷最少的CPU
        • 如果负荷最少的CPU不是当前CPU则调用 sched_migrate_task 向迁移线程发送一个迁移请求

2.8.2 调度域和控制组

在此前对调度器代码的讨论中,调度器并不直接与进程交互,而是处理可调度实体。这使得可以实现组调度:进程置于不同的组中,调度器首先在这些组之间保证公平,然后在组中的所有进程之间保证公平。举例来说,这使得可以向每个用户授予相同的CPU时间份额。在调度器确定每个用户获得多长时间之后,确定的时间间隔以公平的方式分配到该用户的进程。事实上,这意味着一个用户运行的进程越多,那么每个进程获得的CPU份额就越少。但用户获得的总时间不受进程数目的影响。

把进程按用户分组不是唯一可能的做法。内核还提供了控制组(control group),该特性使得通过特殊文件系统cgroups可以创建任意的进程集合,甚至可以分为多个层次。该情形如图2-29所示。
在这里插入图片描述

为反映内核中的此种层次化情形,struct sched_entity增加了一个成员,用以表示这种层次结构:

//include/linux/sched.h
/**
 * 调度器操作的结构体,对进程运行的时间记账等
 * 进程中有这个结构体实例,所以进程能被调度器调度
 * 如果其他结构体想使用调度器也要加入这个结构体实例
*/
struct sched_entity {
...
    /* 组调度宏,需要编译内核时打开选项 */
#ifdef CONFIG_FAIR_GROUP_SCHED
	struct sched_entity	*parent;
	/* rq on which this entity is (to be) queued: */
	struct cfs_rq		*cfs_rq;
	/* rq "owned" by this entity/group: */
	struct cfs_rq		*my_q;
#endif
...
};

所有调度类相关的操作,都必须考虑到调度实体的这种子结构。举例来说,考虑一下在完全公平调度器将进程加入就绪队列的实际代码

for_each_sched_entity会遍历由sched_entity的parent成员定义的调度层次结构,每个实体都加入到就绪队列。

请注意,for_each_sched_entity实际上是一个普通的循环。如果未选择支持组调度,则会退化为只执行一次循环体中的代码,因此又恢复了先前的讨论所描述的行为特性。

2.8.3 内核抢占和低延迟相关工作

现在把注意力转向内核抢占,该特性用来为系统提供更平滑的体验,特别是在多媒体环境下。与此密切相关的是内核进行的低延迟方面的工作,稍后讨论。

  1. 内核抢占
    如上所述,在系统调用后返回用户状态之前,或者是内核中某些指定的点上,都会调用调度器。这确保除了一些明确指定的情况之外,内核是无法中断的,这不同于用户进程。 如果内核处于相对耗时较长的操作中,比如文件系统或内存管理相关的任务,这种行为可能会带来问题。内核代表特定的进程执行相当长的时间,而其他进程则无法运行。这可能导致系统延迟增加,用户体验到“缓慢的”响应。如果多媒体应用长时间无法得到CPU,则可能发生视频和音频漏失现象。

    在编译内核时启用对内核抢占的支持,则可以解决这些问题。如果高优先级进程有事情需要完成,那么在启用内核抢占的情况下,不仅用户空间应用程序可以被中断,内核也可以被中断。切记,内核抢占和用户层进程被其他进程抢占是两个不同的概念

    内核抢占是在内核版本2.5开发期间增加的。尽管使内核可抢占所需的改动非常少,但该机制不像抢占用户空间进程那样容易实现。如果内核无法一次性完成某些操作(例如,对数据结构的操作),那么可能出现竞态条件而使得系统不一致。在多处理器系统上出现的同样的问题会在第5章论述。

    因此内核不能在任意点上被中断。幸运的是,大多数不能中断的点已经被SMP实现标识出来了,并且在实现内核抢占时可以重用这些信息。内核的某些易于出现问题的部分每次只能由一个处理器访问,这些部分使用所谓的自旋锁保护:到达危险区域(亦称之为临界区)的第一个处理器会获得锁,在离开该区域时释放该锁。另一个想要访问该区域的处理器在此期间必须等待,直到第一个处理器释放锁为止。只有此时它才能获得锁并进入临界区。

    如果内核可以被抢占,即使单处理器系统也会像是SMP系统。考虑正在临界区内部工作的内核被抢占的情形。下一个进程也在核心态操作,凑巧也想要访问同一个临界区。这实际上等价于两个处理器在临界区中工作,我们必须防止这种情形。每次内核进入临界区时,我们必须停用内核抢占。

    内核如何跟踪它是否能够被抢占?回想一下,可知系统中的每个进程都有一个特定于体系结构的struct thread_info实例。该结构也包含了一个抢占计数器(preemption counter):

    //<asm-arch/thread_info.h>
    struct thread_info {
        ...
        int preempt_count; /* 0 => 可抢占, <0 => BUG */
        ...
    }
    

    该成员的值确定了内核当前是否处于一个可以被中断的位置。如果preempt_count为零,则内核可以被中断,否则不行。该值不能直接操作,只能通过辅助函数dec_preempt_countinc_preempt_count,这两个函数分别对计数器减1和加1。每次内核进入重要区域,需要禁止抢占时,都会调用inc_preempt_count。在退出该区域时,则调用dec_preempt_count将抢占计数器的值减1。由于内核可能通过不同路线进入某些重要的区域,特别是嵌套的路线,因此preempt_count使用简单的布尔变量是不够的。在陆续进入多个临界区时,在内核再次启用抢占之前,必须确认已经离开所有的临界区。

    dec_preempt_count和inc_preempt_count调用会集成到SMP系统的同步操作中(参见第5章)。无论如何,对这两个函数的调用都已经出现在内核的所有相关点上,因此抢占机制只需重用现存的基础设施即可。

    还有更多的函数可用于抢占处理。

    • preempt_disable通过调用inc_preempt_count停用抢占。此外,会指示编译器避免某些内存优化,以免导致某些与抢占机制相关的问题。
    • preempt_check_resched会检测是否有必要进行调度,如有必要则进行。
    • preempt_enable启用内核抢占,然后用preempt_check_resched检测是否有必要重调度。
    • preempt_disable_no_resched停用抢占,但不进行重调度。

    在内核中的某些点,普通SMP同步方法提供的保护是不够的。例如,在修改per-cpu变量时可能会发生这种情况。在真正的SMP系统上,这不需要任何形式的保护,因为根据定义只有一个处理器能够操作该变量,系统中其他的每个CPU都有自身的变量实例,不需要访问当前处理器的实例。但内核抢占的出现,使得同一处理器上的两个不同代码路径可以“准并发”地访问该变量,这与两个独立的处理器操作该值的效果是相同的。因此在这些情况下,必须手动调用preempt_disable显式停用抢占。

    但要注意,第1章提到的get_cpu和put_cpu函数会自动停用内核抢占,因此如果使用该机制访问per-cpu变量,则没有必要特别注意。

    内核如何知道是否需要抢占?首先,必须设置TIF_NEED_RESCHED标志来通知有进程在等待得到CPU时间。这是通过preempt_check_resched来确认的:

    <preempt.h>
    #define preempt_check_resched() \
    do {\
        if (unlikely(test_thread_flag(TIF_NEED_RESCHED))) \
        preempt_schedule(); \
    } while (0)
    

    该函数是在抢占停用后重新启用时调用的,此时检测是否有进程打算抢占当前执行的内核代码,是一个比较好的时机。如果是这样,则应尽快完成,而无需等待下一次对调度器的例行调用。

    抢占机制中主要的函数是preempt_schedule。设置了TIF_NEED_RESCHED标志,并不能保证一定可以抢占内核,内核有可能正处于临界区中,不能被干扰。

    • preempt_schedule 进行抢占
      • 如果preempt_count非零,或中断停用(无硬件中断无法抢占),则无法抢占当前进程,返回
      • 在调用调度器之前,抢占计数器的值设置为 PREEMPT_ACTIVE
      • schedule 调度
      • 调度的进程执行完毕回到当前进程后,抢占计数器减去 PREEMPT_ACTIVE

    如图2-30所示。它向schedule函数表明,调度不是以普通方式引发的,而是由于内核抢占。
    在这里插入图片描述

    schedule 函数中如果抢占计数器设置了 PREEMPT_ACTIVE 抢占标志会跳过 deactivate_task(如果进程目前不处于可运行状态,则调度器会用deactivate_task停止其活动)操作

    这确保了尽可能快速地选择下一个进程,而无需停止当前进程的活动。如果一个高优先级进程在等待调度,则调度器类将会选择该进程,使其运行

    该方法只是触发内核抢占的一种方法。另一种激活抢占的可能方法是在处理了一个硬件中断请求之后。如果处理器在处理中断请求后返回核心态(返回用户状态则没有影响),特定于体系结构的汇编例程会检查抢占计数器值是否为0,即是否允许抢占,以及是否设置了重调度标志,类似于preempt_schedule的处理。如果两个条件都满足,则调用调度器,这一次是通过preempt_schedule_irq,表明抢占请求发自中断上下文。该函数和preempt_schedule之间的本质区别是,preempt_schedule_irq调用时停用了中断,防止中断造成递归调用。

    根据本节讲述的方法可知,启用了抢占特性的内核能够比普通内核更快速地用紧急进程替代当前进程。

  2. 低延迟 通过在函数中添加有条件调度函数 cond_resched 实现低延迟
    即使没有启用内核抢占,内核也很关注提供良好的延迟时间。例如,这对于网络服务器是很重要的。尽管此类环境不需要内核抢占引入的开销,但内核仍然应该以合理的速度响应重要的事件。例如,如果一网络请求到达,需要守护进程处理,那么该请求不应该被执行繁重IO操作的数据库过度延迟。我已经讨论了内核提供的一些用于缓解该问题的措施:CFS和内核抢占中的调度延迟。第5章中将讨论的实时互斥量也有助于解决该问题,但还有一个与调度有关的操作能够对此有所帮助。

    基本上,内核中耗时长的操作不应该完全占据整个系统。相反,它们应该不时地检测是否有另一个进程变为可运行,并在必要的情况下调用调度器选择相应的进程运行。该机制不依赖于内核抢占,即使内核连编时未指定支持抢占,也能够降低延迟。

    发起有条件重调度的函数是cond_resched

    如何使用cond_resched?举例来说,考虑内核读取与给定内存映射关联的内存页的情况。这可以通过无限循环完成,直至所有需要的数据读取完毕.如果需要大量的读取操作,可能耗时会很长。由于进程运行在内核空间中,调度器无法象在用户空间那样撤销其CPU,假定也没有启用内核抢占。通过在每个循环迭代中调用cond_resched,即可改进此种情况。

    for (;;)
        cond_resched();
        /* 读入数据 */ 
        if (exit_condition) 
            continue;
    

    内核代码已经仔细核查过,以找出长时间运行的函数,并在适当之处插入对cond_resched的调用。即使没有显式内核抢占,这也能够保证较高的响应速度。

    调度器相关的文档移到了一个专用目录Documentation/scheduler/下,旧的O(1)调度器的相关文档都已经过时,因而删除了。有关实时组调度的文档可以参考Documentation/scheduler/sched-rt-group.txt

总结

内核启动时在 rest_init 函数中创建了两个进程,kernel_init 和 kthreadd.
kernel_init 用于内核接下来的初始化,激活多个cpu,关联到调度机制,驱动和子系统的初始化.启动用户空间的第一个程序等
kthreadd 用于实际新进程的创建,kthread_create 向 kthread_create_list 链表添加要创建的进程的信息,并唤醒 kthreadd 进程来进行实际的创建操作

编译器会在main函数(或特定语言使用的main函数)末尾自动添加系统调用 exit

每个cpu有一个运行队列 runqueues,运行队列结构体中保存了所有进程的信息(不分进程优先级和进程的调度类)
运行队列中还保存了不同调度器的子队列(实时调度器队列和完全公平调度器队列)
完全公平调度器队列中用红黑树按进程的虚拟运行时间组织进程的调度入口结构体
实时调度器队列使用了链表数组,数组下标为进程的优先级,相同优先级在一个链表中.并用一个位图记录了链表数组中每个链表是否有进程来加速访问

两种方法激活进程调度:

  1. 进程休眠放弃
  2. 周期机制,在时钟处理函数(tick_nohz_handler)中周期设置重调度标志

最后进程的调度都由函数 schedule 处理

唤醒进程(wake_up_new_task)或修改进程优先级(这里对进程做了出队然后再入队的操作)时,将进程入调度器的队列

在调度(schedule,判断进程为不可运行状态)或修改进程优先级(这里对进程做了出队然后再入队的操作)时,将进程移出调度器的队列

抢占有两个时机:

  1. preempt_check_resched 在抢占停用后重新启用时调用
  2. preempt_schedule_irq 在处理了一个硬件中断请求之后.如果处理器在处理中断请求后返回核心态(返回用户状态则没有影响),特定于体系结构的汇编例程会检查抢占计数器值是否为0

=========================================

涉及的命令,配置和全局变量:

查看进程资源的限制(如能打开的文件最大数等)cat /proc/self/limits

全局链表头 pid_hash,存着struct upid

只要设置了预处理器常数__HAVE_THREAD_FUNCTIONS,那么各个体系结构可以随意在stack数组中存储什么数据。在这种情况下,它们必须自行实现task_thread_infotask_stack_page,这两个函数用于获取给定task_struct实例的线程信息和核心态栈。另外,它们必须实现dup_task_struct中调用的函数setup_thread_stack,以便确定stack成员的具体内存布局。当前只有IA-64和m68k不依赖于内核的默认方法

shell命令set可以查看shell的所有环境变量

  1. current:当前正在运行的进程的指针,在 SMP 中则指向 CPU 组中正被调度的CPU 的当前进程
  2. current_thread_info:指向当前执行进程的thread_info实例的指针
  3. init_task:即 0 号进程的 PCB,是进程树的根
  4. jiffies/jiffies_64:是 Linux 的基准时间,系统初始化时清 0,记录了系统启动以来,经过了多少tick,间隔由CONFIG_HZ中定义,如CONFIG_HZ=200,则一个jiffies对应5ms时间。所以内核基于jiffies的定时器精度也是5ms。由时钟中断处理程序 do_timer()增 1
  5. need_resched():重新调度标志位判断函数,当需要系统调度时置位,在系统调用返回前或其他情况下,判别标志位是否为 1,以决定是否调用 schedule()进行 CPU 调度

全局就绪队列 runqueues,保存就绪的进程,用于调度器选择进程运行
全局变量 sysctl_sched_latency,调度延迟,保证每个可运行的进程都应该至少运行一次的某个时间间隔,可通过/proc/sys/kernel/sched_latency_ns控制
全局变量 sched_nr_latency,一个延迟周期中处理的最大活动进程数目,可以通过sysctl_sched_min_granularity间接地控制,后者可通过/proc/sys/kernel/sched_min_granularity_ns设置.每次sysctl_sched_latency/sysctl_sched_min_granularity之一改变时,都会重新计算sched_nr_latency
sysctl_sched_child_runs_first,参数用于判断新建子进程是否应该在父进程之前运行,可以通过/proc/sys/kernel/sched_child_runs_first修改

/proc/sys/kernel/cpuX/domainY cpu调度域相关设置

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值