• Linux内核设计与实现(二)| 进程调度


    进程调度

    • 概述

    进程调度的执行为调度程序,该程序负责调用那个进程投入运行,何时运行以及运行多长时间等,调度程序可看作为可运行态进程之间分配优先处理器时间资源的内核子系统,只有通过调度程序的合理调用才会最大限度的发挥作用,多进程才有并发执行的效果

    1.多任务

    • 概述

    在多任务OS的背景下,对于单处理器可以造成多个进程在同时运行的假象;而对于多处理器则会使多个任务真正并行的处理任务;在对多任务OS中一般都能使多个进程处于阻塞或休眠状态,对于内核来说这些进程都在内存中但并不处于可运行状态;对于进程本身利用这个特性等到某一事件的发生

    • 多任务系统的划分
    • 非抢占式多任务:这个模式下除非进程自己主动停止,要不然会一直执行,主动停止的工作称为让步,理想很美好,但是实际上有很多缺点(无法估计执行时间、不主动让出使其奔溃等)
    • 抢占式多任务:所谓抢占式就是调度程序决定一个进程何时停止,以便其他进程得到执行事件,这个强制挂起的动作称为抢占,在进程被强占之前能够运行的时间是事前设置好的,称为时间片

    时间片

    时间片实际上就是分配给每个进程可执行的时间段,有效的分配和管理可以避免很多不利,现在大多采用动态时间片分配的计算方式,不过Linux的进程调度本身并没有采用时间片来达到公平调度

    2.Linux的进程调度

    • 概述

    在2.5版本后引入O1调度程序的新调度程序(因为其算法行为而得名),但由于在交互式程序的左面系统运行不在,在2.6.23被完全公平调度算法(CFS)

    3.策略

    策略

    策略是指何时让什么进程运行,负责优化使用处理器的时间

    3.1 IO消耗型和处理器消耗型的进程

    • IO消耗型

    一般是指进程多部分时间用来提交IO请求或等待IO请求,所以这种进程常处于可运行态,但时间很短,因为IO请求处理完总会阻塞,例如鼠标和键盘的交互

    • 处理器消耗型

    就是进程将时间大多用在执行代码上,除非被抢占要不一直在不停地运行,这些一般会被调度程序降低调度频率,延长运行时间

    Linux的策略

    调度程序需要在进程响应速度(响应时间段)和最大系统利用率(吞吐高)做个平衡,Linux中为了保证交互式应用和桌面系统的性能的性能,所以对进程的响应做了优化(缩短响应时间),更倾向于优先调度IO消耗型进程。虽然如此,但在下面你会看到,调度程序也并未忽略处理器消耗型的进程。

    3.2 进程优先级

    • 概述

    进程优先级是最基本的一类优先级调度,通常就是高先执行,低后执行,相同轮转执行,还有些优先极高给的时间片也长

    Linux的优先级范围

    • nice值:

    范围是从-20到+19,默认为0,高的nice值为低优先级,相反低的为高优先级,时间片的比例有nice值决定
    在这里插入图片描述

    • 实时优先级:

    默认为0~99(闭区间可配置)。与nice值相反含义,越大则优先级越高;任何实时优先级都高于普通的进程,与nice值互不相交的两个范畴

    3.3 时间片

    • 概述

    我们上面介绍过时间片,现在说太短太长怎么办,太短会频繁上下文切换,太长又会觉得交互能力差没有并发效果;而对于IO密集型需要长的时间片,处理器密集型有需要很长的时间片(可以让cache的命中率更高)

    • Linux中的分配

    在Linux中CFS调度器并没有直接分配时间片和进程,它是将处理器的使用比例划分给了进程,进程所获取的处理器时间是和系统负载密切相关的。同时会受到nice值的影响

    在一般OS中,是否将进程立刻投入使用是看优先级和是否拥有时间片决定的,而在Linux中抢占时机取决于新的可运行程序消耗了多少处理器使用比,这个值比当前的进程小则会立刻投入运行,否则将推迟

    3.4 调度策略的活动

    • 场景描述

    有两个程序,一个位文本编译器和视频编码器,前者是IO密集型(等待用户的输入),后者是CPU密集型

    一般OS中的调度分配

    因为一般我们要做到高响应,用户希望按下键就得到响应,而对于视频编码器用户则不关心是立刻执行还是隔一会执行,说明执行时间并不需要多大关注,所以我们一般希望分配给编辑器的处理器事件要比编码器多,此时分给前者多并不是需要这么多,而是我们需要用户输入总能得到响应即总是能得到处理器时间,同时希望唤醒时能够抢占编码器

    Linux中

    在Linux中不会简单的分配这个多这个少,而是分配一个处理器使用比,在仅有两个程序,并且nice相同,那么处理器使用比各为50%,在唤醒文本编辑器时,实际使用的处理器时间肯定比我们分配的少之又少,那么CFS一看为了实现公平就让其进行抢占

    4.Linux调度算法

    4.1 调度器类

    • 概述

    Linux的调度器是以模块化方式提供的,针对不同的进程提供不同的调度算法,不同的模块称为调度器类,每个调度器都有一个优先级,通过遍历寻找优先级最高的去选择要执行的进程

    • CFS调度器类

    该调度器是针对于普通进程的调度类

    4.2 Unix的调度算法

    • 概述

    根据优先级和时间片的分配,在Unix的调度算法中优先级只会以nice值的形式输出到用户空间,这会导致很多问题

    1. nice值映射到时间片,在实际执行中与不同进程配合不佳,只根据nice值如果对于两个低优先级的进程,例如总时间10ms,两者各站5ms,我们要在10ms切换两次上下文;显然,我们看到这些时间片的分配方式并不很理想:它们是给定nice值到时间片映射与进程运行优先级混合的共同作用结果。事实上,给定高nice值(低优先级)的进程往往是后台进程,且多是计算密集型﹔而普通优先级的进程则更多是前台用户任务。所以这种时间片分配方式显然是和初衷背道而驰的。
    2. 如果两个nice值在处于中间的位置(0和1,前面说过分配的值就代表时间片的比例)相差不多,具体分配到的时间片也差别不多;但如果是靠后的nice值(18和19)前者会被分10ms,后者则是5ms相差两倍
    3. 设置映射时,需分配一个绝对时间片,这个绝对时间片需要跟随定时器的节拍不好调整
    4. 为了进程能够快速运行,可能利用后门调整优先级打破公平原则

    4.3 公平调度

    • 概述

    为了解决上面的问题,CFS不是分配给进程时间片而是分配给一个处理器的使用比重,举例来说,假如我们有两个运行进程,在标准Unix调度模型中,我们先运行其中一个5ms,然后再运行另一个5ms。但它们任何一个运行时都将占有100%的处理器。而在理想情况下,完美的多任务处理器模型应该是这样的:我们能在10ms内同时运行两个进程,它们各自使用处理器一半的能力。

    • CFS实现公平调度
    • 允许每个进程运行一段时间,循环轮转,选择运行最少的进程作为下一个运行进程
    • 不采用分配时间片的做法,通过可运行进程的数量计算出来一个进程运行多久
    • 绝对nice不被用来计算时间片了,而是代表进程获取处理器运行比的权重,越高的nice(低优先级)获取低的处理器使用权重,低的nice则相反
    • CFS设置一个目标延迟来完成近乎完美的多任务,例如目标延迟为20ms,2个任务在被抢占前可以执行10ms,4个任务则每个5ms,20个任务则每个1ms,当任务趋近于无穷每个的时间直接没了,为此CFS引入最低粒度(时间片底线),每个进程获取的时间片默认为1ms,所以最少也能运行1ms(但如果此时进程很多,你会发现无法突破最低限度,宏观上讲还是不公平的,不过可运行进程一般也就几百个,此时十分公平)
    • 实例

    我们看看CFS不采用绝对nice值,而是处理器使用比带来的效果现在让我们再来看看具有不同nice值的两个可运行进程的运行情况

    比如一个具有默认nice值(0),另一个具有的nice值是5。这些不同的nice值对应不同的权重,所以上述两个进程将获得不同的处理器使用比。在这个例子中,nice值是5的进程的权重将是默认nice进程的1/3。如果我们的目标延迟是20ms,那么这两个进程将分别获得 15ms和5ms的处理器时间。再比如我们的两个可运行进程的nice值分别是10和15,它们分配的时间片将是多少呢?还是15和5ms !可见,绝对的nice值不再影响调度决策:只有相对值才会影响处理器时间的分配比例。

    5.Linux调度实现

    • 概述

    上面说了实现原理,现在说说具体代码层面如何实现,我们需要关注四个组成部分:

    1. 时间记账
    2. 进程选择
    3. 调度器入口
    4. 睡眠和唤醒

    5.1 时间记账

    • 概述

    由于CFS放弃时间片的概念,所以他必须维护每个进程的运行时间,确保每个进程都是公平的

    • 调整器的实体结构

    该实体结构存放在每个进程描述符中

    struct sched_entity {
    	struct load_weight	load;
    	struct rb_node	run_node;
    	struct list_head	group_node;
    	unsigned int	on_rq;
    	u64	exec_start;
    	u64	sum_exec_runtime;
    	u64	vruntime;
    	u64	prev_sum_exec_runtime ;
    	u64	last_wakeup;
    	u64	avg_overlap;
    	u64	nr_migrations;
    	u64	start_runtime;
    	u64	avg wakeup;
    };
    /这里省略了很多统计变量,只有在设置了CONFIG_SCHEDSTATS时才启用这些变量*/
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 虚拟实时

    就是上面的vruntime变量,来记录程序到底运行了多长时间以及他还应该运行多长时间,并且知道下一个被运行的进程

    • 记账功能函数update_curr()

    update_curr()计算了当前进程的执行时间,并且将其存放在变量delta_exec中。然后它又将运行时间传递给了_update_curr(),由后者再根据当前可运行进程总数对运行时间进行加权计算。最终将上述的权重值与当前运行进程的 vruntime相加。

    5.2 进程选择

    • 概述

    前面说到没有正在的完美多任务处理器,那么每个进程的vruntime都不一样,前面还说过CFS会选择执行时间最短的优先执行,那么也就是选择vruntime最小的任务,CFS中用红黑树来维护可执行进程队列

    • 挑选下一个任务

    上面说到可运行的进程维护在红黑树中,其结点值就是可运行时间,假设我们已经有了这棵树,CFS选择最小的,位置在树的最左侧的叶子节点,执行这一过程的函数为_pick_next_entity(),但是一般不进行这个操作,因为这个最小值很特殊一般都直接缓存在一个名为rb_leftmost的字段中

    • 向树中添加进程

    CFS将最左叶子节点添加进缓存也发生在这个操作,将进程添加到树中发生在进程变为可运行态(被唤醒)或通过fork调用第一次创建时,执行添加操作的函数为enqueue_entity(),内部在调用真正添加的函数__enqueue_entity进行添加到红黑树,根据代码可知维护其树高进行颜色修复的函数式rb_insert_color

    /*把一个调度实体插入红黑树中*/
    static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se){
        struct rb_node **link = &cfs_rq->tasks_timeline.rb_node;
        struct rb_node *parent = NULL;
        struct sched_entity *entry;
        s64 key = entity_key(cfs_rq,se) ;
        int leftmost = 1;
        /*在红黑树中查找合适的位置*/
        while (*link){
            parent = *link;
            entry = rb_entry(parent, struct sched_entity, run_node) ;
            /*我们并不关心冲突。具有相同键值的节点呆在一起*/
            if (key < entity_key(cfs_rq,entry)){
                link - &parent->rb_left;
            }else {
                link = &parent->rb_right;
                leftmost = 0;
            }
        }
        /*维护一个缓存,其中存放树最左叶子节点(也就是最常使用的)*/
        if (leftmost)
            cfs_rq->rb_leftmost = &se->run_node;
    
        rb_link_node (&se->run_node,parent,link) ;
        rb_insert_color (&se->run_node,&cfs_rq->tasks_timeline) ;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 从树中删除进程

    CFS删除进程节点发生在进程堵塞(变成不可运行态)或终结时(结束运行),对应操作函数为degueue_entity如果被删除的节点是最左叶子节点,还会触发rb_next函数寻找新的节点并更新缓存

    //和给红黑树添加进程一样,实际工作是由辅助函数_dequeue_entity()完成的。
    static void _dequeue_entity(struct cfs_rq *cfs_rq,struct sched_entity *se)
    {
    	if (cfs_rq->rb_leftmost == &se->run_node){
    		struct rb_node *next_node;
    		
    		next_node = rb_next(&se->run_node);
    		cfs_rq->rb_leftmost = next_node;
    	}
    	rb erase (&se->run_node,&cfs_rq->tasks_timeline);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    5.3 调度器入口

    • 概述

    调度器的入口就是一个通用函数schedule函数,该函数在使用时会与最高优先级的调度类进行绑定,调度类要有自己的可运行队列,调度器就可以知道下一个该运行的进程,该函数主要就是调用pick_next_task()函数,让调度器类以优先级进行排序,从高到低,然后在最高优先级的调度类选择优先级最高的进程

    • 优化

    CFS是普通进程的调度类,系统运行的进程太多也是普通进程,那么可以进行一个简单的优化,前提就是所有可运行进程数量等于CFS类对应的可运行进程数(那么也就是说CFS可以运行所有进程),我们直接调用pick_next_task得到调度类就可以了

    • pick_next_task函数

    每个调度类都有一个pick_next_task函数,指向下一个可以运行的进程,直到返回NULL,该函数最终就会调用我们上一节讲的进程选择的函数_pick_next_entity()

    5.4 睡眠与唤醒

    • 睡眠概述

    休眠(被阻塞)的进程被不可执行态保护,进程一般休眠的原因是在等待一些事件(IO事件或硬件事件)或者尝试获取一个已经被占用的内核信号被迫进行睡眠;进入睡眠的进程会被从红黑树中移出去,放入等待队列,然后又会从调度器入口拿到下一个可运行态,那么就对被选中的进程做反向操作就好了

    进程的不可执行态有两种,分别为可中断与不可中断,不可中断会忽略信号,前者收到信号会被提前唤醒响应信号,两者都位于同一个等待队列

    等待队列

    等待队列是由等待某些事件发生的进程而组成的简单链表,内核表示为wake_queue_head_t,静态动态创建都可,为了避免产生竞争条件,睡眠与唤醒的实现需要是否小心,函数inotify read()就是等待队列的经典用法,负责从通知文件描述符中读取信息

    加入等待队列的步骤

    1. 创建队列节点
    2. 加入队列,等待条件满足后会被唤醒wake_up(),可以看到进入循环前如果条件已经达成是不会进入休眠状态的
    3. 将进程状态设置为不可中断或者可中断
    4. 如果设置为可中断,会检查并处理唤醒的信号
    5. 当进程被唤醒,再次检查唤醒条件是否为true,如果是退出循环,不是则继续调用schdule函数重复操作
    6. 条件满足后将自己的状态设置为可运行态,并移出等待队列
    /*'a’是我们希望休眠的等待队列*/
    DEFINE_WAIT (wait);
    add_wait_queue(q,&wait) ;
    while ( !condition) {/*'condition’是我们在等待的事件*/
    	prepare_to_wait (&q,&wait,TASK_INTERRUPTIBLE);
    	if(signal_pending (current))
    	/*处理信号*/
    	schedule() ;
    }
    firiish_wait(&q,&wait) ;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 唤醒概述

    唤醒的概述一般都用于通过函数wake_up执行,会唤醒指定的等待队列上的所有进程,调用其try_to_wake_up(),负责将进程设置可运行态,然后放入红黑树,如果被唤醒的进程优先级比当前执行的优先级高会被设置一个need resched标志,例如磁盘数据到了VFS(文件系统)就会唤醒等待数据的进程

    • 两者状态的转换图(一次调度)
      在这里插入图片描述

    6.抢占和上下文切换

    • 上下文切换

    上下文切换就是将一个可执行进程切换到另一个可执行进程中,该操作由context_switch()函数负责处理。每当一个新的进程被选出来准备投入运行的时候,schedule()就会调用此函数

    • context_switch所完成的工作:
    • 调用switch_mm(),将虚拟内存从上一个进程映射切换到新进程
    • 调用switch_to(),将上一个进程的处理器状态切换到新任务的处理器状态,这包括保存、恢复栈信息和寄存器信息,都必须以每个进程为对象进行管理和保存

    need resched标志

    前面我们说过执行一次schedule函数标志着一次调度,为了不让其对该函数的错误使用,内核使用need resched标志来表明是否需要重新执行一次调度,该标志对于内核来讲是一个信息,它表示有其他进程应当被运行了,要尽快调用调度程序。该标志被存放在thread_info结构体中

    • 某个进程应该被抢占时,scheduler_tick()就会设置这个标志﹔
    • 当一个优先级高的进程进入可执行状态的时候,try_to_wake_up(也会设置这个标志,内核检查该标志,确认其被设置,调用schedule()来切换到一个新的进程。

    need resched的相关函数
    在这里插入图片描述

    6.1 用户抢占

    • 概述

    内核返回用户线程时,会检查need_resched是否被设置,如果设置会导致schedule的调用,此时就发生了一次用户抢占,内核无论是从系统调用返回还是中断返回都会检查此标志位

    简而言之,用户抢占在以下情况时产生:

    • 从系统调返回用户空间时。
    • 从中断处理程序返回用户空间时。

    6.2 内核抢占

    • 概述

    在没有内核抢占时,调度程序没有办法在内核级的任务执行时进行重新调度,而内核抢占支持后,只要重新调度是安全的,内核就可以走任何时间抢占正在执行的任务,注意下面说的任务都是针对于内核级的任务

    如何判断重新调度是安全的

    只要没有持有锁内核就可以抢占,所以锁是非抢占区域的标识

    如果实现内核调度

    thread_info中存在记录锁的数量的字段preempt_count,使用锁+1,释放锁-1,为0则表明可抢占,所以从中断中返回内核空间就会检查need_resched和preempt_count 的值。

    • 如果need_resched被设置,并且 preempt_count为0的话,这说明有一个更为重要的任务需要执行并且可以安全地抢占,此时,调度程序就会被调用。
    • 如果preempt_count不为0,抢占不安全,此时内核会从中断处返回当前执行进程,如果使用的锁全部释放,此时锁释放的代码就会检查need_resched是否设置,然后调用调用程序等

    内核抢占发生的场景

    • 中断处理程序正在执行,且返回内核空间之前。
    • 内核代码再一次具有可抢占性的时候。
    • 如果内核中的任务显式地调用schedule()。

    此时你会想为什么调用schedule()也会发生内核抢占,如果是不安全的怎么办,其实只要显式发生schedule(),任务本身是知道自己会被安全的抢占,你可以理解为不安全的条件处理完后才会显式调用该函数

    • 如果内核中的任务阻塞(这同样也会导致调用schedule())。

    这种无序额外的判断来保证安全的抢占,因为内部是允许的

    7.实时调度策略

    • 概述

    Linux支持两种不同的实时调度:SCHED_FIFO和SCHED_RR都为静态优先级,不能实时计算优先级;普通的、非实时的为SCHED_NORMAL。这些调度策略并不被完全公平调度器来管理,而是有专门的来管理

    SCHED_FIFO

    该策略实现一种简单先进先出的调度算法,不使用时间片,会比处于SCHED_NORMAL态的进程先得到调度,只有比自己优先级更多的自身态或SCHED_RR态抢占或自己让出否则会一直执行下去

    SCHED_RR

    与上面大体相同,但是带有时间片,及时时间片耗尽也不能被低优先级抢占

    实时优先范围

    规定为0~99,SCHED_NORMAL级别的进程共享这个取值空间,但是不属于实时的,所以nice值(-20~+19)对应会从100~139的实时

    8.与调度相关的系统调用

    在这里插入图片描述

  • 相关阅读:
    Openssl 1.1.x android编译
    [tensorboard] Windows下显示Linux文件
    数据库系统与应用复习——第四章数据库安全性与第五章数据库完整性
    一款专为电压调节电路而设计的齐纳二极管工作原理及MMSZ4678T1G主要参数
    Tomcat原理剖析-Tomcat整体架构设计
    Large Language Model Alignment: A Survey
    基于SSH的CRM客户关系管理系统
    spring 事务源码阅读
    2024年的云原生架构需要哪些技术栈
    最优的纯文本模型?GPT-4蓄势待发
  • 原文地址:https://blog.csdn.net/weixin_49258262/article/details/125541534