进程调度:深入Linux内核架构读书笔记

最近在读深入Linux内核架构这本书,他通过部分linux内核源码,介绍了linux内核的实现逻辑,这本书还是不错的,推荐大家读一读,不过里面虽然精简了大部分的代码,但是还是有不少的代码量,读起来还是有点烧脑的。 这里就通过文字描述的部分,精简汇总下Linux进行进程调度的流程,让大家对Linux进程调度有个更直观的认识。

进程

什么是进程,简单的说,他是一个 正在执行中的程序,他用友自己独立的地址空间,内存,数据栈以及其他资源。 可以这么理解,程序是静态的文件,而进程就是程序的一次执行过程,为什么说是一次,因为可以启动多个进程执行相同的程序。

操作系统上跑的有两种进程,用户进程和内核进程,用户进程就是用户启动的进程,内核进程可以理解为操作系统启动的程序,权利会比较高,如果在linux上使用ps 查看进程,使用[]包起来的就是内核进程,如 [kthreadd] 。

核心态和用户态

为了确保系统的安全性,内核将内存的虚拟地址空间分为了两部分,分为核心态和用户态,用户启动的程序都处于用户态,无法访问到核心态的数据,也不能读取和操作内核空间中的代码,只有内核线程才能进入核心态并读取数据,这样就保证了系统的安全。那如果用户的程序确实需要使用系统的功能呢?那就可以通过系统调用的方式向内核发起请求,让内核帮忙执行系统功能,执行完成后再切换回用户态的程序。

还有一种从内核态切换到核心态的方式,那就是中断。系统调用是由用户程序主动发起的,而中断则不一样,是由硬件或者内核发起的,是不可预测的,比如你的程序正在执行,突然有个网卡接收到数据并处理好了,他就会触发一个中断,让内核快点处理他的数据,不然硬件那边可能会有问题,此时用户进程就会被中断,让出cpu给内核执行中断程序。

抢占

这里就涉及到一个抢占的概念,比如上面说的,你的用户进程被中断给抢占了,cpu让给内核执行中断代码了。不同的进程拥有不同的优先级,因此被抢占的概率也不一样。

  1. 用户普通进程总是可能被抢占的
  2. 如果系统处于核心态并正在处理系统调用,一般是无法被抢占的,除非出现了中断。不过内核进程有可能在一些不能被中断的地方加了屏蔽中断的代码,避免出现错误,等内核执行完了必要部分再执行中断代码。
  3. 中断具有最高优先级,可以暂停处于用户态和核心态的进程,因为在中断触发后必须尽快处理
进程创建

进程的创建一般可以通过fork和exec系统调用,fork会生成当前进程的一个副本,即生成了一个和父进程共享数据的子进程,而exec则是从一个可执行的二进制文件加载另一个应用程序来代替当前运行的程序,换句话说,加载了一个新程序。

通过fork调用生成的子进程和父进程拥有相同的数据,如果每次调用都将内存数据拷贝一份,那性能就会比较差,内核采用了一种写时复制的方式,fork出来的子进程和父进程共享只读的内存空间,只要有一个进程试图写入数据,就会触发缺页异常,然后进行复制数据用于写入。这样只有在确实需要写入数据时才进行内存拷贝,提高了性能。

命名空间

内核中有命名空间的这个概念,是为了进行隔离,比如一台电脑上有多个用户在使用,就可以创建不同的命名空间,这样其他用户就看不见机器上有别的用户程序在执行,好像独占了这台电脑一样。不同的命名空间会进行许多资源的隔离,比如程序的pid隔离,文件系统隔离等,这个就不详细说了,现在的容器也是基于命名空间实现了其部分的隔离机制。

进程的pid,是每个进程在操作系统中的唯一id,每个进程的id不能重复,内核是通过位图来实现唯一id的生成的,位图是使用一个比较长的内存空间,这段空间中的每一位都代表一个数字,如果这个数字被使用了,就置为1,没被使用,就是0,内核可以通过检查最近的一个非0的位,来给程序找一个唯一的未使用的进程id。

每个系统都有个pid是1的进程,他是操作系统第一个启动的进程,如果你使用过容器,会发现每个容器内也有个pid是1的进程,而在主机上,这个进程的pid又是别的数字,这个是什么原因呢? 其实这就和之前介绍的命名空间有关系,进程的pid是在指定的命名空间中唯一,而命名空间也有层级关系。

比如在操作系统上启动了一个容器,这个容器的进程和操作系统上启动的其实是同一个,只不过操作系统在启动容器时生成了一个子命名空间,然后在这个子命名空间中给进程分配了一个0的pid。 当你在主机上查询ps时,你查看到的是主机上命名空间中进程的pid,当你进入容器时,其实也是进入了对应的子命名空间,使用ps查看到的就是子命名空间中的程序的pid,也就是0。

进程调度

一般在操作系统上会同时运行多个进程,进程的数量比实际的物理cpu内核数要多,但是你发现这些进程总是同时运行的,这是什么原因呢? 其实这就是内核通过进程调度,在一定时间内,比如一秒,把每个进程的代码都跑一下,这样看起来就好像是所有进程都在同时运行,这叫并发执行,其实是内核通过调度器把cpu不断的分配给各个进程执行而造成的幻象。而内核是怎么将cpu分配给不同的进程执行的?这就涉及到了调度器

调度器

调度器负责将cpu分配给不同的进程执行,他会在两种情况下被激活,然后进行进程调度。

  1. 当前执行中的进程打算进入睡眠,或者出于其他原因主动放弃cpu,这时当进程执行让出cpu的系统调用,就会进入内核态,执行调度器的逻辑进行调度
  2. 另一种是通过周期性的机制,以固定频率运行,不时检测是否有必要进行进程切换。比如硬件定时会触发一个中断,抢占普通程序,让cpu执行调度器的逻辑进行调度。
进程生命周期

进程不总是可以运行的,有可能他在等待事件。比如一个服务端程序,在没有请求进来时,他可能无事可做,这时候如果调度器把cpu让给这个进程执行,他也做不了事情,那在调度器被下一次的中断拉起前,cpu都空闲着,资源被浪费了。因此调度器需要知道进程的状态,将cpu分配到无事可做的进程是没有意义的。

这里就产生了进程的生命周期,用于调度器针对不同生命周期的进程执行不同的操作。生命周期有:

  1. 运行,这时进程处于运行中
  2. 就绪,此时进程已经做好了准备,只要cpu被让给进程,就能开始执行有意义的工作,这种状态的进程会被放到就绪队列中,调度器也是从就绪队列中挑进程进行调度执行的。
  3. 阻塞,此时进程在等待一个事件,从而自愿陷入了这个状态,此时给进程分配cpu是没有意义的,因为他等待的事件到来之前,他无法正常的工作,此时进程不会被调度。
  4. 终止,进程执行完毕或者被终止了,就会进入此状态。
调度的公平性

在调度器执行调度逻辑时,他需要确保调度的公平性,避免某些进程长时间得不到执行。这样就需要一个算法来确保调度的公平性,但是应该怎么判断是否公平呢?

我们可以这样做,在进程刚被调度时记录一个时间,当进程调度完毕,调度器准备把cpu给下个进程时,再记录个时间,这两个时间的差值就是进程的执行时间,我们可以把进程的所有执行时间加起来并记录成cpu执行总时间,作为调度的依据。

因为调度的不公平性可以通过cpu执行总时间看出来,要是有的进程的cpu执行总时间特别小,那就说明他收到了不公平的待遇,那下次调度就应该优先考虑此进程,那么,我们只要每次调度都找cpu执行总时间 最小的那个进程,不就可以尽量保证调度的公平性了么。

而调度器是这样实现的,他在就绪队列的数据结构上增加了一个红黑树,每个节点对应一个待调度的进程,他将所有进程的累计cpu执行时间(后续称为vruntime)中 最小的那个值记录为min_vruntime,作为一个就绪队列的属性存储。因为cpu使用时间总是会增加的,所以这个值也总是递增的。 然后他又会将 进程的vruntime - min_vruntime 作为进程在红黑树中的key,这样,运行时间最短的进程就会被排到红黑树的最左边,调度器只要每次都调度红黑树最左边的进程就行了

红黑树是一种树状的结构,他是根据节点的key进行排序,最小的节点会被排到最左下角的地方,有兴趣可以自己深入了解下

通过这种方式,内核实现了两种机制:

  1. 在进程运行时,其vruntime总是增加的,因此他在红黑树中总是向右移动,运行的越久的进程,总是越晚被调度,进程运行的越久,其vruntime - min_vruntime的值就越大,越晚被调度。
  2. 如果进程进入睡眠,则其vruntime保持不变,因为就绪队列的min_vruntime总是单调递增的,因此vruntime - min_vruntime的值就会越来越小,因此在进程醒来后,在红黑树的位置就会更靠左,更容易被调度。
优先级

进程是有优先级的,优先级越高的进程就会被越优先调度,这样可以保证重要的任务优先得到执行,但是应该怎么控制优先级和公平性的平衡呢?

首先,进程被分为了几种类型,有实时进程,普通进程和批量执行进程,其中实时进程优先级最高,但是我们平时一般用不到,普通进程是日常最常用的,可以通过nice系统调用修改其优先级,而批量执行进程是优先级最低的。

内核为了实现不同进程的优先级方案,是针对不同类型的进程实现了不同的调度类,用于使用不同的算法来判断接下来运行哪个进程,目前的调度策略有这些:

  1. 实时调度类,用于调度实时进程,会被内核优先执行,因此实时进程的优先级总是高于普通进程的。
  2. 完全公平调度类,用于调度普通进程,优先级在实时进程之后
  3. 在无事可做时调度空闲进程
完全公平调度类的优先级实现方案

系统是通过一个数字来定义进程的优先级的,其中0-99给实时进程保留,100-139留给了普通进程,内核会将nice值的-20到19 映射到100-139,比如nice值为0的进程的全局优先级就是120,从这里也可以看出,实时进程总是优先于普通进程的。

为了完成优先级的调度,完全公平类调度引入了一个虚拟时间的概念,之前用于进程调度的累计cpu执行时间(vruntime)就会使用虚拟时间计算,那为什么要引入一个虚拟时间呢?直接用物理时间不行么?

调度器就是通过虚拟时间来实现优先级的调度的,他会根据进程的优先级,每个优先级的值对应一个比例值,将比例值乘以实际的物理时间,就得到了虚拟时间,这样他就可以通过优先级控制进程被调度的快慢了。

举个例子,nice值是0优先级会被作为一个标准,其虚拟时间是和物理时间完全一致,比例值为1,而优先级越高的,就会被授予一个较小的比例值,比如0.5,优先级低的,会被授予一个较大的比例值,比如1.5。 运行相同的物理时间t之后:

  1. nice值是0的进程,其虚拟时间就是 vruntime = t* 1 = t
  2. 高优先级的进程,其虚拟时间是 vruntime = 0.5*t = 0.5t
  3. 低优先级的进程,其虚拟时间是 vruntime = 1.5*t = 1.5t

之前说过,调度器优先调度vruntime最小的进程,因此在占用相同的cpu时间的情况下,就会优先调度 优先级高的进程,因为其vruntime值最低。

当然,上面只是一个简单的描述,实际上这个比例是经过精心计算的一个值。

同时,因为进程调度时也会出现上下文切换,这个行为是有系统开销的,为了避免频繁的进行上下文切换,内核设置了一个最低运行时间,也就是进程被调度后,最少会使用一个固定的cpu时间,避免被频繁切换,把宝贵的cpu时间都浪费在无用的上下文切换上了。

当然,也有一个对应的最大进程运行时间,避免其他进程无法被调度执行。

结语

以上就是本次的进程调度相关的内容了,希望对读者有所帮助。

相关推荐
代码扳手2 小时前
Go 微服务数据库实现全解析:读写分离、缓存防护与生产级优化实战
数据库·后端·go
Charlie_Byte2 小时前
Netty + Sa-Token 实现 WebSocket 握手认证
java·后端
多云的夏天2 小时前
SpringBoot3+Vue3基础框架(1)-springboot+对接数据库表登录
数据库·spring boot·后端
shoubepatien2 小时前
JAVA -- 12
java·后端·intellij-idea
木木一直在哭泣3 小时前
Spring 里的过滤器(Filter)和拦截器(Interceptor)到底啥区别?
后端
源码获取_wx:Fegn08953 小时前
基于springboot + vue物业管理系统
java·开发语言·vue.js·spring boot·后端·spring·课程设计
無量3 小时前
MySQL事务与锁机制深度剖析
后端·mysql
無量3 小时前
MySQL索引设计与优化实战
后端·mysql
木木一直在哭泣3 小时前
CAS 一篇讲清:原理、Java 用法,以及线上可用的订单状态机幂等方案
后端