Linux内核-进程调度

进程调度

进程调度相关概念

多任务操作系统就是能同时并法地交互执行多个进程的操作系统,多任务系统分为两类:

  • 非抢占式操作系统
  • 抢占式操作系统

linux是抢占式的多任务模式,它由调度程序决定什么时候停止一个进程的运行,以便其他的进程能够得到执行机会

策略

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

进程可以分为I/O消耗型处理器消耗型,I/O消耗型指的是进程的大部分时间用来提交IO请求或是等待IO请求,所以进程常处于可运行状态,但通常是运行短短一会,因为它在等待更多的IO请求时最后总会阻塞

而处理器消耗型进程把更多时间用在执行代码上,除非被抢占,都则会一直不停运行,所以系统调度器策略往往是尽量降低他们的调度频率,延长其运行时间

优先级

Linux采用了两种不同的优先级范围:

  • nice值
  • 实时优先级

nice值:nice值的范围是-20到+19,默认值为0,越大的nice值意味着越低的优先级,默认为0。低nice值的进程获得更多的处理器时间

实时优先级:默认情况下他的变化范围是0~99,与nice值相反,越高的实时优先级代表着进程的优先级越高,任何实时进程的优先级都高于普通的进程,也就是说实时优先级和nice优先级处于互不相交的两个范畴

时间片

时间片表明进程被抢占前所能持续运行的时间,时间片过长会导致系统对交互的响应表现欠佳,让人觉得无法并法执行应用程序;时间片过短会明显增加进程切换带来的处理器耗时,会有相当一部分系统时间浪费在进程切换上

Linux系统并不是直接分配时间片给每个进程,而是将处理器的使用比例划分给每个进程

抢占式多任务处理

linux进程管理的结构中有两种进程状态:用户态核心态

进程通常处于用户态,只能访问自身的数据,无法干扰系统中的其他应用程序

如果进程想要访问系统数据或功能,则必须切换到核心态,这是在受限情况下完成的,实现方法有系统调用。第二种方法是通过中断,这种切换是自动触发的,而系统调用是程序有意调用的。

内核的抢占调度模型建立了一个层次结构,用于判断哪些进程可以由其他状态抢占

  • 普通进程总是可能被抢占,甚至是由其他进程抢占,在一个重要进程变为可运行时,例如编辑器接收到了等待已久的键盘输入,调度器可以决定是否立即执行该进程
  • 如果系统处于核心态并正在处理系统调用,那么系统中的其他进程无法夺取其CPU时间,调度器必须等待系统调用执行完成,才能去选择另一个进程执行,但中断可以中止系统调用
  • 中断可以暂停处于用户状态和核心态的进程,中断具有最高优先级,因为在中断触发后需要尽快处理

进程状态

创建、就绪、执行、阻塞、终止 是进程的一般5种理论状态

linux有7中状态:

1.运行状态, 包括就绪态和运行态,进程切换只能在运行状态

2.可中断睡眠状态, 也就是阻塞状态,收到信号后可以执行信号处理函数,可以变成running状态

3.不可中断的睡眠状态, 磁盘IO时会出现这种状态,进程无法被中断,不能响应信号

4.停止状态

5.退出状态

6.僵尸状态, 子进程先于父进程退出,并且父进程没有调用wait或waitpid回收子进程。此时子进程即处于僵尸状态

7.跟踪状态, 当利用gdb调试某个程序,程序停留在某个断点处时,就处于跟踪状态

ps -aux 查看进程状态

#define TASK_RUNNING            0    // 进程正在运行或已准备就绪,就绪态+运行态
#define TASK_INTERRUPTIBLE        1    // 进程处于可中断等待状态
#define TASK_UNINTERRUPTIBLE    2    // 进程处于不可中断等待状态,主要用于 I/O 操作等待
#define TASK_ZOMBIE                3    // 进程处于僵死状态,已经停止运行,但父进程还没发信号
#define TASK_STOPPED            4    // 进程已停止

进程调度代码实现

这里基于linux0.1内核,它实现的调度算法本身不复杂,重要的是学习进程调度的思想。代码主要是在kernel/sched.c主要的是schedule函数

/*
 *  'schedule()' is the scheduler function. This is GOOD CODE! There
 * probably won't be any reason to change this, as it should work well
 * in all circumstances (ie gives IO-bound processes good response etc).
 * The one thing you might take a look at is the signal-handler code here.
 *
 *   NOTE!!  Task 0 is the 'idle' task, which gets called when no other
 * tasks can run. It can not be killed, and it cannot sleep. The 'state'
 * information in task[0] is never used.
 * 进程调度
 */
void schedule(void)

信号唤醒进程

    int i,next,c;
    struct task_struct ** p;

/* check alarm, wake up any interruptible tasks that have got a signal */
// ========== 根据信号唤醒进程 ===========
    for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
        if (*p) {                  // 如果任务的 alarm 时间已经过期(alarm<jiffies),在信号位图中置 SIGALRM 信号,然后清 alarm
            if ((*p)->alarm && (*p)->alarm < jiffies) {
                    (*p)->signal |= (1<<(SIGALRM-1));
                    (*p)->alarm = 0;
                }
            if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&        //则置任务为就绪状态
            (*p)->state==TASK_INTERRUPTIBLE)
                (*p)->state=TASK_RUNNING;
        }

schedule函数一开始先去一个一个遍历task_struct链表,查看是否有设置的alarm到时间,如果有的话就给当前进程一个SIGALRM信号

接着就会唤醒因信号而睡眠的进程:如果该进程有未阻塞的待处理信号(比如刚设置的 SIGALRM),并且它当前处于可中断的等待状态TASK_INTERRUPTIBLE),那么就将它唤醒(置为 TASK_RUNNING),让它有机会在调度中运行并处理这个信号

在 Linux 0.1 中,alarm内核为每个进程提供的唯一的、简易的定时机制

优先级时间片轮转调度算法

/* this is the scheduler proper: */

    while (1) {
        c = -1;
        next = 0;
        i = NR_TASKS;
        p = &task[NR_TASKS];
        while (--i) {
            if (!*--p)
                continue;
            if ((*p)->state == TASK_RUNNING && (*p)->counter > c)   //get counter_max 值
                c = (*p)->counter, next = i;
        }
        if (c) break;   //如果有不为0的时间片,那么就退出
        for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)                //calculate counter
            if (*p)
                (*p)->counter = ((*p)->counter >> 1) +     //优先级时间片轮转调度算法
                        (*p)->priority;
    }

使用一个大的while循环去获取最大时间片,这里本质就是一个寻找最大值的遍历算法。通过内部的while能够得到当前可运行态的进程中时间片最大的那一个,如果最大时间片不为0则退出循环进入后续步骤,如果为0(所有进程时间片都为0)则重新分配时间片

优先级时间片轮转调度:这里使用的是一个for循环,遍历系统中的所有进程,而不仅仅是正在运行的进程!

笔者最开始看到这里认为经过if(c)的判断之后这里的所有时间片都已经为0了,所以每俄国进程分配得到的时间片就是他的优先级,不需要再去写((*p)->counter >> 1) +

但是却忽略了其他态的进程对于正在睡眠(TASK_INTERRUPTIBLE 等)的进程:它们可能刚进入睡眠不久,counter 还没有被消耗完(可能还剩一些值)。这个 for 循环同样会处理它们!

所以,((*p)->counter >> 1) + (*p)->priority 这个公式的精妙之处在于:

  • 对于运行进程counter 为 0):它等效于直接赋值 priority,重置时间片。
  • 对于睡眠进程counter 非 0):它保留了部分剩余时间片并加上基础优先级,作为一种“奖励”,让那些因为等待 I/O 而主动让出 CPU 的进程(如交互式程序)能获得更高的调度优先级,从而提升系统的响应速度。

这是一套非常经典的兼顾公平与响应速度的调度算法,远比简单的“用完就重置”要高明得多。作者之所以这样写,正是为了统一处理这两种情况,实现这套优先级动态调整的逻辑。

进程切换

schedule函数最后会进行进程切换,调用的switch_to

这是一个由汇编代码组成的宏定义,实现效果是把 CPU 从当前进程交给另一个进程,同时保证所有状态(寄存器、内存映射、协处理器等)正确转移

xchgl %%ecx, _current:原子地交换 ECX_current 的值。执行后,_current 变成新任务的指针(切换完成),而 ECX 变成旧任务的指针(留作后面判断协处理器用)。

/*
 *    switch_to(n) should switch tasks to task nr n, first
 * checking that n isn't the current task, in which case it does nothing.
 * This also clears the TS-flag if the task we switched to has used
 * tha math co-processor latest.
 * 切换任务
 */
#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
    "je 1f\n\t" \
    "movw %%dx,%1\n\t" \
    "xchgl %%ecx,_current\n\t" \
    "ljmp %0\n\t" \
    "cmpl %%ecx,_last_task_used_math\n\t" \
    "jne 1f\n\t" \
    "clts\n" \
    "1:" \
    ::"m" (*&__tmp.a),"m" (*&__tmp.b), \
    "d" (_TSS(n)),"c" ((long) task[n])); \
}

这里通过远跳转指令ljmp切换任务

Intel 80386 支持通过远跳转(ljmp)到 TSS 描述符来自动完成任务切换。CPU 会:

  • 自动保存当前任务的所有寄存器到当前 TSS;
  • 加载新任务的 TSS 内容到寄存器;
  • 然后从新任务的地方继续执行。

这比手工保存/恢复所有寄存器要省事,Linux 0.1 正是利用了这一硬件特性。

sleep函数

在sched.c中有一个将进程休眠的sleep_on函数,笔者最开始看这个代码的时候误以为它和用户态的sleep函数一样是休眠一段时间,但是sleep_on 不是一个“定时睡眠”函数,而是一个“资源等待”函数。它的作用是让当前进程永久睡眠,直到它等待的特定内核资源(如磁盘数据、内存缓冲区)可用为止。

// 将当前任务置为不可中断的等待状态,并让睡眠对象的任务指针指向当前任务,只有明确地唤醒时才会返回
void sleep_on(struct task_struct **p)
{
    struct task_struct *tmp;

    if (!p)
        return;
    if (current == &(init_task.task))
        panic("task[0] trying to sleep");
    tmp = *p;
    *p = current;
    current->state = TASK_UNINTERRUPTIBLE;
    schedule();
    if (tmp)
        tmp->state=0;
}

函数的参数传递的是一个task_struct的指针的指针,或者叫做等待队列头指针的指针

在里面使用一个tmp指向数组头元素,然后将当前进程task_struct存放进入等待队列头

然后再将当前进程的状态设置为不可中断等待状态,再去执行调度。当下一次轮到当前进程执行时候将tmp指向的进程变为运行态

在初看这部分代码时笔者有一堆疑问,下面我将列出我的疑问以及对应解答:

为什么 tmp = *p; *p = current;

这里实现的是一个栈的等待队列实现

  1. tmp = *p;:先把当前队列头(即之前排在第一个的进程)保存下来。
  2. *p = current;:把当前进程放到队列的最前面(成为新的队头)。

因为 Linux 0.11 的等待队列是单链表没有显式的 next 指针,它利用每个进程内核栈上的临时变量来串联。tmp 实际上充当了“指向下一个等待者的指针”的角色。

为什么要设置当前进程为不可中断睡眠?

因为当前进程就是要等待资源的那个进程。设置 current->state = TASK_UNINTERRUPTIBLE,是为了告诉调度器:“我现在要睡觉了,而且我睡得很死(不能被信号打断),在没有被明确唤醒之前,不要再把我放进去运行。”

为什么要执行 schedule()

设置状态只是“贴标签”,CPU 并不会自动切换。必须调用 schedule(),让调度器去选择另一个进程运行,当前进程才能真正停下来(因为它的状态已经不是 TASK_RUNNING 了,调度器会跳过它)。

为什么调度返回后,只设置 tmp->state=0

第一步(睡眠时)
假设有两个进程 A 和 B 先后等待同一个资源:

  • B 先进来:tmp = NULL*p = B。B 睡眠。
  • A 后进来:tmp = B(保存了旧的队头 B),*p = A。A 睡眠。

现在的队列是:队列头 p 指向 A,A 的栈上 tmp 指向 B

第二步(唤醒时)
当资源可用时,中断处理程序会调用 wake_up(struct task_struct **p)

void wake_up(struct task_struct **p) {
 if (p && *p) {
 (**p).state = 0; // 只唤醒队头!
 *p = NULL;
 }
}

注意!wake_up 只唤醒了队头 *p(也就是进程 A),并清空了队列头指针。

第三步(A 被调度回来,执行 if(tmp) tmp->state=0;
此时 A 在 schedule() 后面醒来,它发现自己 tmp 里保存的是 B。于是它执行 tmp->state = 0;顺便把 B 也唤醒了

这就形成了一个“链式唤醒”(后进先出):

资源可用 -> 唤醒最后睡眠的 A -> A 运行完或让出资源前 -> A 唤醒 B -> B 再运行。

在 Linux 0.11 的单核简陋环境下,同时唤醒所有进程会导致“惊群效应”,且多个进程争抢同一个资源(比如一个内存块)会让状态难以管理。
采用“唤醒链”,资源就像接力棒一样,严格地依次传递给下一个等待者,不会出现多人争抢的混乱。而且不需要复杂的链表 next 指针维护,仅靠栈上的 tmp 变量就串起了所有等待者,非常节省内存和代码量。

关于内核的理解

笔者在之前一直以为内核是一个单独的大的进程,时时刻刻一直运行,当有系统调用来就是这个大进程执行系统调用,以及中断等,但是在看上面的sleep_on函数的时候看到每个进程都有内核栈,并且都可以设置tmp,每个tmp不同,就发现似乎理解有问题。实际上我的理解确实不对

  • 内核是代码和数据:内核本质上是操作系统常驻内存的那部分代码(函数)和数据结构(任务数组、内存管理表等)。它本身没有“进程控制块(PCB)”,也不参与进程调度。
  • 进程是“运行中的程序”:进程(任务)是在用户态运行的实体,它们需要执行I/O、申请内存等特权操作时,主动陷入(通过系统调用)或被动陷入(通过中断) 内核,让内核代码替它做事。
  • 内核线程(特殊例外):Linux 0.11 中确实有“任务0”(idle进程)和“任务1”(init进程),它们看起来像进程,但任务0是内核的一部分(死循环),任务1是内核启动的第一个用户态进程。但“内核本身”作为整体,绝不是一个大进程。

内核代码的“运行载体”取决于进入内核的途径,分为三种情况:

  • 情况一(最常见):借宿在用户进程体内
    当你的应用程序调用 read() 时,CPU 陷入内核,此时运行内核代码的载体就是当前这个用户进程。内核使用这个进程自己的内核栈(就是 task_union 里的 4KB),current 指针指向这个进程。内核此时就像这个进程的“私人律师”,替它办事。
  • 情况二(中断上下文):随机寄宿
    当硬盘中断发生时,CPU 无论当前在运行哪个用户进程,都会立刻打断它,转而执行中断处理程序。此时,内核代码运行在被中断的那个进程的上下文里,使用被中断进程的内核栈。这和进程主动调用系统调用不同,进程是被动地“被借用”了。
  • 情况三(例外):确实有“纯粹的内核进程”
    在 Linux 中,确实存在内核线程(Kernel Thread)。在 Linux 0.11 中,任务 0(idle 进程) 就是这样一个例子。它没有对应的用户程序,永远只运行内核代码(死循环调度)。现代 Linux 还有 kswapdkworker 等内核线程。这些才是“单独的”内核进程,但它们只负责后台杂务,绝对不负责执行用户进程的系统调用

sleep_on函数的运行为例:

  • 假设进程 A 调用 read() 进入内核,发现自己要等磁盘数据,于是调用 sleep_on(&wait_queue)
  • 此时,内核是在进程 A 的内核栈上运行的current 指向 A。
  • 执行 current->state = TASK_UNINTERRUPTIBLE;,把进程 A 置为睡眠。
  • 执行 schedule(),CPU 切换到进程 B

当磁盘中断发生,中断程序调用 wake_up 唤醒进程 A。此时 CPU 正运行在进程 B 的内核栈上(因为中断打断了进程 B),但 wake_up 修改的是进程 A 的状态。

发现了吗? 同一个内核函数 wake_up,在中断时是借用了进程 B 的“肉身”在运行,但它操作的数据却是进程 A 的。并没有一个独立的“内核进程”来替它做这件事。

内核代码不是在一个固定的“内核进程”里运行,而是像“幽灵”一样,附身在当前正在运行的进程(或被打断的进程)身上运行。 每个进程都自带一个“内核栈”(躯壳),内核代码走到哪里,就借用哪个进程的躯壳,直到返回用户态,再把躯壳还给该进程。只有少数专门的内核线程(如任务 0)才是“独立行走的幽灵”。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇