Linux---进程切换与调度

补充概念

独立性

多进程运行时,独享各自的资源,运行期间彼此之间互不影响,这叫独立性,因此,父子进程间也是独立的,进程=内核数据结构+进程的代码和数据,所以父子进程的PCB肯定是不一样的,之前的博客里提到过,父子进程之间共享代码和数据(fork之后),代码是只读的,这个无所谓,但是数据要发生修改呢?之前也说过,为了维持进程间的独立性会发生写时拷贝。

并发性

多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,这叫进程并发,这里还是老生常谈的时间片的问题,假设每一个进程的时间片为10ms,总共10个进程,则1s之内这10个进程轮转10次,1s==1000ms,就是这10个进程在一个调度队列里,每隔10msCPU选择一个进程执行,当这个队列里的进程全部轮转完之后再开始新的一轮。所以我们才可以同时运行多个进程,又由于每个进程的时间片时间其实很短,而且轮转完一遍之后再轮转新一轮那个停顿的时间其实也很短,短到觉得它是连续的,其实是一卡一卡的。

进程切换

关于进程切换的概念,其实主体部分已经在讲进程概念那一篇博客里的上下文数据那里已经说的挺全了,现在来稍微总结一个点,由于需要进程切换(就是当前进程的时间片到期了要换下一个进程了),那么需要保留当前进程执行的数据(上下文数据),就比如说现在这个进程在下载一个1GB的数据,没下载完呢但是时间片到了,这时候就需要保留当前下载的数据,等下一次该进程切换回来的时候接着继续下载,寄存器是进程共享的,但是寄存器里的数据是该进程私有的,切换走的时候需要进程将自己的数据(进程上下文数据)也一起带走,进程切换的本质切的是进程的上下文数据。

组织进程

OS管理任何东西全是用到的先描述再组织的方式,之前的博客里其实主要就是在说OS描述进程的结构体PCB里属性的话题,为了理解进程调度,我们必须先来谈一谈OS是怎么组织进程的,它是以双链表的形式组织的,但是它的这个双链表跟我们以前学的不是一个东西,慢慢来吧。

前置知识:

现在给你一个结构体struct A {int a, int b, int c, double d},问:它的对象里各个成员在内存里的地址是由低到高还是由高到低?答案是由低到高,则&(obj) == &(obj.a),(obj是结构体A的对象)。现在有个整型变量a(int a),一共四个字节,也就有4个地址,那&a是这四个字节的哪一个字节的地址?答案是第一个字节的地址。那int a10的&a呢?&a = &a0。综上:C语言里的任何变量的地址数字,都是开辟的众多字节中,地址数据最小的那个。注意跟大小端区分,我这里上文说的意思是你在C语言里&变量得到的地址肯定是它这个变量所占的内存地址里最小的那一个字节的地址,而大小端存储指的是你这个变量里的内容是从小地址开始存还是大地址开始存。

假如我现在不用&obj的方式去计算结构体A的地址,但我知道结构体中成员c的地址,那我如何知道结构体A的起始地址呢?我们可以将0号地址强转成struct A*,然后再去访问c成员,也就是&((struct A*)0->c),此时就知道了当起始地址为0的时候,c成员的地址,这个地址也是c成员相对于起始地址的偏移量,最后,我们用c成员的地址去减去这个偏移量就能知道A的起始地址了(因为结构体是从低地址开始存的,所以c的地址一定比起始地址大,所以用减法)。

重新设计双链表:

如下图所示,task_struct内部有一个node结点,node内部的next指向下一个进程的PCB里的node,依次往复,所有的进程就都被组织起来了。注意这里的双链表跟我们数据结构里的不太一样,我们数据结构里学的链表就是指针指向完整的一个结点,而这里是指针指向的是PCB这个大结点内部的一个小结点。

我如何获取当前进程的其他属性?有了上边组织进程的方式,我们其实只知道进程PCB内部node的地址(由前一个进程PCB里node内的next指针保管着),那怎么知道PCB内部其他属性呢?其实这个就是上文前置知识里的问题,只知道A结构体c成员的地址,通过偏移量得到&obj,然后再用obj去访问结构体里的其他成员。

知道了如何访问task_struct里的其他成员属性之后,想一想问什么非要这么设计,我直接next和prev也全部塞进task_struct里,然后直接组织结构体不可以吗?这本来也符合我们学过的链表的认知啊?(如下图)。单独设计node,然后再将node作为task_struct的一个成员的好处是增加链式管理的扩展性,OS除了要管理进程,还要管理文件,管理硬件,都是采用先描述再组织的方式,就拿硬件举例,如果用下边的方式,不就重新要设计组织硬件的链表?但如果采用node,就直接将node作为硬件结构体task_device里的一个成员,组织方式其实跟组织进程就一模一样,这么设计之后,代码也只要维护一份,对于链表的增删查改其实都依托于node,结构体里其他属性不用动,传参数的时候传特定的参数就可以了,比如管理进程就传t->node(task_struct* t),管理硬件就传d->node(task_device* d)......

从OS角度,这样设计的好处是什么?Linux内核会将所有进程的task_struct统一放在一张双链表中,那之前说进程状态的时候,进程不是还有什么运行队列,阻塞队列?它们都是链式结构,难道说一个进程能放在多个链表里吗?这好像不符合我们的认知啊?其实就是这样的,现在我的task_struct里可以有node,那我是不是也可以有run(运行队列).......意思就是node,run.....它们都是struct link这个结构体的对象,我完全可以各归各干事情,node负责将进程以它的方式组织作为OS组织进程的链表,那run就负责组织某些运行状态下的进程......你是你我是我,我们互不干扰,但都是同一个进程。

struct link { struct link *next, *prev; };

struct task_struct

{

//进程属性......

struct link tasks;

struct link run;

......

};

甚至使用上述的方式,不同的结构体对象也能用链表连接,甚至二叉树,哈希表之类的数据结构,我只要将node塞进不同类型的结构体里,就能将它们组织起来,我只要将组织二叉树的left和right指针塞进一个结构体里,再将这个结构体对象塞进task_struct里(就像上边的struct link node一样),那么进程既可以用链表组织,也可以用二叉树组织......

进程调度

每一个CPU都有一个调度队列,叫做struct runqueue。在运行队列(runqueue)里有一个成员叫做queue140,它的全称是struct list_head queue140,struct list_head就是上文里的struct link,也就是说,queue里相当于有140个task_struct*,我们只关心queue里下标100~139的部分,总共40个,大家有没有发现,优先级PRI的范围是60, 99也是40个,PRI范围内的每一个数字加上40对应的就是100~139,因此,优先级数字的本质就是数组的下标,这也进一步解释了为什么优先级的范围是40个梯度,本质是OS就这么设计的。

下标为100~139里的每一个元素都指向一个fifo的队列,CPU选择进程时,直接根据该进程的优先级映射到queue数组里对应的下标,根据下标直接就确定了一个fifo的队列,直接取队头的那个进程就可以了(因为只要在一个FIFO队列里的进程的优先级都是一样的),因此根据优先级选择进程的时候,本质就是一个hash的过程,一旦确定是哪个队列(queue里每一个元素指向的都是一个FIFO的队列),剩下的就是FIFO,那么现在我有一个进程启动了,根据它的优先级就可以映射到queue的下标,然后就直接尾插到队列里就行。

CPU选择普通进程的过程就是在局部性便利queue数组(100~139),看看每个元素指向的队列是否为空,不为空的话就相当于找到了一批优先级最高的进程,然后就取队头进程就直接选择到了当前所有进程中优先级最高的一个进程。CPU选择进程的时间复杂度就是O(1)。那有人可能会说,如果100~138里没有进程,只有139下标里有进程,那选择进程的时候不就相当于要全部便利一遍了吗?但是这里撑死就便利40次,对于CPU来说不是很快的一件事吗,时间复杂度依旧是O(1)啊,如果你觉得这样还是太慢,还可以使用位图的方式。

位图的意思就是我整一串大于140位的二进制01序列,每一个二进制位就对应queue数组里的第几个下标,如果该二进制位为0就说明该queue数组里对应下标里元素指向的FIFO队列为空,反之就不为空,不为空就直接选择对应的进程。那为什么要将便利数组转化为位图的方式呢?我可以把位图里每32位看成一个整型数字,如果这个数字为0,说明32bit每一位都为0,那我是不是可以直接跳过queue里32个下标了,那原来可能要便利queue140次,现在只要便利个几次几十次,而且二进制不是有很多位运算嘛,选择进程就转化为了找到一串二进制序列里第一个1,可以大大加快查找的速度,位图对应在runqueue里是用long bitmap5来表示的,一个long4个字节,则一个bitmap20个字节,20*8==160bit,正好覆盖queue数组,还剩下20个比特位不用。

综上:CPU到queue里选择进程来调度的思路是,直接去查bitmap,查出第一个1出现的位置并且统计出这在bitmap所对应的二进制序列里的哪一位,再映射到queue对应的下标,就直接选择到了将要被调度的进程,将该进程的上下文数据恢复到寄存器里调度它。

如果整个runqueue里一个进程都没有怎么办?一个都没有查来查去的浪费时间,因此runqueue里还会有一个nr_active的成员,它是用来统计当前OS里有多少个进程的,每次调度出去或者新增进来都会更新一下nr_active,只有当nr_active>0的时候才会去查。

总结:上述一整个过程用到的东西可以归结为一个叫优先级数组的结构体(如下)。

struct prio_array_t

{

nr_active;

bitmap5;

queue140;

};

两个典型问题:

第一个:所有进程优先级都是61,但是不断有60的进程来,那根据上文的描述,CPU去便利queue数组的时候不就只会去便利下标为60+40=100的那个队列吗,那优先级为61的队列永远都调用不到,这就产生了进程饥饿问题,因此我们的OS是分时系统,它会以较为公平的方式选择一个进程,一段时间内让所有的进程都得到CPU资源。

第二个:假设现在一个进程的PRI为80,我将它直接修改成81(不借助NI),那是不是就意味着要将该进程从120号队列移动到121号队列,这太扯了,成本很高。

基于上边两个典型问题,我们引出真正的runqueue,先看结构。

如上图所示,其实runqueue内部是有两个queue140的,上文也说了,nr_active,bitmap,queue140组成一个prio_array_t结构体,那我现在有两套这样的结构,因此其实就是一个struct prio_array_t array2数组,如上图所示,runqueue里还有两个指针,active和expire,一个叫活跃指针,一个叫过期指针,它俩的类型是struct prio_array_t*,分别指向array0,array1

struct prio_array_t* active = &array0;//活跃140队列

struct prio_array_t* expire = &array1;//过期140队列

CPU在选择进程的时候只会去活跃队列里去找,根据优先级选择一个进程调度,当其时间片到了之后不会将它放到原来活跃队列的位置,而是放到过期队列里(优先级映射数组下标的FIFO队列)的相应位置,当活跃队列里的进程都被调度过了之后就意味着活跃队列现在为空,一轮基于时间片的轮转调度已经结束,接下去swap(&avtive, &expire),active指向原来的过期队列,意思就是原来的过期队列现在变成活跃队列了,然后继续去选择调度里边的进程。

现在来回答上边两个问题,当有新进程要进来的时候,不考虑抢占,新进程是加入到过期队列里的,因为就算新进程的优先级很高,那也是跟后来的进程比,之前的进程早就在活跃队列里排队等着调度了,等到下一轮调度的时候,会重新划分优先级然后根据优先级重新调度,这就解决了明显的饥饿问题了。重新理解一遍,新进程会加入到过期队列,不会影响到活跃进程里该轮调度,其优先级会在下一轮调度的时候(active和expire交换之后)体现出来。还有一个问题是PRI直接从80改到81,直接修改会导致的问题就是需要改变进程在queue里的位置(此过程叫断链),因此会有NI值,当前的PRI我先不变,等到你这个进程被调度加入过期队列之后,我再根据NI值重新计算它的优先级,加入到队列里相应的位置。所以NI的值为什么是-20,19?这是跟调度算法有关的,因为负数可以让优先级变高,正数会让优先级变低,基于queue数组的下标100~139的我们关心的位置总共40个梯度,PRI的计算是基于默认值80的,80-20+40=100,80+19+40=139,正好对应queue数组100~139下标。

Linux的这种调度算法没有饥饿问题,因为所有的进程都会被调度到,只有活跃队列里的进程全部调用完了才会交换指针进入下一轮调度。

相关推荐
底层开发智库1 小时前
C1-Ultra FVP调试并运行Linux kernel全程记录,硬核演示如何解决启动问题
linux·arm开发·内核·嵌入式·arm
utf8mb4安全女神1 小时前
【forwarding】怎么把客户端的日志转发到服务器【日志转发】【rsyslog服务】
运维·服务器
承渊政道2 小时前
Linux系统学习【进程控制:进程创建、终止与等待、进程程序替换、自主shell命令行解释器详解】
linux·服务器·c++·学习·ubuntu·bash·远程工作
志起计算机编程2 小时前
挖掘单节点Clickhouse极致性能上限
服务器·开发语言·python
Kurisu5752 小时前
深度拆解:从 Linux 内核 Namespace 与 Cgroups 洞察容器技术的底层本质
java·linux·运维
llf_cloud2 小时前
docker compose滚动部署实践
运维·docker·容器
liulilittle2 小时前
Linux SS快速诊断命令
linux·运维·智能路由器
田里的水稻2 小时前
OE_ssh密钥_密钥种类和分别
运维·ssh
feng14562 小时前
OpenSREClaw - 从 AIOps 到 RDaaS
运维