【读书笔记】Linux内核设计与实现-进程调度

进程调度:内核以这种微妙而有趣的方式来决定哪个进程运行,何时运行,以何种顺序运行。

进程在操作系统看来是程序的运行态表现形式。

进程调度程序(简称调度程序)可看作在可运行态进程之间分配有限的处理器时间资源的内核子系统

调度程序最大限度的利用处理器时间的原则是:只要有可以执行的进程,那么就总会有进程正在执行。

在一组处于可运行状态的进程中选择一个来执行,是调度程序所需完成的基本工作。

1.多任务–抢占、时间片和让步

多任务操作系统就是能同时并发的交互执行多个进程的操作系统。
无论在单处理器还是多处理器机器上,多任务操作系统都能使多个进程处于阻塞或者睡眠状态,也就是说,实际上不被投入执行,知道工作确实就绪。

多任务系统可以分为:非抢占式多任务(cooperative multitasking)和抢占式多任务(preemptive multitasking)。

Q:什么叫做抢占(preemption)?
A:由调度程序来决定什么时候停止一个进程的运行,以便其他进程能够得到执行机会,这个强制挂起动作就叫抢占。

Q:什么叫做时间片(timeslice)?
A:进程在被抢占之前能够运行的时间是预先设置好的,这个时间就是进程的时间片。

Q:什么叫做让步(yielding)?
A:进程主动挂起自己的操作系统称为让步。

抢占式和非抢占式的优劣不言而喻,处于非抢占式的多任务模式下,除非进程主动让步,这样其他的进程才能够有运行机会,若不让步,一个绝不做出让步的悬挂进程就能使系统崩溃。

2.Linux的进程调度–O(1)调度程序

3.策略–决定调度程序在何时让什么进程运行

3.1 I/O消耗型和处理器消耗型的进程

进程可以被分为I/O消耗型和处理器消耗型。
I/O消耗型指进程的大部分时间用来提交I/O请求或者是等待I/O请求。
处理器消耗型指进程把时间大多用在执行代码上。除非被抢占,否者通常都一直不停的运行。

ps:进程可以同时展示这两种,即既是I/O消耗型也是处理器消耗型。

调度策略通常要在两个矛盾的目标中间寻求平衡:进程相应迅速(响应时间短)和最大系统利用率(高吞吐量)

3.2 进程优先级

调度算法中最基本的一类就是基于优先级的调度。
调度程序总是选择时间片未用尽而且优先级最高的进程运行。

Linux采用了两种不同的优先级范围。nice值和实时优先级

类型 说明
nice值 1:范围从-20到+19,默认值为0;
2:越大的nice值意味着更低的优先级;
3:相关命令 ps -el ,NI列就是进程对于的nice值
实时优先级 1:值可配置;
2:默认情况变化范围从0到99(包含);
3:越高的实时优先值意味着进程优先级越高;
4:任何实时进程的优先级都高于普通的进程(实时优先级和nice优先级处于互不相交的两个范畴)
5:相关命令:ps -eo state,uid,pid,ppid,rtprio,time,comm ()详情如下图
ps -eo state,uid,pid,ppid,rtprio,time,comm

在这里插入图片描述

3.3 时间片

时间片是一个数值,它表明进程在被抢占前所能持续运行的时间。

经验表明,任何长时间片都将导致系统交互表现欠佳。

Linux的CFS(完全公平调度)调度器并没有直接分配时间片到进程,而是将处理器的使用比划分给了进程。
因此,进程所获得的处理器时间其实是和系统负载密切相关的。而且这个比例还会受进程nice值的影响,nice值作为权重将调整进程所使用的处理器时间使用比。

Linux是抢占式的,当一个进程进入可运行态,它就被准许投入运行在Linux的CFS调度器中,其抢占时机取决于新的可运行程序消耗了多少处理器使用比。如果消耗的使用比比当前进程小,则新进程立刻投入运行,抢占当前线程,否则,将推迟其运行。

3.4 调度策略的活动–建议阅读原书此小节,很形象的说明了“策略”

4.Linux调度算法

ps:暂时跳转到 11 章

4.1 调度器类(scheduler classes)–模块化结构

Linux调度器是以模块方式提供的,这样做的目的是允许不同类型的进程可以有针对性地选择调度算法。

调度器类允许多种不同的可动态添加的调度算法并存,调度属于自己范畴的进程。

每一个调度器都有一个优先级,基础的调度器代码定义在kernel/sched.c文件中。

完全公平调度(CFS)是一个针对普通进程的调度类,在Linux中称为SCHED_NORMAL(在POSIX中称为SCHED_OTHER),CFS算法实现定义在文件kernel/sched_fair.c中。

4.2 Unix系统中的进程调度

4.3 公平调度-CFS

CFS的出发点基于一个简单的理念:进程调度的效果应如同系统具备一个理想中的完美多任务处理器。

在这种系统中,每个进程将能获得1/n的处理器时间—n是指可运行进程的数量。

上述理想模型并非现实,因为无法在一个处理器上真的同时运行多个进程。

CFS的做法是允许每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,而不再采用分配给每个进程时间片的做法。

CFS在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是依靠nice值来计算时间片。

nice值在CFS中被作为进程获得的处理器运行比的权重:越高的nice值(越低的优先级)进程获得更低的处理器使用权重,这是相对默认nice值进程而言的;相反,更低的nice值(越高的优先级)的进程获得更高的处理器权重。

每个进程都按其权重在全部可运行进程中所占比例的“时间片”来运行,为了计算精准的时间片,CFS为完美多任务中的无限小调度周期的近似值设立了一个目标–“目标延迟”。

CFS引入每个进程获得的时间片底线,称为最小粒度

只有相对值才会影响处理器时间的分配比例。

5.Linux调度的实现–kernel/sched_fair.c

5.1 时间记账

所有的调度器都必须对进程运行时间做记账。
当一个进程的时间片被减少到0时,它就会被另一个尚未减到0的时间片可运行进程抢占。

CFS不再有时间片的概念,但也必须维护每个进程运行的时间记账,确保每个进程只在公平分配给它的处理器时间内运行。
CFS使用调度器实体结构定义在文件<linux/sched.h>中的struct sched_entity中来追踪进程运行记账。

ps:调度器实体结构作为一个名为se的成员变量嵌入在进程描述符struct task_struct内。

vruntime变量(定义在struct sched_entity)存放进程的虚拟运行时间,该运行时间(花在运行商的时间和)的计算是经过了所有可运行进程总数的标准化。

CFS使用vruntime变量来记录一个程序到底运行了多长时间以及它还应该再运行多久。

定义在kernel/sched_fair.c文件中的update_curr()函数实现了该记账功能。

5.2 进程选择–CFS调度算法:选择具有最小vruntime值的进程

CFS使用红黑树(rbtree,自平衡二叉搜索树)来组织可运行进程队列,并利用其迅速找到最小vruntime值的进程。

  1. 挑选下一个任务;
  2. 向树中加入进程;
  3. 从树中删除进程。

5.3 调度器入口

进程调度的主要入口点是函数schedule(),它定义在kernel/sched.c中。
它正是内核其他部分用于调用进程调度器的入口:选择哪个进程可以运行,何时将其投入运行。

schedule()通常都需要和一个具体的调度类相关联,也就是说,它会找到一个最高优先级的调度类–后者需要有自己的可运行队列,然后问后者谁是下一个该运行的进程。

schedule实现相当简单,它会调用pick_next_task()(也定义在文件kernel/sched.c中)。

pick_next_task()会以优先级为序,从高到低,依次检查每一个调度类,并且从最高优先级的调度类中,选择最高优先级的进程

CFS中pick_next_task()实现会调用pick_next_entity(),该函数会再来调用上面5.2体到的__pick_next_entity()函数。

ps:CFS是普通进程的调度类。这里详细可以参考这篇文章Linux系统进程调度——调度架构详细分析

5.4 睡眠和唤醒

休眠(被阻塞)的进程处于一个特殊的不可执行状态

进程休眠有多种原因,但肯定都是为了等待一些事件。

内核的睡眠唤醒操作都相同
睡眠:进程把自己标记成休眠状态,从可执行红黑树中移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。
唤醒:唤醒的过程刚好和睡眠相反,进程设置为可执行状态,然后再从等待队列中移到可执行红黑树中。

等待队列(休眠)
休眠通过等待队列进行处理。
等待队列是由等待某些事件发生的进程组成的简单链表
内核用wake_queue_head_t来代表等待队列。
等待队列可以通过DECLARE_WAITQUEUE()静态创建,也可以由init_waitqueue_head()动态创建。

进程通过执行如下几个步骤将自己加入到一个等待队列中:

  1. 调用宏DEFINE_WAIT()创建一个等待队列的项;
  2. 调用add_wait_queue()把自己加入到队列中。该队列会在进程等待的条件满足时唤醒它。当然我们必须在其他地方撰写相关代码,在事件发生时,对等待队列执行wake_up()操作。
  3. 调用prepare_to_wait()方法将进程的状态变更为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE。而且该函数如果有必要的话会将进程加回到等待队列,这是在接下来的循环遍历中所需要的;
  4. 如果状态被设置为TASK_INTERRUPTIBLE,则信号唤醒进程。这就是所谓的伪唤醒(唤醒不是因为事件的发生)。因此检查并处理信号;
  5. 当进程被唤醒的时候,它会再次检查条件是否为真。如果是,它就退出循环,如果不是,它再次调用schedule()并一直重复这步操作;
  6. 当条件满足后,进程将自己蛇者为TASK_RUNNING并调用finish_wait()方法把自己移出等待队列。

eg:

/* 'q' 是希望休眠的等待队列 */
DEFINE_WAIT(wait);

add_wait_queue(q,&wait);
while(!condition){ /* 'condition' 是在等待的事件 */
	prepare_to_wait(&q,&wait,TASK_INTERRUPTIBLE);
	if(signal_pending(current))
	{
		/* 处理信号 如:*/
		/* do_something; */
	}
	schedule();
};
finish_wait(&q,&wait);

ps:如果在打算睡眠的时候还占有锁,记得在调用schedule()之前需要释放掉锁,而在这以后在重新获取它们,或者响应其他事件。

函数inotify_read()位于文件fs/notify/inotify/inotify_user.c中,复制从通知文件描述符中读取信息,它的实现是等待队列的一个典型用法(同样的tty设备串口通讯中的n_tty.c 的 n_tty_write()函数也是一个典型的等待队列用法)。

唤醒
唤醒操作通过函数wake_up()进行,它会唤醒指定的等待队列上的所有进程。
wake_up函数调用try_to_wake_up(),该函数复制将进程设置为TASK_RUNNING状态,调用enqueue_task()将此进程放入红黑树中,如果被唤醒的进程优先级比当前正在执行的进程的优先级高,还要设置need_resched标志。

ps:通常哪段代码促使等待条件完成,它就要复制随后调用wake_up()函数。
ps:关于休眠有一点需要注意,存在虚假的唤醒。即有时候进程被唤醒并不是因为它所等待的条件达成了才需要用一个循环处理来保证它等待的条件真正达成。
在这里插入图片描述

6.抢占和上下文切换

上下文切换,也就是从一个可执行进程切换到另一个可执行进程,由定义在kernel/sched.c中的context_switch()函数负责处理。
每当一个新的进程被选出来准备投入运行的时候,schedule()就会调用该函数。
context_switch()函数完成了如下两项基本工作:

  1. 调用声明在<asm/mmu_context.h>中的switch_mm(),该函数负责把虚拟内存从上一个进程映射切换到新进程中;
  2. 调用声明在<asm/system.h>中的switch_to(),该函数负责从上一个进程的处理器状态切换到新进程的处理器状态。包括保存、回复栈信息和寄存器信息,还有其他任何与体系结构相关的状态信息,都必须以每个进程为对象进行管理和保存。

内核必须知道在什么时候调用schedule()。提供了一个need_resched标志来表明是否需要重新执行一次调度。

用于访问和操作need_resched的函数如下所示:

函数 目的
set_tsk_need_resched() 设置指定进程中的need_resched标志
clear_tsk_need_resched() 清除指定进程中的need_resched标志
need_resched() 检查need_resched标志的值,如果被设置就返回真,否则返回假

Q:need_resched标志何时被设置?
A:当某个进程应该被抢占的时候,scheduler_tick()就会设置这个标志;当一个优先级高的进程进入可执行状态的时候,try_to_wake_up会设置这个标志,内核检查该标志,确认其被设置,调用schedule()来切换到一个新的进程。再返回用户空间以及从中断返回的时候,内核也会检查need_resched标志,如果被设置,内核会再继续执行之前调用调度程序。

每个进程都包含一个need_resched标志,因为访问进程描述符内的数值要比访问一个全局变量快(因为current宏速度很快并且描述符通常都在高速缓存中)。2.6以后的版本need_resched被移到thread_info结构体里,用一个特别的标志变量中的一位来表示。

6.1 用户抢占

内核即将返回用户空间的时候,如果need_resched标志被设置,会导致schedule()被调用,此时就会发生用户抢占。

用户抢占在以下情况时产生:

  1. 从系统调用返回用户空间时;
  2. 从中断处理程序返回用户空间时候。

6.2 内核抢占–只要没有持有锁,内核就可以进行抢占

Linux完整地支持内核抢占。即调度程序可以在一个内核级的任务正在执行的时候重新调度。

只要调度安全,内核就可以在任何时间抢占正在执行的任务。

Q:什么时候重新调度才是安全的?
A:只要没有持有锁,内核就可以进行抢占。锁是非抢占区域的标志。

内核抢占在以下情况时产生:

  1. 中断处理程序正在执行,且返回内核空间之前;
  2. 内核代码再一次具有可抢占性的时候;
  3. 如果内核中的任务显式地调用schedule();
  4. 如果内核中的任务阻塞(这同样也会导致调用schedule())。

ps:为了支持内核抢占所做的第一处变动,就是为每个进程的thread_info引入preempt_count计数器。该计数器初始值为0,每当使用锁的时候数值加1,释放锁的时候数值减1。当数值为0的时候,内核就可以执行抢占。

7.实时调度策略–SCHED_FIFO和SCHED_RR

Linux提供了两种实时调度策略:SCHED_FIFO和SCHED_RR。
而普通的、非实时的调度策略是SCHED_NORMAL。

实时策略并不被晚期公平调度器来管理,而是被一个特殊的实时调度器管理。实现定义在文件kernel/sched_rt.c中。

实时调度策略 详细说明
SCHED_FIFO SCHED_FIFO实现了一种简单的、先入先出的调度算法:它不使用时间片。处于可运行状态的SCHED_FIFO级的进程会比任何SCHED_NORMAL级的进程都先得到调度。一旦一个SCHED_FIFO级进程处于可运行状态,就会一直执行,直到它自己受阻塞或显式地释放处理器为止;它不基于时间片,可以一直执行下去,只有更高优先级的SCHED_FIFO或者SCHED_RR任务才能抢占SCHED_FIFO任务。如果有两个或者更多的通优先级的SCHED_FIFO级进程,它们会轮流执行,但是已让只有在它们愿意让出处理器时才会退出。只要有SCHED_FIFO级进程在执行,其他级别较低的进程就只能等待它变为不可运行后才有机会执行 实现的都是静态优先级。内核不为实时进程计算动态优先级。这能保证给定优先级别的实时进程总能抢占优先级比它低的进程
SCHED_RR SCHED_RR与SCHED_RR大体相同,只是SCHED_RR级的进程在耗尽事先分配给它的时间后就不能再继续执行了。也就是说,SCHED_RR是带有时间片的SCHED_FIFO–这是一种实时轮流调度算法。当SCHED_RR任务耗尽它的时间片时,在同一优先级的其他实时进程被轮流调度。时间片只是用来重新调度同一优先级的进程。对于SCHED_FIFO进程,高优先级总是立即抢占低优先级,但低优先级进程决不能抢占SCHED_RR任务,即使它的时间片耗尽。 实现的都是静态优先级。内核不为实时进程计算动态优先级。这能保证给定优先级别的实时进程总能抢占优先级比它低的进程

Linux的实时调度算法提供了一种软实时工作方式。软实时的含义是:内核调度进程,尽力使进程在它的限定时间到来前运行,但内核不保证总能满足这些进程的要求。

实时优先级范围从0到MAX_RT_PRIO减1(0-99).默认情况下,MAX_RT_PRIO为100。
SCHED_NORMAL级进程的nice值共享了这个取值空间;它的取值范围是从MAX_RT_PRIO到(MAX_RT_PRIO+40),即在默认情况下,nice值从-20到+19直接对应的是从100到139的实时优先级范围。

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

Linux提供了一个系统调用族,用于管理与调度程序相关的参数。
与调度相关的系统调用如下表所示

系统调用 描述
nice() 设置进程的nice值
sched_setscheduler() 设置进程调度策略
sched_getscheduler() 获取进程的调度策略
sched_setparam() 设置进程的实时优先级
sched_getparam() 获取进程的实时优先级
sched_get_priority_max() 获取实时优先级的最大值
sched_get_priority_min() 获取实时优先级的最小值
sched_rr_get_interval() 获取进程的时间片值
sched_setaffinity() 设置进程的处理器的亲和力
sched_getaffinity() 获取进程的处理器的亲和力
sched_yield() 暂时让出处理器
发布了91 篇原创文章 · 获赞 17 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/qq_23327993/article/details/105072421