万字详解Linux内核调度器极其妙用

Posted popsuper1982

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了万字详解Linux内核调度器极其妙用相关的知识,希望对你有一定的参考价值。

《趣谈Linux操作系统》专栏推出之后,很多读者问,这里面都是Linux基本原理的阐述和代码解读,在真正的工作中,有什么用呢?

这里我解析一下Linux内核调度器,以及使用它使得资源利用率翻倍的例子。

一、调度器原理到底困惑在哪里?

我们先从调度器的概念和原理入手。

Linux为什么需要调度器呢?说白了,就是CPU的数量是有限的,但是进程数目远远超过CPU的数目,因而就需要进行进程的调度,有效地分配CPU的时间,既要保证进程的最快响应,也要保证进程之间的公平。

这里听着简单,但是仔细想想其实挺复杂的,还特别的绕,假设有三个用户进程A,B,C,整个机器上只有一个CPU,假设进程切换的次序为A->B->C->A。

为了实现这个效果,内核里面要有一套数据结构来管理进程在CPU上的时间分配,还要有一套代码来实现调度的算法,把进程切来切去。

但是这个时候,你是否有很多困惑,A进程在CPU上运行的好好的,是谁把他切换下来的?如果是内核,那内核代码总要在CPU上运行,才能做“切换”这个动作吧,又是谁把A从CPU上拿下来,换成内核代码的呢?

另外,进程的切换是个很笼统的词,到底都切换些什么?直接从A运行到某行代码,直接跳到B进程的某行代码就算切换了么?其实没办法这样跳,A和B都不在一个进程空间里面。A先到内核再到B,这个看起来靠谱,但细想也有问题,A到内核这个容易,假设内核正在运行切换程序的某一行,怎么让内核运行着运行着,突然变成运行B的某一行呢?

要想不被这里的逻辑绕进去,告诉大家一个小技巧,就是改变视角。作为一个程序员,咱们平时写程序往往站在用户进程的视角。如果你写过驱动,你还会站在内核的视角。但是切换的过程,涉及A,B,C三个应用程序,还涉及到内核代码,站在任何一个视角研究他们之间的切换,都容易绕进去,相当于自己把自己举起来。所以可以试着切换到硬件CPU的视角,这样应用程序和内核,就都变成客体研究对象了,很多逻辑也就清晰了。

二、CPU的工作机制

当前CPU内部架构也是比较复杂的,我们不纠结细节,看一下他的运行逻辑。

以上只是个示意图,CPU里面有指令指针寄存器,指向的就是当前运行的指令的位置,把这里的指令读进CPU执行,如果需要取数据,则将数据到寄存器里面来,进行运算,运算的结果从寄存器写入内存。

反正一加电,CPU就按照这个模式运行下去,指令指针寄存器指向A进程的代码某一行,就运行A进程的逻辑,指向内核的代码某一行,就运行内核的逻辑,一直像这样运行下去。

当然CPU里面还有其他的寄存器,用于内存管理,进程切换等,我们后面会展开。

三、进程如何管,在内存中如何放

在内核中,进程是被维护在一个叫task_struct的数据结构里面,并放在一个进程列表中,如下图所示。无论是进程还是线程在内核里面都称为任务task。

这个列表有个头,是启动的时候创建的,struct task_struct init_task,通过他,可以找到所有的进程。

从CPU的角度来看,他怎么知道struct task_struct这个数据结构放在哪里呢?

他肯定放在物理内存的某个位置,但Linux很少允许用物理地址,而是使用虚拟地址,哪怕这里访问的是一个内核数据结构。

Linux的虚拟地址空间的地址从"全是0"到"全是1",很大很大,反正是虚的,不占实际的地方。

Linux将如此大的虚拟地址空间分为用户态地址空间和内核态地址空间,分别从两个视角看这片虚拟的区域。

从用户程序的视角,也就是咱们平时写程序的视角来看,访问的是用户态地址空间这一片,这部分地址不同的程序访问的地址都是重复的,只不过Linux要想办法把这部分各个程序都重复的空间对应到物理内存不同的地方,当然不用都对应,哪里有数据对应哪里,毕竟物理空间没有这么大的地方。

用户态地址空间里面有一些的数据:

  • 代码段,全局变量等;

  • 函数栈;

  • 堆;

  • 内存映射区

而另一片内核地址空间,如图中是虚线,在用户程序的视角来看,只是看起来有,就是访问不到。

如果想访问内核态地址空间,就需要从用户态通过例如系统调用进入内核,进入内核后,视角就变成了内核视角,在内核视角,这片地址空间只有一份,无论从哪个进程进入的内核态,进来后访问的是同一份,对应的也是物理内存中同一块空间。

在内核视角看起来,用户态的那部分地址空间其实没多少意义,因为每个进程都有这么一块空间,而且还是虚的。

内核态地址空间包含以下数据:

  • 内核的代码、全局变量等;

  • 内核数据结构;

  • 内核栈;

  • 内核中动态分配的内存。

而刚才讲的task_struct结构,就放在内核态地址空间里面的数据结构区域。

讲完了用户态视角和内核态视角,我们切回CPU视角,对于CPU这个物理硬件来讲,用户态地址空间和内核态地址空间都太“虚”,必须要变成物理的才好访问,这个映射的过程称为页表映射。

因为每个进程都有自己的用户态虚拟地址空间,因而每个进程都有自己的页表,用于将用户态的地址映射为物理地址。每个进程的页表的根,放在task_struct结构中。

内核有统一的一个内核虚拟地址空间,因而内核也应该有个页表,用于将内核态的地址映射为物理地址。内核的页表的根放在一个预设的地方,可以直接映射到物理地址。

在处理器内部,有一个控制寄存器叫 CR3,存放着页目录的物理地址,故 CR3 又叫做页目录基址寄存器(Page Directory Base Register,PDBR)。

如果当前进程A运行在用户态,则从task_struct里面找到页表顶级目录,加载到CR3里面去,则程序里面访问的虚拟地址就通过CPU指向的页表转换成物理地址进行访问。

然而内核里面访问task_struct使用的也是虚拟地址,因而进程进入内核后,CR3要变成指向内核页表的顶级目录,内核程序访问内核数据结构的虚拟地址就是用CPU指向的内核页表转换成物理地址进行访问的。

内核页表的根是内存初始化的时候预设在一个虚拟地址和物理地址。

解释了这大半天,我们才大概弄明白,从CPU的视角,是如何访问task_struct结构的。

四、进程调度相关数据结构

接下来我们来看task_struct结构里面,与调度有关的变量。

首先与调度有关的变量是policy——调度策略。策略分为两大类:

  • 实时调度策略:SCHED_FIFO、SCHED_RR、SCHED_DEADLINE是实时进程的调度策略,优先级比较高

  • 普通调度策略:SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE,优先级比较低。

policy只是一个变量,而调度的时候执行的代码是放在sched_class里面的:

  • rt_sched_class就对应实时进程的调度策略

  • fair_sched_class就对应普通进程的调度策略;

  • idle_sched_class就对应空闲进程的调度策略。

这里对于普通进程的调度类,有fair的字眼,这是基于最常用的CFS,

Completely Fair Scheduling,叫完全公平调度。我们后面重点看这种调度算法。

task_struct里面的成员变量执行哪种sched_class,就会按照哪种方式被调度。

虽然每个进程都有一个task_struct,但是所有的task_struct的sched_class变量指向的都是同一个或者rt_sched_class,或者fair_sched_class,或者idle_sched_class。

这三个sched_class内核中只有一份,只包含调度逻辑,是中立的,不属于任何进程的,因而你在看代码的时候要意识到,一旦进了某个sched_class的代码逻辑,就要脱离某个进程的视角,进入内核的视角了。

另外一个和调度有关的task_struct里面的成员变量是sched_entity。有

实时调度实体sched_rt_entity,也有完全公平算法调度实体sched_entity。

在Linux内核中,为每一个CPU都创建一个队列来保存可以在这个CPU上运行的任务,用struct rq来表示,这里面包括一个实时进程队列rt_rq和一个CFS运行队列cfs_rq ,task_struct就是用sched_entity这个成员变量将自己挂载到某个CPU的队列上的。

sched_entity里面有一个重要的变量vruntime,对于完全公平调度算法,需要记录下进程的运行时间。CPU会提供一个时钟,过一段时间就触发一个时钟中断。就像咱们的表滴答一下,这个我们叫Tick。CFS会为每一个进程安排一个虚拟运行时间vruntime。如果一个进程在运行,随着时间的增长,也就是一个个tick的到来,进程的vruntime将不断增大。没有得到执行的进程vruntime不变。

 

显然,那些vruntime少的,原来受到了不公平的对待,需要给它补上,所以会优先运行这样的进程。

 

这有点像让你把一筐球平均分到N个口袋里面,你看着哪个少,就多放一些;哪个多了,就先不放。这样经过多轮,虽然不能保证球完全一样多,但是也差不多公平。

因而CFS运行队列需要能够对vruntime进行排序,找出最小的那个。这个能够排序的数据结构不但需要查询的时候,能够快速找到最小的,更新的时候也需要能够快速地调整排序,要知道vruntime可是经常在变的,变了再插入这个数据结构,就需要重新排序。

 

能够平衡查询和更新速度的是树,在这里使用的是红黑树。

CFS的运行队列相应的数据结构如图所示。

五、新创建的进程是如何运行起来的

当一个进程创建的时候,就会分配task_struct结构,就会根据程序员设置的调度策略设置sched_class变量,默认指向fair_sched_class。

进程创建后的一件重要的事情,就是调用sched_class的enqueue_task方法,将这个进程放进某个CPU的队列上来,虽然不一定马上运行,但是说明可以在这个CPU上被调度上去运行了。

在这里sched_class的代码已经被调用了,其实调度已经开始了。这里从CPU的视角来看,指令指针寄存器里面指向的还是sched_class里面的代码,也即内核的代码只是对内存中的task_struct及sched_entity进行操作而已,进程的代码并没有运行。

在sched_class的enqueue_task方法将进程放入CPU队列后,会调用一个方法check_preempt_curr,来试图去抢占当前的进程去运行。当前进程是谁?在Linux里面,进程都是由父进程fork的,这里的当前进程是父进程,然而这个时候父进程并没有运行在CPU上,因为这个时候CPU里面运行的还是内核代码,这里的当前进程也即父进程是目前队列里面排名最靠前的进程,而这里的抢占的意思是子进程试图排在父进程的前面,但是也不是马上就放到CPU上运行,而是仅仅将父进程设置了一个标志位TIF_NEED_RESCHED,表示应该被调度走。什么时候真的被调度走呢?父进程创建子进程是调用fork系统调用进的内核,当创建完毕后,从fork系统调用返回用户态的时候,发现了这个标志位TIF_NEED_RESCHED,才主动让给子进程运行的。

那如何让子进程在用户态运行起来呢?这件事情其实不简单,到现在为止,我们讲的还都是CPU如何执行内核代码来对进程相关数据结构,也即对内存上的数据做修改。要想让用户态程序真的运行,按照CPU的原理,应该CR3指向这个进程的页表,虚拟内存及对应的物理内存上有代码,有数据,然后指令指针寄存器指向程序的某行代码,这样才可以。

那用户进程的这些信息是如何被设置到相应的寄存器,虚拟内存,物理内存的呢?这和task_struct的另一个成员变量stack指向的内核栈有关。

一提栈,学过程序设计的同学就能和函数调用联系起来,函数的调用过程,A调用B,调用C,调用D,然后返回C,返回B,返回A,这是一个后进先出的过程。有没有觉得这个过程很熟悉?没错,咱们数据结构里的栈,也是后进先出的,所以用栈保存这些最合适。

栈是一个从高地址到低地址,往下增长的结构,也就是上面是栈底,下面是栈顶,入栈和出栈的操作都是从下面的栈顶开始的。

在CPU里,SP(Stack Pointer)是栈顶指针寄存器,入栈操作Push和出栈操作Pop指令,会自动调整SP的值。另外有一个寄存器BP(Base Pointer),是栈基地址指针寄存器,指向当前栈帧的最底部。

在用户态,如果A调用B,A的栈里面包含A函数的局部变量,然后是调用B的时候要传给它的参数,然后返回A的地址也应该入栈,这就形成了A的栈帧。接下来就是B的栈帧部分了,先保存的是A的栈帧的栈底的位置,也就是EBP。因为在B函数里面获取A传进来的参数,就是通过这个指针获取的,接下来保存的是B的局部变量等。

 

当B返回的时候,返回值会保存在EAX寄存器中,从栈中弹出返回地址,将指令跳转回去,参数也从栈中弹出,然后继续执行A。

以上的栈操作,都是在进程的内存空间里面进行的。

如果程序通过系统调用,从进程的内存空间到内核中了。内核中也有各种各样的函数调用来调用去的,也需要这样一个机制,这该怎么办呢?于是上面的成员变量stack,也就是内核栈,就派上了用场。

在内核栈的最高地址端,存放的是一个结构pt_regs,这个结构的作用是:当系统调用从用户态到内核态的时候,首先要做的第一件事情,就是将用户态运行过程中的CPU上下文也即各种寄存器保存在这个结构里。这里两个重要的两个寄存器SP和IP,SP里面是用户态程序运行到的栈顶位置,IP里面是用户态程序运行到的某行代码。

这样当从内核系统调用返回的时候,才能让进程在刚才的地方接着运行下去。

所以在内核填充pt_regs,然后从系统调用返回用户态,是让用户态程序运行的一种方式。

整个Linux系统的第一个用户态进程就是这样运行起来的。Linux系统启动的时候,先初始化的肯定是内核,当内核初始化结束了,会创建第一个用户态进程,1号进程。创建的方式是在内核态运行do_execve,来运行"/sbin/init","/etc/init","/bin/init","/bin/sh"中的一个,不同的Linux版本不同。

写过Linux程序的我们都知道,execve是一个系统调用,它的作用是运行一个执行文件。加一个do_的往往是内核系统调用的实现。

在do_execve中,会有一步是设置struct pt_regs,主要设置的是ip和sp,指向第一个进程的起始位置,这样调用iret就可以从系统调用中返回。这个时候会从pt_regs恢复寄存器。指令指针寄存器IP恢复了,指向用户态下一个要执行的语句。函数栈指针SP也被恢复了,指向用户态函数栈的栈顶。所以,下一条指令,就从用户态开始运行了。

接下来所有的用户进程都是这个1号进程的徒子徒孙了。如果要创建新进程,是某个用户态进程调用fork,fork是系统调用会调用到内核,在内核中子进程会复制父进程的几乎一切,包括task_struct,内存空间等。这里注意的是,fork作为一个系统调用,是将用户态的当前运行状态放在pt_regs里面了,IP和SP指向的就是fork这个函数,然后就进内核了。

子进程创建完了,如果像前面讲过的check_preempt_curr里面成功抢占了父进程,则父进程会被标记TIF_NEED_RESCHED,则在fork返回的时候,会检查这个标记,会将CPU主动让给子进程,然后让子进程从内核返回用户态,因为子进程是完全复制的父进程,因而返回用户态的时候,仍然在fork的位置,当然父进程将来返回的时候,也会在fork的位置,只不过返回值不一样,等于0说明是子进程。

六、运行起来的进程如何主动调度走

现在你可能会问,让是什么个让法?接下来我们就来解析这个过程。

进程调度分主动调度和抢占调度。

所谓的主动调度,就是A进程运行着运行着,里面有一条指令sleep,或者等待某个I/O事件,就需要主动让出CPU,让其他的进程运行。

所谓的抢占调度,就是A进程运行的时间太长了,会被其他进程抢占。还有一种情况是,有一个进程B原来等待某个I/O事件,等待到了被唤醒,发现比当前正在CPU上运行的进行优先级高,于是进行抢占。

我们先来看主动调度的场景。

我们假设A进行正在用户态运行,函数的调用过程假设如下,A_main->A_Fun->read(),这个调用过程自然是被保存在用户态的栈里面的。

read()是一个系统调度,会进入内核,进入内核的那个时刻,用户态的栈顶指针SP和指令指针IP都会被保存在pt_regs里面,放到A进行对应的task_struct的stack变量指向的内核栈里面。

在内核中,仍然会进行函数调用过程假设如下,do_read()->A_Kernel1->A_Kernel2,这个调用过程自然是被保存在内核栈里面,这个栈也有一个栈顶指针SP,这个时候的IP指向的是A_Kernel2里面的内核代码。

在内核里面的函数A_Kernel2中,发现要读的东西还没就绪,就需要进行等待,如果你搜索内核代码,你能看的如下类似的样子:

/* Nothing to read, let's sleep */
schedule();

调用了schedule()函数,这是主动调度的开始。

这里请注意,虽然是A_Kernel2调用的schedule()函数,这个调用操作仍然保存在内核栈中,SP和IP也都会指向schedule()函数,但是代码逻辑已经进入内核调度逻辑了,要注意切换到中立的视角,而非进程A的视角。

在schedule()函数里面会完成以下的事情:

  • 在当前的CPU上,我们取出任务队列rq

  • task_struct *prev指向这个CPU的任务队列上面正在运行的进程curr,其实是A。为啥是prev?因为A一旦被切换下来,它就成了前任了。从这里可以看出,视角要换成中立的视角,已经不把A当做当前进程了。要不然容易晕。

  • fair_sched_class.pick_next_task取下一个任务,其实是从红黑树里面找出当前vruntime最小的,最应该运行的任务,task_struct *next指向这个任务,这就是继任,假设为进程B。

  • 当选出的继任者和前任不同,则进行上下文切换,上下文切换是在context_switch里面实现的。

七、容易晕的进程上下文切换

接下来context_switch有点难懂,需要切换到CPU的视角才容易理解一些。

context_switch里面有下面两行代码

  switch_to(prev, next, prev);
  return finish_task_switch(prev);

当内核运行到switch_to这一行的时候,IP指向这一行,SP指向的还是A进程的内核栈。而一切都在switch_to里面发生了变化。

在task_struct里面,还有一个成员变量struct thread_struct thread,里面保存了进程切换时的寄存器的值。在switch_to里面,A进程的SP就保存进A进程的task_struct的thread结构中,而B进程的SP就从他的task_struct的thread结构中取出来,加载到CPU的SP栈顶寄存器里面。

在switch_to里面,还将这个CPU的current_task指向B进程的task_struct。

至此,进程上下文的切换就结束了,不信咱们来屡一下。

对于A进程来讲,SP暂时停在了switch_to这一行,并被保存进了task_struct结构,如果SP拿出来,我们就能通过task_struct里面的stack指向的内核栈,找到在内核的调用过程do_read()->A_Kernel1->A_Kernel2->schedule,再往前追溯,task_struct的pt_regs里面保存了A进程在用户态的SP和IP,通过他们可以还原A进程在用户态时的运行状态。也即将来A恢复运行的时候,是能够从当时的节点运行的。

对于B进程来讲,current_task指向了他的task_struct,也就能找到B进程的内核栈,而SP被拿出来,指向了内核栈的栈顶。在B进程的内核栈里面,能够找到当年B被调度走的那个时刻的在内核中的调用过程,我们假设是do_write()->B_Kernel1->B_Kernel2->schedule,为什么最后一层是调用schedule呢?因为B当年被调度走的时候,也是是运行的schedule函数。

我这里戏称进程调度第一定律——无论某个进程是如何被调度的,最终都会到达schedule函数。

到目前这个时刻,CPU里的指令指针寄存器IP其实没有被修改过,这是让人容易困惑的事情。不过马上你会明白,其实根本不需要修改。

当调用switch_to函数的时候,IP就随着函数的调用进入函数逻辑,当switch_to干完了上面的事情,返回的时候,IP就指向了下一行代码finish_task_switch。

下面请问,这里的finish_task_switch是B进程的finish_task_switch,还是A进程的finish_task_switch呢?其实是B进程的finish_task_switch,因为当年B进程就是运行到switch_to被切走的,所以现在回来,运行的是B进程的finish_task_switch。

其实从CPU的角度来看,这个时候还不区分A还是B的finish_task_switch,在CPU眼里,这就是内核的一行代码。

但是代码再往下运行,就能区分出来了,因为finish_task_switch结束,要从schedule函数返回了,那应该是返回到A_Kernel2呢,还是返回到B_Kernel2呢?根据函数调用栈的原理,栈顶指针指向哪行,就返回哪行,别忘了前面SP已经切换成为B进程的了,已经指向B的内核栈了,所以返回的是B_Kernel2。

可以看到,虽然指令指针寄存器IP还是一行一行代码的执行下去,不用做特意的切换,但由于有进程调度第一定律,所有的调度都会走schedule函数,而从schedule函数这里一进一出,IP没变,但是SP变了,进程切换就完成了。

接下来B进程还会返回B_Kernel1,返回do_write,进一步返回用户态,返回的时候,从B进程的pt_regs里面拿出B进程当年进内核的时候在用户态的IP和SP,返回用户态,假设当年B进程在用户态是这样调用的,B_main->B_Fun->write(),接下来,B在用户态就能在B_Fun里面接着运行下去。

一次主动切换终于完成了。

八、一个运行中的进程是如何被抢占的

接下来我们来看抢占调度。

最常见的现象就是一个进程执行时间太长了,是时候切换到另一个进程了。那怎么衡量一个进程的运行时间呢?在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统,时间又过去一个时钟周期,可以查看是否需要抢占的时间点。

 

时钟中断处理函数会调用scheduler_tick(),在这里面会更新新当前进程的vruntime,然后调用check_preempt_tick,检查是否是时候被抢占了。

当发现当前进程应该被抢占,不能直接把它踢下来,而是把它标记为应该被抢占TIF_NEED_RESCHED,要等待当前进程在某个时机可以运行schedule。

另外一个可能抢占的场景是当一个进程被唤醒的时候。

我们前面说过,当一个进程在等待一个I/O的时候,会主动放弃CPU。但是当I/O到来的时候,进程往往会被唤醒,就会调用了check_preempt_curr检查是否应该抢占当前进程。如果应该发生抢占,也不是直接踢走当前进程,而是将当前进程标记为应该被抢占TIF_NEED_RESCHED,同样等待前进程在某个时机可以运行schedule。

这都是进程调度第一定律在发挥作用。

那什么时候是真正抢占的时机呢?

对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机。

在exit_to_usermode_loop里面,当发现当前进程被标记TIF_NEED_RESCHED的时候,当前进程会调用schedule让出进程。

对内核态的执行中,被抢占的时机一般发生在preempt_enable()中。

 

在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调用preempt_disable()关闭抢占,当再次打开的时候,就是一次内核态代码被抢占的机会。

在preempt_enable()中,同样是当发现当前进程被标记为TIF_NEED_RESCHED的时候,当前进程会调用schedule让出进程。

在内核态也会遇到中断的情况,当中断返回的时候,返回的仍然是内核态。这个时候也是一个执行抢占的时机,从中断返回内核的,调用的是preempt_schedule_irq,里面会在需要的时候调用schedule让出当前进程。

至此,内核的调度机制就讲完了。

九、一种可以让利用率翻倍的内核调度器

从这里的机制我们可以看出,CFS的抢占,其实是不那么暴力的抢占。

目前互联网企业都拥有海量的服务器,其中大部分只运行交互类延时敏感的在线业务,使CPU利用率非常低,造成资源的浪费(据统计全球服务器CPU利用率不足20%)。为提高服务器CPU的利用率,需要在运行在线业务的服务器上,混合部署一些高CPU消耗且延时不敏感的离线业务。

为了使得在离线互相不影响,需要在在线业务CPU使用率低的时候,可以运行一些离线业务,而当在线业务CPU利用率高的时候,可以对离线业务快速抢占。

如果在离线业务都使用不暴力的CFS调度器,现有的混部方案没办法做到及时抢占的。在同调度类优先级的进程,互相抢占的时候,需要满足两个条件。第一个是抢占进程的虚拟时间要小于被抢占进程,第二是被抢占进程已经运行的实际要大于最小运行时间。如果两个条件不能同时满足,就会导致无法抢占。

基于这种考虑,开发了针对离线业务的新调度算法bt,该算法可以保证在线业务优先运行。

如果你理解了上述的内核调度算法,添加一个新的sched_class也比较容易理解了,具体的代码实现可以参考https://github.com/Tencent/TencentOS-kernel。

在这里面,有一些实验数据,模块b是一个翻译模块,对时延很敏感,原本b模块是不能混部的,业务尝试过混部,但是因为离线混部上去之后对模块b的影响很大,时延变长,所以一直不能混部。使用我们的方案的效果如下图所示,整机CPU利用率从20%提升至50%,并且对模块没有影响,时延基本上没有变化。

看是不是学习操作系统还是很有用哒。如果大家感兴趣这种混部模式,我可以专门写文章来解析一下。

十、欢迎订阅操作系统专栏

最后做个广告,上面的原理解析图片全部来自我的《趣谈Linux操作系统》专栏。

这个专栏能帮你轻松有趣地系统学习操作系统,现在已经有3万人加入学习了。

这个专栏用「学习路径 + 源代分析 + 实战」,和“像小说一样”的“趣谈”形式,带你一步一个台阶,轻松掌握 Linux 操作系统。

扫码免费试读????

新人仅需 ¥69.9

我也是极客时间《趣谈网络协议》的作者,专栏有超过 5 万人订阅,内容“像小说一样”通俗易懂。

《趣谈Linux操作系统》这个专栏延续了之前的风格,让原本晦涩难懂的底层知识,变得生动有趣,简单易学。

下面是一些同学的评价,随手截图了几个给你参考:

Linux 操作系统,其实没有想象中那么难,方法已经给到你了,坚持把这个专栏啃下来,基本就可以理解的很透彻了。

最后,再提醒一下,专栏限时特惠

新人 3.5 折,只需 ¥69.9 !

老用户拼团+口令「Linux2021」,到手¥114 

扫码免费试读????

一次订阅,永久有效

点击「阅读原文」,轻松搞定 Linux!

以上是关于万字详解Linux内核调度器极其妙用的主要内容,如果未能解决你的问题,请参考以下文章

万字长文,锤它!揭秘Linux进程调度器

别偷看!Linux系统乾坤大挪移 三式够用- 详解日志管理妙用

深入理解Linux内核之主调度器(上)

Linux内核线程kernel thread详解--Linux进程的管理与调度

Linux内核线程kernel thread详解--Linux进程的管理与调度

Linux(内核剖析):08---进程调度之Linux调度算法(调度器类公平调度(CFS))