本章首先引出线程这个概念,并以线程为单位对CPU切换进行详细论述,这是因为线程切换是进程切换的核心内容
;
进程切换由资源切换和指令流切换两部分构成,其中资源切换是将分配给进程的非CPU以外的资源进行切换,如对当前地址空间的切换(资源切换将在之后的内存管理、文件系统章节详细论述);而指令流切换就是CPU切换,也就是线程切换(这是本章的重点);
并发是CPU高效工作的基础,并发的基本含义就是多段程序交替执行,我们思考,是否可以将这种交替执行的思想应用于一个进程(同一个可执行文件)的不同函数之间呢?这样的交替执行就产生了线程的概念;
我们给出一个浏览器使用线程和不使用线程的例子:
肯定又有人问了,那我使用四个进程呗?和使用四个线程有什么区别?进程和线程的区别在于是否共享资源(线程之间不会共享栈!!!
这在之后我们会介绍),此处因为这四个函数是可以不使用地址隔离策略将内存缓存区分离的(不存在安全性问题),况且使用四个进程会造成内存的浪费以及代码执行效率的降低,所以我们选择共享共同地址空间等进程资源的四个并发指令执行序列作为四个线程;
下面的表格给出了线程和进程的区别和联系
线程相较于进程的优点:
1.从资源上来讲:线程是一种非常“节俭”的多任务操作方式。而进程的创建需要更多的资源。
2.从切换效率上来讲:运行于一个进程中的多个线程,它们之间使用相同的地址空间,而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间。
据统计,一个进程的开销大约是一个线程开销的30倍左右。
3.从通信机制上来讲:对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过进程间通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其他线程所用,这不仅快捷,而且方便。
线程和进程的关系:
在一个地址空间下启动并交替执行的线程既可以由操作系统管理,也可以由用户程序管理:
想要实现多个用户级线程之间的切换,只需要在切换位置(调度点)调用一个普通的用户态切换函数(由用户自己编写)即可;
在设计用户态切换函数的时候需要注意:如果每个线程用自己的栈,那么在线程中执行函数调用和返回时就不会莫名其妙地跳转到其他线程中;
单个栈执行的两个用户级线程的切换实例
切换函数Yield是完成线程切换的核心,这里我们举例说明Yiled的工作过程:两个用户级线程,线程1执行A函数并在其中调用B函数;线程2执行C函数并在C函数中调用D函数;
首先线程1正常执行函数A,当需要调用函数B时需要通过一些栈来保存信息以便在函数B执行完毕后可以跳回A继续执行(这是函数调用的基本常识),因此我们将需要返回的地址104压栈;同理B中调用Yield的时候也会将204压栈,接着执行Yield函数(找到下一个线程以及下一个线程切换过去的时候的执行位置)
Yiled(){jmp 300};
切换到线程2执行,同理依次压入304和404,此时再次执行Yield函数跳转回线程1,因此此时的Yield函数应该为
Yiled(){jmp 204};//跳转回204的地址,因为刚才线程1是从204之前切换出去的
当我们从204继续执行的时候,遇到B的“}”会发生弹栈进行函数返回,我们期望的情况是能够弹回104即A函数中,但是很明显我们的栈会弹出线程2的404地址,因此我们需要做出改进
独立栈执行的用户级线程的切换实例
因为现在每个线程都拥有自己的栈,所以在Yiled函数切换的时候不仅要修改PC的值,还需要完成栈的切换,总结如下:
用户级线程的切换就是在切换位置上调用Yield函数;
切换函数Yield完成的基本工作就是找到下一个线程的TCB,根据当前线程中的TCB和下一个线程的TCB完成用户栈的切换,具体来说就是将寄存器ESP中的值esp1保存在当前线程的TCB中,然后从下一个线程的TCB中取出保存的esp2值赋给ESP寄存器;
新栈中的Yield函数中的“}”会将PC指针切换到下一个线程要执行的指令的位置;
Q:TCB和PCB有什么区别?答案参考自(28条消息) 进程PCB与线程线程TCB_shintyan的博客-CSDN博客_pcb和tcb
A:TCB是进程控制块,PCB是线程控制块(也称为任务控制块):
PCB作为独立运行基本单位的标志,PCB也是进程存在于系统中的唯一标志,PCB提供了进程管理所需要的信息、提供了进程调度所需要的信息并实现与其他进程的同步与通信,PCB中主要有如下信息:
每个进程都有一个PCB,每个线程也都存在一个TCB,将所有控制和管理线程的信息记录在TCB中,TCB中主要包含如下信息:
与TCB相比,PCB额外存放地址空间、进程包含的线程Tid
用户级线程由线程库创建和管理,其实现都在用户态,内核无法感知;(老师发的PDF里面还介绍了一种通过内核线程创建用户线程的方式,关于这点我在网上没有查阅到相关资料)
一旦明白切换的具体实现,线程的创建就非常容易理解 —— 线程的创建就是将线程做成第一次切换的样子(此处描述可能比较抽象,下面会详细描述);
一个进程通常包含多个线程,多个线程中必须包含一个主线程,进程启动时会从主线程(程序运行时即使没有创建线程后台也会存在如主线程、gc线程等多个线程)开始执行,接着主线程调用其他子线程或其他子线程之间相互调用,主线程结束意味着整个进程都会结束,那么其他所有子线程也会结束(强制结束);
我们假设线程1是主线程,线程1在执行过程中调用Yield()转换函数让出CPU切换到线程2中执行,如果线程2能够顺利执行则证明线程2创建成功(当然这里讨论的都是用户态线程的创建)
创建用户级线程的函数thread_create()大致如下
//用户级线程创建代码
thread_create(void*func)
{
long*stack = malloc(SIZE_OF_USERSTACK)+ SOME SIZE;
TCB*p = malloc(SIZE OF_TCB);
*stack = func;
*(stack--)=eax;//初始化执行现场,可以全是0
......
p->esp = stack;
}
前面介绍了用户级线程,下面我们通过用户级线程引出内核级线程,主要参照文章用户级线程和内核级线程,你分得清吗? - 知乎 (zhihu.com)(这篇文章争议比较多,但是对于初学者理解用户级线程和内核级线程完全够用)
用户级线程这个概念刚提出的时候,操作系统的厂商为了避免直接将未验证过的东西加入内核于是编写了一个关于用户级线程的函数库,However,这个函数库位于用户空间,也就是说操作系统内核对这个函数库一无所知(也就意味着操作系统的眼中还是只有进程而没有线程这个概念,所以借助线程库写的多线程进程实质上还是只能在一个CPU核心上运行)
结论1:对操作系统来说,用户级线程具有不可见性(透明性)
结论2:用户级线程只能使用一个处理器,只能做到并发而无法做到并行加速
因为用户级线程的透明性,导致操作系统无法主动切换线程,也就意味着A、B两个进程同时存在时,A在运行的时候线程B想要运行只能等待A主动放弃CPU;
那么用户级线程就一无是处了?作为程序员,我们可以为自己编写的应用程序定制调度算法(自己决定什么时候退出线程),有关用户级线程的其他好处我们在之后会总结;
因为用户级线程在操作系统中是看不见的,那么当其中某一个线程阻塞(比如上面的A线程阻塞,那么B也会一直等待下去),在操作系统眼中是整个进程都阻塞了,对于这种情况也出现了相应的解决方法(如jacket),但如果我们使用内核级线程就不会存在这样的问题(线程A被阻塞,但与它同属一个进程的线程B不会被阻塞);
为了实现内核级线程,内核中需要有一个能够记录系统中所有线程的线程表,每当需要新建一个内核级线程的时候都需要进行一个系统调用进行线程表的更新,得益于线程表,操作系统可以看见内核级线程,因此操作系统可以将这些多线程放在多个CPU核心上实现真正的并行
,然而内核级线程并不是完全优秀,因为内核级的线程调度需要操作系统来实现,这意味着每次切换内核级线程都需要陷入内核态,而操作系统从用户态到内核态是有开销的,同时线程表存放在堆栈空间其数量受到限制,拓展性比不上用户级线程;
总结一下用户级线程和内核级线程(原文链接(28条消息) 用户级线程和内核级线程_TABE_的博客-CSDN博客_用户线程和内核线程):
用户级线程切换的核心是根据存放在用户程序中的TCB找到用户栈,通过用户栈切换完成用户级线程的切换,整个切换过程通过调用Yiled()函数引发;
内核级线程切换的核心是首先进入操作系统内核并在内核中找到线程TCB,进而根据TCB找到线程的内核栈(进入内核之后需要在内核中的某个地方完成PC指针切换,于是仿照用户级线程,将这个PC指针放在栈中,利用内核栈的切换引发PC指针的切换)、通过内核栈切换完成内核级线程切换,整个切换过程由中断引发;
用户级线程的优点 | 用户级线程的缺点 |
---|---|
线程切换代价的代价比内核线程少,因为保存线程状态的过程和调用程序都只是本地过程,没有上下文的切换 | 线程发生I/O或页面故障引起的阻塞时,如果调用阻塞系统调用则内核由于不知道有多线程的存在,而会阻塞整个进程 |
允许每个进程定制自己的调度算法,线程管理(创建、销毁等)比较灵活 | 由于每个线程并不具有自身的线程上下文。因此就线程的同时执行而言,任意给定时刻每个进程只能够有一个线程在运行,而且只有一个处理器内核会被分配给该进程。 |
内核级线程的优点 | 内核级线程的缺点 |
---|---|
多处理器系统中,内核能够并行 执行同一进程内的多个线程 |
即使CPU在同一个进程的多个线程之间切换,也需要陷入内核,因此其速度和效率不如用户级线程 |
如果进程中的一个线程被阻塞,能够切换同一进程内的其他线程继续执行 |
某些操作系统同时支持用户线程和内核线程,实现了用户级线程和内核级线程的连接方式:
1)多对一模型。将多个用户级线程映射到一个内核级线程,线程管理在用户空间完成。此模式中,用户级线程对操作系统不可见(即透明)。
优点:线程管理是在用户空间进行的,因而效率比较高。
缺点:一个线程在使用内核服务时被阻塞,整个进程都会被阻塞;多个线程不能并行地运行在多处理机上。
2)一对一模型。将每个用户级线程映射到一个内核级线程。
优点:当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。
缺点:每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。
3)多对多模型。将n个用户级线程映射到m个内核级线程上,要求m≤n。
特点:多对多模型是多对一模型和一对一模型的折中,既克服了多对一模型并发度不高的缺点,又克服了一对一模型的一个用户进程占用太多内核级线程而开销太大的缺点。此外,还拥有多对一模型和一对一模型各自的优点,可谓集两者之所长。
Q:为什么要把用户线程和内核线程结合在一起啊?有什么意义呢?
A:用户线程就是用户自己创建的线程调度程序,但是这样的用户线程实际上不能单独运行,用户线程运行的唯一方法就是告诉内核线程,使其帮忙执行用户线程中包含的代码;
简单来说用户线程根本就不是线程,仅仅算得上是用户程序中的一堆数据,内核线程才是真正的线程,所以要使得用户线程能够运行只能是将其与内核线程映射关联;
回顾用户级线程的切换主要分为三个步骤:
TCB切换;
根据TCB中存储的栈指针完成用户栈切换;
根据用户栈中压入函数返回地址完成PC指针切换;
补充知识点:
内核栈(
栈是用来在函数跳转时保存返回地址等重要信息以备将来返回的
)中记录了当前用户栈的位置和当前用户程序执行的位置,在内核级线程切换的时候利用这两个信息完成PC指针的切换以及用户栈的切换;
内核级线程的TCB存储在操作系统的内核中,因此完成TCB切换的程序应该执行在操作系统的内核中,因此内核级线程的切换应该从进入内核开始,那么我们就必须先介绍中断,因为中断会导致用户态到内核态的切换,那么首先我们就先来弄明白发生中断过后会有什么情况(几乎所有的外部中断都会引起下面的动作):
中断指令执行时,会找到当前进程的内核栈,然后将用户态执行的一些重要信息压到内核栈中;
简单来说内核级线程切换仍然完成三个工作:切换TCB、切换栈和切换PC指针,但是这些切换动作要分散在中断入口、中断处理、线程调度、上下文切换以及中断返回等多个地方。不像用户级线程切换那样所有切换动作都在一个Yeild()函数中。因此内核级的切换过程就复杂得多,为清晰起见,将内核级线程的切换过程归纳整理为下图所示的五个阶段:
Q:很多人可能会疑惑不是说内核级线程之间的切换吗?怎么看图好像是用户线程1->内核线程1->内核线程2->用户线程2这样的一个切换顺序
A:这里因为书上有些概念没讲明白,所以我们额外讲一下
好的,现在我们来讲为什么会出现用户栈的概念(参考原文(28条消息) 8.内核级线程(核心级线程)_PacosonSWJTU的博客-CSDN博客_内核级线程):
内核级线程由内核直接创建并管理
前面已经介绍过内核级线程的切换主要由四个具体的切换构成:切换TCB,切换内核栈,切换用户栈,用户程序PC指针切换;
相应地创建内核级线程的关键在于初始化TCB、内核栈以及用户栈:
第一,创建一个TCB,主要存放内核栈的esp指针;
第二,分配一个内核栈,其中主要存放用户态程序的PC指针、用户栈地址以及执行现场;
第三,分配用户栈,主要存放进入用户态函数时用到的参数等内容;
前面已经介绍过,多进程视图是操作系统的核心视图,多进程视图的核心就是创建进程
的系统调用
fork;
fork的核心是通过复制父进程来创建子进程,操作系统中最基本的两个父进程是0号和1号进程(在操作系统初始化时由系统建立),系统中所有的进程都是从0号进程和1号进程继承而来;
fork是一个只能工作在用户态应用程序中的系统调用,创建0号进程不能使用fork,需要手动设置进程信息(PCB、内核栈、用户栈以及用户程序等)
调度的基本概念:当有一堆任务需要处理,但是由于资源有限,这些任务不能同时处理(此处是真正意义上的同时),于是就需要确定某种规则来决定处理这些任务的顺序,这就是调度研究的问题;
线程切换中我们并没有解决这样一个问题 —— 如何在一系列可供选择的就绪线程中选择下一个线程(其目的是将CPU分配给这个线程)是良好的,这就引出本节的主题,CPU调度;
CPU调度简单来说就是在就绪
线程/进程队列
中选择一个合适的线程/进程
,再通过切换机制将CPU资源分配给选择的线程/进程
;(说一下,这里本质上应该是进程调度,只是哈工大教材根本没有提及三级调度的概念)
因为根据操作系统是否支持线程,CPU调度的基本单位分为线程和进程,所以这里我们将线程和进程统称为任务;
这里我们以PC机的通用操作系统作为基本对象分析(注意不同的对象其调度策略的目的和设计、实现原则等可能不同),需要考虑如下准则:
我们举个例子说明CPU调度的重要性,PC机上交互任务和非交互任务同时存在:
这两个目标之间存在矛盾,不可能同时优化,因此能够有效的折中任务调度策略成为CPU调度分析的核心问题;
一个作业从提交开始到完成,一般需要经历如下三级调度:
作业调度。又称高级调度
,其主要任务是按一定的原则从外存
上处于后备状态的作业中挑选一个(或多个)作业,给它(们)分配内存、输入/输出设备等必要的资源,并建立相应的进程,以使它(们)获得竞争处理机的权利。简言之,作业调度就是内存与辅存之间的调度
。对于每个作业只调入一次、调出一次。多道批处理系统中大多配有作业调度,而其他系统中通常不需要配置作业调度。作业调度的执行频率较低,通常为几分钟一次;
中级调度。又称内存调度
,其作用是提高内存利用率和系统吞吐量。为此,应将那些暂时不能运行的进程调至外存等待,把此时的进程状态称为挂起态。当它们已具备运行条件且内存又稍有空闲时,由中级调度来决定把外存上的那些已具备运行条件的就绪进程,再重新调入内存,并修改其状态为就绪态,挂在就绪队列上等待;
进程调度。又称低级调度
,其主要任务是按照某种方法和策略从就绪队列中选取一个进程,将处理机分配给它。进程调度是操作系统中最基本的一种调度,在一般的操作系统中都必须配置进程调度。进程调度的频率很高,一般几十毫秒一次(咱们下面讲的非交互式、交互式实际上都只是讲的进程调度)
作业调度从外存的后备队列中选择一批作业进入内存,为它们建立进程,这些进程被送入就绪队列,进程调度从就绪队列中选出一个进程,并把其状态改为运行态,把CPU分配给它。中级调度是为了提高内存的利用率,系统将那些暂时不能运行的进程挂起来。当内存空间宽松时,通过中级调度选择具备运行条件的进程,将其唤醒;
这里我们再辨析两个概念:进程调度和进程切换
进程调度就是我们上面所说的从就绪队列中选中一个要运行的进程(这个进程可能是刚刚被暂停执行的进程(原因可能是因为资源不足等因素),也可能是另一个进程);
进程切换就是指一个进程让出处理机,由另一个进程占用处理机,进程调度中的第一种情况不需要进程切换(因为本来就是同一个进程),第二种情况(即不同进程的调度)才需要进程切换;
调度了新的就绪进程之后才会进行进程间的切换,理论上这两个事件顺序不能颠倒(事实上也确实不会颠倒),进程切换(也就是我们上面刚开始所说的切换机制)主要完成:
- 对原来运行进程各种数据的保存
- 对新的进程各种数据的恢复
(有没有发现很类似之前介绍过的线程切换?因为线程切换是进程切换的核心,但是对于进程切换,王道和哈工大似乎都没怎么细讲)
进程调度方式简单来说就是当有优先级更高的进程进入就绪队列的时候应该如何分配处理机,通常有两种进程调度方式:
操作系统中有多种调度算法,有些调度算法适用于作业调度,有的适用于进程调度,有的两者都适用,下面我们介绍的都是能用于进程调度的一些调度算法;
FCFS调度算法就是选择就绪队列头部的的任务调度执行,特性是公平,缺点是很可能导致任务的平均周转时间较长,我们用下面这个例子举例
根据FCFS的基本思想我们可以得到其算法实例如下
则其平均周转时间(average completion time)为(10+39+42+49+61)/5=40.2,那么假如我们让T2和T3交换次序,则平均周转时间为(10+13+42+49+61)/5=35,其背后的思想是:让任务执行时间短的任务提前执行可以使平均周转时间变小,也就是下面我们会介绍的最短作业优先调度算法;
SJF算法的思想就是按照任务的执行时间从小到大排序,任务按照这个顺序依次调度执行,我们假设表4.2中的五个任务同时出现在0时刻
基本思想:如果在调度序列中存在Ti排在Tj前面,但是其执行时间大于Tj,那么就交换Ti和Tj在调度序列中的位置
Q:不难发现这里假设的是5个任务同时到达,然后按照执行时间大小顺序排列,假如不是同时到达呢?
关于上面那个问题,其实也就引出了最短剩余时间优先调度SRTF:每当有新任务到达时
,选择当前剩余执行时间最短的任务进行调度执行;
SRTF一定不是直接选择执行时间最短的!
这点很容易忽略,SRTF是一种可抢占式调度,这就意味着不是由任务自身主动让出CPU才引起的调度,而是只要有新任务到达就可能导致有任务抢占当前任务的CPU(因为新出现的任务具有更短的执行时间,所以具有更高的优先级);
SRTF在非交互任务中完成的很好,但是在交互任务中可能会不尽人意,最简单的,假设图4.15的T2是一个交互任务(用户点击鼠标),在时刻1我们点击了鼠标,在时刻32该用户操作才被响应,这在交互式体验中是非常差的!假如我们在一段时间内让所有的任务都有机会向前推进而不是呆呆的让抢占到CPU的任务执行完毕才让出CPU,是否可以优化响应时间呢?这就引出时间片轮转RR调度:将一段时间等分(执行时间片)的分割给每个任务,当前任务的时间片用完后就会切换到下一个任务;
假设一共有N个任务,时间片长度固定为u,则对于任何一个任务,最多等待N*u的时间,这个任务一定会得到执行的机会;
因此,通过设计合理的N和u可以保证用户响应时间的上界;
操作系统中既有交互任务,也有非交互任务,如何组合RR和SRTF来处理两种任务都存在的情况呢?
最简单的思想就是引入两个队列:
通常让前台队列具有更高的优先级,即假如前台队列中存在就绪任务则采用RR调度处理这个队列中的任务(此时就只能让后台队列中的队列慢慢等待);
多级队列调度存在一个明显的问题:假如采用非抢占式调用,则一旦被后台任务调度得到CPU,则只能等待它执行完毕之后才会主动释放CPU,这段时间可能导致前台任务的响应时间变长;但是如果采用抢占式调用(这里的抢占就是指只要有前台任务就执行前台任务),后果就是后台任务需要等待前台队列中没有任务才能调度;
解决上述问题的方法是后台任务也需要分配时间片,这样就算前台队列中存在任务,后台任务也不至于一直无法执行;
第二个问题是操作系统如何区分前台任务和后台任务 —— 多级队列中的任务类型并不是在任务创建时确定,应该根据任务在执行过程的具体表现来动态调整(编译过程看起来是后台任务,但是Ctrl+C中断编译术语用户交互),而这个动态调整实际上就是“反馈”的含义;
因此动态调整就成为了多级反馈队列调度的核心:
多级反馈队列调度算法实现思想如下:
除了上面介绍的调度算法以外,我们这里额外补充一些有特点的调度算法;
该算法是根据任务的开始截止时间来确定任务的优先级。截止时间愈早,其优先级愈高,越先被处理机执行;
该算法要求在系统中保持一个实时任务就绪队列,该队列按各任务截止时间的早晚排序,具有最早截止时间的任务排在队列的最前面。调度程序在选择任务时,总是选择就绪队列中的第一个任务,为之分配处理机,使之投入运行;
最早截止时间优先算法既可用于抢占式调度,也可用于非抢占式调度方式中;
下面我们直接给出一个例子理解
参考文章:
彩票调度的基本思想是:一开始的时候给每个进程发彩票(优先级越高,发的彩票越多),然后每隔一段时间(一个时间片),举行一次彩票抽奖,抽出来的号是谁的,谁就能运行;
假如有两个进程A和B,调度器想让A占用80%的 CPU 时间,B占用20%的CPU时间,调度器就给A发80张彩票,给B发20张彩票;这样,每次抽奖的时候,A就有80%的概率占用CPU,从数学期望上讲,1秒钟之内,A能运行800ms;
实际上彩票调度并没有在CPU调度程序里广泛使用,一个原因是不能很好的适合I/O,另一个原因是票数分配问题没有确定的解决方式,比如新打开了一个浏览器进程,那该给它分配多少票?票数少了,响应跟不上,票数多了,又会浪费 CPU时间;