第四章 线程切换与调度——操作系统的发动机

对于操作系统而言,即使是最基本的Linux 0.11来说,线程切换也是精致且精妙的,博主在这里只能用我的话大致的描述出基本框架,相比起我目前在学校学习到的课程更加深入一些。朋友们完全可以看一个乐呵,当然如果你想再深入了解一些,到达汇编指令的阶段,我推荐李志军,刘宏伟老师的《操作系统原理,实现与实践》一书。

4.1 线程与进程

我们刚开始想进程切换可能无从下手,但只要把它分开来看,进程无非是资源加上相应的指令。那么通俗来讲,进程切换由 资源切换 和 指令流切换 两部分构成,其中 资源切换 是将分配给进程的非CPU资源进行切换,eg:地址空间的切换;而 指令流切换 是CPU切换,也就是线程切换

资源切换我们在第三章其实已经提到过,不同进程并发执行的时候为了防止相互干扰,设立了地址隔离。而访问真实的物理地址需要通过一张与相应进程匹配的映射表。切换映射表,简单意义上已经完成了资源的切换。而与之配套的是指令流的切换。而指令流的切换并不是,一个跳转指令就能解决的。我们就需要去深入了解一下进程实际工作的样子,因为我们需要保存相应的现场到PCB中以便我们再切回来。

4.1.1 线程概念的引入

我们进程切换是为了并发,而并发是为了提高CPU的工作效率。那么在进程内部我们是不是还可以用并发来提高效率?就如同进程是对CPU执行程序的一种抽象,线程也是对进程内部执行程序的一种抽象。下面以浏览器浏览网页为例讲解一下一个进程执行时内部线程并发执行的样子,以及为何不使用多个进程来完成线程的工作。

再做进一步的解释,进程执行序列是由我们编写的程序组成的,而在程序中我们调用一个一个函数,如果粗略的看起来,线程其实就像那一个一个的函数。

4.1.2 一个多线程实例

对于浏览器浏览网页的这个进程而言,多线程并发可以显著提高人机交互的友好性假设一个浏览器页面启动,而且你正在访问斯坦福大学主页。 你会发现:首先,网页中的文字会被加载出来; 其次,一些比较小图片会加载出来; 然后,更大的图片和视频加载出来了。 这样的话,我们在等待过程中,可以先一步看清网页的文字信息,提升了人机交互友好性。


现在分析这个浏览器进程: 这个浏览器进程在加载这个页面的时候一共启动了四个线程:获取数据的线程,显示文本的线程,解压图片的线程,渲染图片的线程。 获取数据的线程开始工作,将网页总体布局和文本信息下载后,切换到显示文本的线程之中开始工作。再切回到获取数据线程中继续接收数据,等到较小图片数据获取完成之后,再切换到相关线程开始再页面中显现... 用 只用一个进程,用四个进程来完成 与上面使用 一个进程中的四个线程 来完成进行对比:

用一个进程:把全部数据下载下来之后,再显示到页面上面。

四个进程:进程切换除了指令流切换还包括资源切换,会导致内存的浪费和代码执行效率降低。除此之外,对一浏览器显示页面来说,需要的资源是共通的,所以此处并不存在安全问题,因此并不需要地址隔离策略来分隔。

4.1.3 线程与进程

线程进程详细描述
能否并发
切换内容指令流指令流和资源如进程的地址分布需要切换
切换代价如内存映射表,线程并不需要创建
创建速度(资源消耗)线程创建的时候使用的直接是进程的公有资源
相互影响操作同一内存完全分离一个进程的多个线程使用的是同一个内存映射表
安全性一个线程可以访问同一进程下的其他线程的内存位置
隶属关系同属于一个进程属于操作系统

4.2 用户级线程的切换与创建

交替执行的线程可以由操作系统来管理,也可以由用户程序自己管理。

而由用户程序自己管理的线程,对操作系统是透明的,操作系统不知道,也无法管理这些线程。称这些线程为:用户级线程。

4.2.1 用户级线程之间的切换

简单来讲,用户级线程之间的切换使用用户态函数Yield(),(关于用户态和内核态可以查看第二章 系统接口——通向操作系统内核的大门)

接下来分析程序调用用户态函数Yield()之后发生了什么事情。我们先假设这里有一个进程正在单核单CPU上面执行,这个进程上有两个线程A和B,正在来回并发执行。

1.直接去看我们可能只知道过程,而不知道其所以然,想要明白所以然,我们需要先明白函数的调用过程这样才能学到更多的知识,同时对操作系统也理解更加深入(可以参考我这篇文章:)。在A线程的Yield()函数调用的时候,函数返回地址首先被压栈,然后开始执行Yield()函数。理所应当的里面存放的是跳转到B线程的开始地址的指令jmp,然后CPU取指执行。等到执行到B的Yield()函数时,如同上面一样,函数返回地址压栈,然后开始准备跳回到A线程。但是这个时候问题产生了,当我们跳回到A线程后,B线程的Yield()执行完毕。这时候该退栈返回了,问题出现了,当B线程的Yield()函数执行完毕返回时,返回的的是B线程的Yield()函数执行完毕后的场景。整个过程就类似于,A线程正在执行着程序,我们调用Yield()函数,让它切换到B线程上执行,但是当我们在B线程调用Yield()函数返回A线程时,它其实并没有真正返回,只是短暂的到达了A线程的Yield()切换时的场景,然后就又返回到了B线程Yield()执行完毕的场景,这个时候栈里面还压着A线程的其他函数返回地址。整个事件相当于A线程执行中,切换到B上,在B中短暂的返回了A一下,然后又回到B中,执行完B,再返回到A中调用Yield()函数出。如同A线程执行了一次函数调用,不过这个函数中途返回了一下调用它的地方。

2.为什么最后会返回到B线程中,原因就在于我们最后将B线程的Yield()函数返回地址压栈了,而这与A线程的执行顺序显然不能混为一起。最简单的解决办法就是,为A线程和B线程各自申请一个栈,并在切换线程的时候切换相应的栈。而这个时候因为切换了栈,也就没有必要再使用跳转语句切换到A线程Yield()执行完后返回的地址了,因为栈中会帮我们记录着A线程的返回地址。我们只需要等待Yield()函数执行到最后的 } 时,系统会自动弹栈,弹出来的正好是A线程Yield()的返回地址,程序也就可以继续正确的执行了。一个进程中的用户级线程切换到这里基本上就完成了。当然这只是粗糙意义上的完成,不过也确实是完成了。

总结一下我们在上面经历过的线程切换模样,我们得到以下这些结论: 1.用户级线程切换就是在需要切换的位置上调用Yield()函数。Yield()函数帮助我们完成切换,这其实是一种封装。

2.Yield()函数内部通过寻找到下一个线程的TCB(Thread Control Block 如同 PCB) 来获取切换所需要的所有信息。

3.切换接近尾声时,A(上一个线程)的Yield()函数执行到 } 处,此时弹栈,PC指针将被置为B要执行的指令处。

4.当然在切换时我们需要保存一些寄存器的值(通用寄存器,保存在相应线程的栈中)以便返回时,恢复现场。

4.2.2 用户级线程的创建

上面讲完了线程的切换,这一小节我们来看一下线程的创建。同进程一样,线程也是运行起来的程序。让程序运行起来并不困难,CPU取指执行即可,而上面也论述清楚了CPU是如何在不同线程之间切换的。换句话说,只要我们能创造出来一段程序,这段程序可以执行起来,并在不同线程之间来回切换,显然我们就大概意义上创造出了一个线程。或者,换句话说,创造一个线程,也就是创造出一个能让CPU切换进去的样子。

由前面知识我们可以知道,一个线程需要有一个独属于它自己的一个栈来保存相关的调用关系,然后还需要有一个TCB来保存一些基本信息,如线程栈顶指针ESP(Extended Stack Pointer 堆栈指针寄存器,只指向最后一个栈帧的的栈顶)等内容。

具体实现可以查看李志军,刘宏伟老师所写的《操作系统原理,实现与实践》,这里只介绍基本流程,不再详细说明。

4.3 内核级线程的切换与创建

4.3.1 内核级线程的引出

当用户级线程为了完成某些操作,需要调用操作系统的功能时,会进入内核态。想上面浏览器例子中,获取数据的线程会通过网卡向网站发出数据下载请求,而网卡是操作系统负责驱动和管理的,因此,此时会进入内核态。而CPU为了效率,它可以说是始终为并发而努力,在网卡获取数据时,CPU要切换到其他程序去执行。而这个时候,问题出现了。由于CPU只能感知到内核态的东西,像进程之类的,CPU可以通过它的PCB来感知它(可以说是唯一途径),而用户级线程创建在内核态,其相关信息保存在进程之中,CPU是无法感知到的。这个时候可能就会导致一个进程中的线程之间无法很好的并发执行,只能等待线程内部调用Yield()函数来手动切换,极大的影响了并发效率。从而为了让CPU能够感知到用户级线程的存在,我们在内核态中创建相应的内核级线程来解决这个问题。

可能有的朋友觉得,这里CPU没有办法切换进程中线程,是影响了这个进程内部的线程并发效率,而不是影响了整局的并发效率。注意我们现在的场景,随着技术的发展,CPU逐渐发展为多核心CPU。这个核就像我们理解的那样,就是可以同时执行。但是核与核之间通常来讲是共享一个存储管理部件MMU(Memory Management Unit)以及一些缓存(暂存一些最近的地址映射结果),而MMU在同一时间只能查找一张地址映射表,也就是说如果多核执行多个进程时,我们需要来回切换地址映射表同时存储的缓存也就不能发挥很好的功效。而一个进程内部的多个线程,我们前面提到过,是共享他们所属进程的资源的。显然线程是十分适合多核处理器结构的,可以显著的提高效率。这也就是内核级线程的关键,可以让CPU察觉到用户级线程,从而开启并发。

最后,我们概括一下用户级线程,内核级线程,进程这三者之间的关系: 1.不管是用户级线程,内核级线程和进程,他们都是正在执行中的程序,他们也都是对现有执行中的程序的一种抽象,也都是指令序列,没有本质区别。 2.进程除了需要执行指令序列外,还需要分配内存等资源。 3.将进程执行指令序列进一步抽象,引出了线程概念。 4.再回看进程,进程因为需要分配一些涉及到计算机硬件的资源,所以进程必须再内核中创建,而进程中存在于内核的那一套执行序列,其实就是一个内核级线程。 5.再看内核级线程,内核级线程是操作系统基于一套进程的资源创建的。操作系统为每一个这样的内核级线程创建相应的数据结构来实现对这些内核级线程控制,如切换,调度等。 6.而用户级线程,是由操作者,或者说代码的编写者来操控的,它可以中应用程序中,对启动的用户级线程进行相应的操控与调度。

只有我们需要用到内核的东西,也就是进行了相应的系统调用的时候,我们才创建出相应的内核级线程来帮助我们完成相应的功能。而那些已经基本创建好的内核级线程大部分处于休眠状态,只有相应的功能过来,它才唤醒,然后与相应的进程接轨,完成创建。

这一部分可能总体感觉会有点乱,需要我们不断地观看别人地讲述,或者自己把Linux 0.11代码拉下来,来仔细研究。可能才能在脑海中构建出一个CPU工作的蓝图。

4.3.2 内核级线程之间的切换

回看用户级线程的切换,我们会发现,它的切换过程无非就是三步:找到TCB,然后根据TCB中存储的栈指针完成栈的切换,再根据栈中存储的函数返回地址完成PC指针的切换。因此,我们完全可以做出一个大胆的推测,即:内核级线程的切换也是如同用户级线程切换一般,经历这个三部曲。

但我们知道内核级线程是与用户级线程有区别的,区别最大一点就是TCB的位置,内核级线程的TCB是存放在内存中的,由操作系统来分配,用户级线程TCB是存在于它所属的进程之中的。而我们指令是运行在用户态中的,想要切换内核级线程,我们需要首先进入内核态,而引起内核中断的函数我们在第一张 系统启动——打开电源以后发生的故事里有讲到过,就是int函数。综上所述:用户级线程切换的核心是根据存放在用户程序中的TCB找到用户栈,通过用户栈切换完成用户级线程的切换,整个切换过程通过调用Yield()函数引发。内核级线程切换的核心是首先进入操作系统内核并在内核中找到线程TCB,进而根据TCB找到线程的内核栈,通过内核栈切换完成内核级线程切换,整个切换过程由中断引发。

接下来我们来看中断以后发生的事情 ,如同其他中断一样(像时钟中断,键盘中断,磁盘读写完成中断等),int指令执行时,会找到当前进程的内核栈,然后将用户态执行的一些重要信息(比如:当前程序执行位置CS:EIP,当前用户栈栈顶位置SS:ESP,标志寄存器EFLAGS等)压入到内核栈中。这里内核栈是做如用用户栈一样的工作为了存储信息,方便跳转与恢复。

我们现在已经知道,中断以后,内核栈会把与它相关联的用户栈的信息压入,在这之后,它就会如用户级线程切换一般,从队列中取出下一个内核级线程的TCB来切换像用户级线程切换一般我们这里也通过一个函数Schedule()来完成切换,接着再从这个TCB中取出栈。这个时候我们需要返回到用户态中,我们介绍一个iret()函数,这个函数功能正好与int()函数相反,它是int指令的逆过程(int中断会把信息存入栈,那么iret就会把栈中的信息恢复)。

综上所述,内核级线程之间的切换如同用户级线程之间的切换一样:切换TCB,切换栈,切换PC指针。但内核栈需要返回,需要中断,所以说内核级线程切换不能像用户级线程切换一样封装在一个Yield()函数之中。这里,李志军老师将内核级线程切换过程归纳为五个阶段:1.中断进入,2.调用schedule完成TCB切换,3.内核栈的切换,4.中断返回,5.用户栈的切换

归纳一下用户级线程,和内核级线程之间的关联摸样。首先,想象出一个栈的模样,我们把它作为用户栈,现在把它放到最上面。然后再想象出一块空间里面存放着我们的代码,也就是我们计算机要执行的指令。这两块并列存放。然后下面有一层屏障,在屏障下方便是我们内核态。再想象一个内核栈,这个栈中有着用户栈的许多重要信息,因此我们用线把他连起来,像一个指针一样。同样的,这个栈中也有着我们PC指针等与我们代码执行相关的,因此也用指针指向。而栈又存储在TCB中,我们再构建一个空间,这个空间是TCB,TCB内部如同指针一样,指着栈,帮助我们找到相应的栈。到此,基本的形状也就出来了,下面放上一张来自老师书中的图,希望对你理解有帮助。

4.3.3 内核级线程的创建

像上面用户级线程一样,内核级线程的创建也就是:将一个线程初始化成能切换进去的,且切换进去会从它入口函数开始执行的这么一个样子。

那么依着内核级线程的切换,我们有以下四个步骤:1.创建一个TCB,主要存放内核栈的esp指针,2.分配一个内核栈,其中主要存放用户态程序的PC指针,用户栈地址以及执行现场,3.分配用户栈,主要存放进入用户态函数时用到的参数等内容

到此内核级线程的创建也就完成了。

具体代码及详细细节请参考,Linux 0.11代码和李志军,刘宏伟老师的操作系统原理,实现与实践。

4.4 创建0号/1号进程

操作系统的0号进程和1号进程是在初始化时有系统创建的,是操作系统启动的最后一段代码。其中0号进程需要手动设置进程信息,包括PCB,内核栈,用户栈以及用户程序。

调用fork()函数来创建一号进程,此后会根据fork()的返回值进行分支处理。而1号进程最终会进入execve系统调用,也就是变成了一个壳子,就是所谓的shell程序。

4.5 CPU的调度

4.5.1 CPU调度的含义与算法准则

首先操作系统调度没有绝对的完美,只有相较而言的完美。这是因为每个要求都是不同的,我们不可能保证效率的情况下,对于用户鼠标的点击间隔两秒钟才回应。

4.5.2 若干CPU调度的基本算法

1.先来先服务调度

FCFS(first come first service),就像一个队列一样。

公平,但是效率不高.

2.最短作业优先调度

SJF(shortest job first),是一种可抢占式的调度,谁的运行时间低就先执行谁。

效率,但是不公平。是平均周转时间最小的调度算法。

3.轮转调度

RR(rount-robin) 时间片轮转调度。

可以保证用户的响应时间,因此是交互式任务调度的解决方案

4.多级队列调度

引入两个就绪队列,交互任务队列(前台任务队列)和非交互任务队列(后台任务队列)。

前台任务往往具有更高的优先据,但容易导致后台任务完不成,类似于手机切到后台的应用基本上就停止运行了。

5.多级反馈队列调度

前台任务和后台任务是互相会转换的,并不是固定死的。

所以我们需要进行动态调整,主要有两种方法,I/O动态调整和按照执行时长动态调整。

4.5.3 多级反馈队列调度算法的一个具体实现

4.6 实践项目4:基于内核栈完成进程切换

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值