首页 > 系统相关 >MIT6.828_进程切换和进程调度

MIT6.828_进程切换和进程调度

时间:2023-03-15 20:22:26浏览次数:104  
标签:优先级 env 调度 MIT6.828 ENV 进程 struct

MIT6.828_JOS进程切换

进程状态

JOS共有5种进程状态:

enum {
	ENV_FREE = 0,
	ENV_DYING,
	ENV_RUNNABLE,
	ENV_RUNNING,
	ENV_NOT_RUNNABLE
}

我个人觉得ENV_FREE这个状态其实不算是一个状态,这仅仅表示env数组中有一个空余的元素可以使用,前面的文章我也分析了fork和spawn的实现,它们都需要在env数组中找到一个空位置,然后“注册”一个新的进程。

当fork、spawn函数返回后,新的进程的状态就是ENV_RUNNABLE,表示它可以被调度器调度至CPU执行。当一个进程被调度至CPU执行时,它的状态就由ENV_RUNNABLE转变为ENV_RUNNING

如果进程在执行过程中需要等待某种事件结束(或者等待某种资源),那么它的状态将会被置为ENV_NOT_RUNNABLE,表示它将不会对调度器可见(见下一节)。JOS中比较典型的场景是:进程调用sys_ipc_recv,会阻塞本进程等待其他进程的消息。

当进程调用exit时,进程状态将会被置为ENV_DYING,相当于linux中的僵尸进程,但与linux不同,JOS的僵尸进程由本进程而不是父进程“收尸”。当释放了僵尸进程的资源后,该env结构又回到了ENV_FREE的状态。

各状态转换图如下所示:

image-20230313191930478

进程切换

JOS的进程切换相对比较简单,因为JOS的内核栈是固定的。前面的文章讲过,在x86体系中,内核栈的位置由tss中的esp0指针指定,它在各cpu启动时已经被设置好了:

void
trap_init_percpu(void)
{
	// 设置Tss的esp ss
	thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - thiscpu->cpu_id * (KSTKSIZE + KSTKGAP);
	thiscpu->cpu_ts.ts_ss0 = GD_KD; 
	thiscpu->cpu_ts.ts_iomb = sizeof(struct Taskstate);
	// 在GDT中设置后NCPUS个的段描述符
	gdt[(GD_TSS0 >> 3) + thiscpu->cpu_id] = SEG16(STS_T32A, (uint32_t) (&(thiscpu->cpu_ts)),
					sizeof(struct Taskstate) - 1, 0);
	gdt[(GD_TSS0 >> 3) + thiscpu->cpu_id].sd_s = 0;
	// 加载TR寄存器
	ltr(GD_TSS0 + (thiscpu->cpu_id<<3));

	// Load the IDT
	lidt(&idt_pd);
}

而且IDT的门描述符都是中断门,表示当用户通过中断态进入内核态时,不会再响应外部中断,因此内核态代码是不会被打断的,所以JOS用不着保存内核栈,每次进程中断进入内核时都默认内核栈为空,所以整体逻辑会比xv6稍微简单一些。

还是举个例子吧,假设进程A通过时钟中断进入了内核态:

trap_dispatch(struct Trapframe *tf)
{

	int ret ;
	switch(tf->tf_trapno){
		// ....
		case IRQ_OFFSET+IRQ_TIMER: // 收到时钟中断就调度
			lapic_eoi();
			sched_yield(); 

前面的文章已经详细说过了通过中断进入内核时,中断栈帧(Trapframe)的构建流程。而且要知道,从trap(struct Trapframe *tf)到trap_dispatch(struct Trapframe *tf)的调用流程过程中,两个tf指针指向不同的内存地址。第一个tf指针指向内核栈中的trapframe,第二个tf指针指向curenv中的trapframe。这样做是为了方便后头的env_xx函数的实现。

再回到上面的代码,假设进程A通过时钟中断进入了内核态,在确认中断后(lapic_eoi),就调用sched_yield()执行Round_Roubin调度:

// Choose a user environment to run and run it.
void
sched_yield(void)
{
	struct Env *idle;

	// 简单的round-roubin轮询调度
	int i=0,j;

	if(cpus[cpunum()].cpu_env)
		i=ENVX(cpus[cpunum()].cpu_env->env_id);
	// round-roubin 选择Env数组中的元素
    // 只选择ENV_RUNNABLE的进程进行调度
	for(j=(i+1)%NENV; j!=i; j=(j+1)%NENV) {
		if(envs[j].env_status == ENV_RUNNABLE)
			env_run(&envs[j]);
	}
	//如果不加下面这个判断,就少判断了env[i]的状态
	//这样当所有用户环境只剩env[i]的状态是ENV_RUNNABLE时,cpu无法运行env[i]也无法halt,就一直在循环
	if(j==i && envs[j].env_status == ENV_RUNNABLE)
		env_run(&envs[j]);
	// 找不到下一个可运行进程,那么本CPU再次运行当前进程
	if(curenv && curenv->env_status == ENV_RUNNING){	
		env_run(curenv);	
	}

	sched_halt();
}

调度器只选择进程状态为ENV_RUNNABLE的进程。然后调用env_run函数,执行上下文切换

void
env_run(struct Env *e)
{
	// 1. 将本进程状态设为ENV_RUNNABLE,被抢占了
	if (curenv && curenv->env_status == ENV_RUNNING) {
		curenv->env_status = ENV_RUNNABLE;
	}
    // 2. 设置即将运行的进程, 
	curenv = e; 
	curenv->env_status = ENV_RUNNING;
	curenv->env_runs++;
	lcr3(PADDR(curenv->env_pgdir)); // 切换页目录
	
	env_pop_tf(&(curenv->env_tf)); // 中断返回至用户态
}

CR3寄存器存放页目录的物理地址,因此首先使用PADDR将curenv->env_pgdir从虚拟地址转换为物理地址,然后调用lcr3将新的页目录地址加载进CR3中:

static inline void
lcr3(uint32_t val)
{
	asm volatile("movl %0,%%cr3" : : "r" (val));
}

最后调用env_pop_tf将会从新进程的中断上下文返回至用户态

env_pop_tf(struct Trapframe *tf)
{
	curenv->env_cpunum = cpunum();
	unlock_kernel(); // 释放大内核锁
	asm volatile(
		"\tmovl %0,%%esp\n" // esp 指向trapframe 末尾
		"\tpopal\n"
		"\tpopl %%es\n"
		"\tpopl %%ds\n"
		"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
		"\tiret\n"// 从中断返回, eip 在load_icode()设置 或者 在trapentry.S中保存
		: : "g" (tf) : "memory"); 
	panic("iret failed");  /* mostly to placate the compiler */
}

进程调度

无论是JOS还是xv6,它们的调度策略都是简单的Round-Robin策略。但进程调度相关话题在面试中出现的频率很高,所以在这里首先整理以下关于进程调度的理论知识,然后试着分析一下,linux系统的调度策略。

理论

参考书籍:《Operating Systems: Three Easy Pieces》

衡量指标与负载类型

进程调度有两个衡量指标:

  1. 周转时间:任务完成的时间点减去到达的时间点
  2. 响应时间:任务首次完成时间减去到达时间点

通常情况下,这两种指标是不可兼得的,只能视工作负载的不同选择合适的衡量指标。对应上面的两个衡量指标,有两个工作负载类型:

  1. CPU密集型作业,需要大量CPU时间很少的IO时间。它要求有较低的周转时间
  2. IO密集型作用,需要大量的IO请求。这类作业通常对交互性的要求较高,也就是说要求响应时间较低。典型的应用程序:shell。

操作系统类型

批处理系统:作业成批装入计算机由计算机管理,作业执行的过程中用户不能干预交互。适合于前后程序结果依赖的作业。

分时系统:多个用户可以通过各自的终端共用一台计算机,同计算机进行交互操作,计算机以“分时”(划 分时间片)的方法轮流为每个用户服务。

实时系统:要求及时响应且高可靠的一类系统。

FIFO(先来先服务)

维护一个队列,将到来的队列依次加入其中,执行时依次从队列头取出。非抢占,适用于批处理系统,对耗时长的作业有优势。

可能出现护航效应:一些花费时间较少的资源,由于它”晚到“,因此一直等到一个运行时间很长的任务结束才会被调度。

image-20230313222845261

此时的平均周转时间是比较高的。

最短任务优先

维护一个队列,每次cpu从中选择运行时间最短的那一个进行调度。

非抢占,适用于批处理系统,对耗时长的作业有优势。

同样有护航效应。

最短完成时间优先

为了缓解最短任务优先的护航效应,最短完成时间优先允许抢占。假设长任务A在0时刻到来,运行到10时刻时,有更短的作业B、C到来,则B、C抢占A。

image-20230313223158869

如此,平均周转时间会缩短不少。

轮转(Round Roubin)

以上两个策略的响应时间指标都比较差。为了使得交互型程序(IO多)获得更好的表现,Round Roubin是个不错的策略。

轮转策略的基本思想:在一个时间片内运行某个程序一段时间,然后切换至队列中的下一个任务。

抢占式调度,比较公平,且重要的是响应时间短,当然了周转时间就显得比较长了。

缺点:时间片的选取比较棘手。如果时间片很短,那么进程上下文切换的花费占比就很重,比较浪费CPU资源。如果时间片很长,那么轮转策略的响应时间就会变长。

多级反馈队列(The Multi-Level Feedback Queue)

多级反馈队列调度策略是一种结合轮转和优先级的调度策略

解决了两方面的问题:

  1. 在操作系统对任务的执行时间一无所知时,如何建立一个周转时间小的调度程序。
  2. 同时兼顾响应时间保证用户的体验。

MLFQ有许多独立的队列,每个队列维护一组优先级相同的作业,且从上至下,它们的优先级降低。如下图所示,A、B的优先级相同,且它们的优先级大于C,而C的优先级大于D。

image-20230313225228846

多机反馈队列的规则如下:

  1. 如果A的优先级 > B的优先级,运行A
  2. 如果A的优先级=B的优先级,轮转地运行A和B
  3. 作业首次进入系统时,放在最高优先级的队列
  4. 一旦工作用完了其在某一层中地时间配额,无论主动放弃了多少CPU(防止用户程序愚弄(game)调度程序而永远不降低优先级),就降低其优先级
  5. 经过一段时间S,就将系统中的所有工作重新加入最高优先队列(防止饥饿问题

linux调度策略

linux的task_struct中使用一个字段保存标识它自己是哪一类进程,大致有三类:

  • FIFO
  • RR
  • OTHER

其中FIFO和RR是实时进程,OTHER则表示其他普通进程。

  1. 实时进程的优先级一定是比普通进程高的,因此linux调度器会优先调度实时进程。在实时进程的调度过程中,FIFO与RR策略与上节的原理部分一致。但是linux通过了两个选项限制了实时进程的运行时间:

    /proc/sys/kernel/sched_rt_period_us
    /proc/sys/kernel/sched_rt_runtime_us
    

    表示FIFO和RR这些实时进程在总共period_us时间内,最多可以运行sched_rt_runtime_us时间。这样普通进程被饿死的可能性就大大降低了

  2. 接着是对普通进程的调度策略,linux在历史上对普通进程的调度过三种不同的调度策略。即O(n)、O(1)、CFS,其中O(n)、O(1)并不仅仅调度普通进程,它们还关注着实时进程,而CFS则专注于普通进程的调度。关于O(n)、O(1)、CFS出现的先后顺序,看这篇文章

    • O(n)调度器:维护一个链表头runqueue_head,将所有可运行进程串联起来。调度器在选择下一个要执行的进程时,则遍历runqueue_head链表,使用goodness()计算其中每个进程的权重,挑选权重值最大的那一个运行。很明显它的时间复杂度为O(n),优点是简单,缺点是扩展性不高,并发度不高(runqueue_head全局共享,包括在多核系统下)。

      那么goodness到底怎样计算权重呢?

      /*linux 2.4内核源码 sched.c*/
      // p : 候选task_struct
      // this_mm: 当前在此CPU上运行的进程的struct mm 结构,它表示了一个进程的地址空间(相当于JOS的PGDIR)
      static inline int goodness(struct task_struct * p, int this_cpu, struct mm_struct *this_mm)
      {
      	int weight;
      	weight = -1;
          // 1. 如果是yield的进程,权重为-1
      	if (p->policy & SCHED_YIELD)
      		goto out;
      	// 2. 针对普通进程
      	if (p->policy == SCHED_OTHER) {
      		// 如果下一个进程进程的时间片还没有用完,则奖励一些权重
      		weight = p->counter; 
      		if (!weight)
      			goto out;
      		// 如果候选task_struct使用的struct mm 与当前进程相同,表示它与当前执行流逻辑上属于同一个进程。加一些权重
      		if (p->mm == this_mm || !p->mm)
      			weight += 1;
              // 最后通过nice值计算
      		weight += 20 - p->nice;
      		goto out;
      	}
      	 // 3.实时调度进程, RT进程表示自己有特权,权重直接加上1000,不更普通进程比,更其他实时进程比
      	weight = 1000 + p->rt_priority;
      out:
      	return weight;
      }
      

      可以看到,对于实时进程,它根本不需要和普通进程比较,实时进程的权重永远比普通进程高,它只需要和其他实时进程比较rt_priority这个属性。

      至于普通进程的权重,则是通过conter和nice值综合结算的结果, counter值越高(也就是时间片剩余越多,也就是喜欢IO的进程)、nice值越低的进程,其权重越高。其中nice的取值范围为[-20 - 19],可以简单理解“越nice的进程,越得不到CPU的关注”。

    • O(1)调度器:使用了2个优先级数组,一个为active,另一个为expired,每个数组使用140位的bitmap表示,bitmap的每一位(如果它有效)都对应了一个队列,该队列中的进程优先级相同。bitmap的1-100的优先级分配给实时进程用,其余的优先级给普通进程,与O(n)相同,这里的实时进程同样拥有较高的优先级。调度器在选择下一个执行的进程时,只要找到优先级最高的队列选择一个进程执行即可。这个过程的时间复杂度是O(1),为什么呢?因为找到bitmap中的第一个有效位这个操作可以由一条CPU指令完成,时间复杂度为O(1)。此外每个CPU拥有的两个优先级数组,并发度有所提高。

      image-20230315152725889

      当一个进程用完了它的时间片后,调度器重新计算并重置它的时间片,然后放入expired数组中。当active数组的每一位都无效时,交换active、expierd两个bitmap即可,这个交换操作只需要交换指针指向即可,时间复杂度还是O(1)。

      此外,除了上述的静态优先级,O(1)调度器也会计算每个进程的动态优先级,目标也和O(n)调度器一样,对IO密集型的进程有利。但尽管如此,O(1)调度器对IO密集型进程的识别效果也欠佳。而且动态优先级的计算很复杂,可能使得O(1)调度器名不副实。

    • CFS调度器。该调度器专门用于普通进程的调度,CFS即为完全公平调度器,旨在对每个进程一视同仁。但是,即时是普通进程,他们的优先级有高也有低,同时调度器应该多照顾IO密集型任务,那么这里说的公平是指什么公平?指的是虚拟时间vruntime的相同:

      vruntime = 实际运行时间 * 1024 / 进程权重
      

      其中进程权重由nice值转换而来,nice值越低,权重值越高:

      image-20230315195320252

      如果忽略系数,那么CFS的目标就使得进程的实际运行时间与进程权重的比值相同。

      在具体实现上,linux使用红黑树维护每个进程的vruntime,红黑树O(logn)复杂度的插入、查找、删除操作是CFS的基础。位于红黑树左侧的节点vruntime相对较低,调度器选择vruntime值小的的进程运行一段时间后,随着实际运行时间的增加,vruntime也会增加,红黑树节点将慢慢向右移动

      image-20230315200530351

      从直觉上理解一下CFS,对于那些优先级高的进程,虽然它们的实际运行时间可能一直在增加,但是由于分母较大,所以它们向右移动的趋势比优先级低的进程小。对于那些IO密集型的进程,它们的实际运行时间就很少,所以也倾向于一直待在红黑树的左侧。所以CFS比较均衡地同时考虑了优先级和IO密集型进程。

TUDO: CFS调度器的缺点

参考资料:

标签:优先级,env,调度,MIT6.828,ENV,进程,struct
From: https://www.cnblogs.com/HeyLUMouMou/p/17219858.html

相关文章

  • 【docker系列】容器自启动与守护进程停止后容器保活
    本文为大家介绍容器自启动以及docker守护进程挂掉或者docker升级的情况下,如何保证容器服务的正常运行。主要包含三个部分内容文章目录一、守护进程开机自启二、......
  • Linux进程通信 | 管道与FIFO
    Linux进程间通信通常使用的方式有很多种,其中比较常用的包括管道(pipe)和FIFO(命名管道)。本文将介绍这两种通信方式的基本概念,并用C语言编写示例代码,来说明如何在两个进程之间......
  • Quartz组件的搭建及实现任务调度demo
    1.基本环境配置<dependency>​<groupId>org.slf4j</groupId>​<artifactId>slf4j-log4j12</artifactId>​<version>1.7.25</version>​</dependency><d......
  • python入门学习-3.多线程、多进程、网络通信
    进程和线程多任务线程是最小的执行单元,而进程由至少一个线程组成。多进程Linux操作系统提供了一个fork()系统调用,子进程返回0,父进程返回子进程的ID。调用getpid()可以......
  • 地铁线路查询系统课上进程。
    本次课老师布置了地铁查询系统的工作任务,使用Web编程完成。 首先我与我的伙伴遇到的问题就是数据库字段怎么来设计,这都为后期数据库的查询提供便利。  我与团队其他......
  • mysql 有守护进程导致无法kill停止
    现象:停止mysqld服务时,发现kill进程后,过一段时间服务会自动重启。查看发现是守护进程导致可以试下以下办法方式一:使用service停止:servicemysqldstop方式二:......
  • Linux进程的创建与销毁
    Linux操作系统是一种多任务、多用户的操作系统,这意味着它可以同时运行多个进程,每个进程都可以执行不同的任务。在本文中,我们将介绍如何在Linux系统中创建和销毁进程。进程......
  • Linux进程与线程的基本概念及区别
    前言假设你正在玩一款在线多人游戏,在游戏中,有多个角色需要进行不同的操作,例如攻击、移动、释放技能等等。接下来,我们用玩游戏的例子,来解释进程和和线程的概念,以及进程和......
  • FreeRTOS 任务调度
    【FreeRTOS】05任务的调度:抢占式、协作式、时间片轮转_freertos抢占式_xiaobaibai_2021的博客-CSDN博客1、FreeRTOS的任务调度方法有抢占式、时间片轮转、协作式。2、抢......
  • goroutine调度机制(GMP模型)
    进程、线程和协程进程是操作系统分配资源的最小单元,是一个具有一定独立功能的程序关于某个数据集合上的一次运行活动线程是操作系统调度的最小单元,是进程的一个执行单元......