SmlOS十一-多任务支持

  • 内容
  • 评论
  • 相关

操作系统是如何实现任务并行的

这里的任务可以理解成进程或者线程操作

不知道有没有同学对此表示过好奇,多个进程如何做到同时运行且不相互影响的。

也许操作系统课上有这样解释过,两个任务线程不停的快速切换,看起来就像是同时运行了一样。

说的没错,不过只有亲自实现一遍,才能对此有深刻的理解。


时间片轮转

先来说下任务切换的原理,其实很简单。

假如我们有只有两个任务A和B,每个任务的时间片是100ms。

刚开始是A在运行,过了100ms,由时钟中断打断了,在中断中,把A所有的寄存器的值保存在一个地方,这个操作我们称之为保存现场。

然后把B任务的所有寄存器从内存恢复到寄存器,这时候就已经完成一次任务切换了。

然后又过了100ms,把B任务保存现场,载入A任务的当时所有寄存器的值,从此循环往复。

简单来说,任务切换就是切换了一堆任务寄存器。

有人可能觉得好奇,这是为什么呢?

这里我们举个例子:

假如A任务的指令序列是:

地址    汇编指令
0x100   mov eax, 1
0x102   add eax, 2
0x104   add eax, 3

eip = 0x100, eax = 0

B任务的指令序列是:

地址    汇编指令
0x200   mov eax, 2
0x202   add eax, 4
0x204   add eax, 8

eip = 0x200, eax = 0

eip = 0x100 代表下一条运行的指令就是0x100地址处的指令

以上面的情景为例,A任务的起始eip是0x100

然后执行了一条指令,这时A现场:

    eip = 0x102
    eax = 1

我们假如这一条指令执行了100ms,发生了时钟中断,这个时候进行了任务切换,那么会做什么呢?

这个时候先把A现场保存,然后把B任务现场的寄存器,全部恢复,这时cpu寄存器:

    eip = 0x200
    eax = 0

这时就切换到B的任务中了。

而cpu执行指令只会看eip寄存器指向。

B也执行了一条指令,这时:

    eip = 0x202
    eax = 2

假如这条指令花费了100ms,又要切换回A任务了,这时候会把刚刚保存的A现场恢复:

    eip = 0x102
    eax = 1

然后A执行了一条指令,

0x102   add eax, 2

然后eax会变成3,当然: 如果没有任务切换的话,eip执行到这里,eax也是3

你就会惊奇的发现:

我们的任务切换并没有影响到各自任务的执行结果,它和只运行单个任务运行是一样的

当然,我们这里只是拿两个寄存器来说明原理,其他寄存器也是很重要的。


TSS硬件跳转

什么是TSS呢:

TSS (Task State Segment) 任务状态段

暂且把它理解成一个包含了各种寄存器的结构体:

struct TSS32
{
 int backlink, esp0, ss0, esp1, ss1, esp2, ss2, cr3;
 int eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi;
 int es, cs, ss, ds, fs, gs;
 int ldtr, iomap;
};

比如说我们要自己实现任务A现场保存,并且切换到B任务。

我们要怎么做呢,我们需要把A任务各种寄存器都手动保存到内存,然后把B任务寄存器都从内存加载到寄存器。

这需要写很多汇编指令。

然后intel提供了一种基址,让你可以“一键跳转”,这就是TSS。

使用TSS跳转是很古老的任务切换机制,比如在linux 0.11内核版本上就是使用TSS,现代操作系统已经不再使用

TSS跳转的细节

在任务切换的过程中,每个处理器寄存器的值保存到由TR任务寄存器指定的TSS。

而任务的跳转则由一条汇编指令jmp far实现

不过在执行farjmp之前需要在GDT里首先注册好TSS的地址。

这样在farjmp之后,CPU会自动保存相应值到TSS里面,然后这个TSS所保存的寄存器的值会被取出还原到各个寄存中去,从而实现了任务现场的恢复,实现了任务的跳转

科普:什么是远跳转

当然程序的跳转分为两种跳转

一种是near模式跳转和far模式跳转。

一般跳转的实现是更改EIP寄存器的值来实现,

在near模式跳转中,只是改写EIP寄存器的值

在far模式中不仅改变EIP,而且还要改变CS寄存器值。


任务优先级设定

那么为什么需要任务优先级呢?

以上面的例子为例,每个任务都是执行100ms然后就开始切换任务。

但是我认为A任务比较重要,需要给200ms的执行时间,给B任务100ms时间,我们要怎么做呢?

在现代的计算机操作系统中,任务的切换动作在0.01-0.03秒左右。

现代的操作系统,对任务的都可以有自定义时间片的分配。

即在执行不同任务的时候,分给任务执行的时间是不同的。

说下我们的实现,介绍下保存单个任务信息的结构体:

struct TASK
{
 int sel, flags;
 int level;
 int priority;
 struct TSS32 tss;
 struct FIFO32 *fifo;
};

sel 该任务的段选择子(GDT中的选择子)

flag 标识该任务的状态

level 任务运行运行层上(越低层优先运行)

priority: 优先级(时间片长度,这里命名不是很好。。)

tss: TSS数据结构

fifo 该任务缓冲区

切换到指定任务之前,首先设置prioriy指定的超时时间比如设定200ms,那就200ms后才发生切换。

超时功能,之前的章节有讲述。

这样能做到什么效果呢?

我们就实现了两个任务:
A和B:

void task_a()
{
    ...

    int count = 0;
    for (;;) 
    {
        count++;    

        // 显示到窗体
        print_count(count);
    }
}

void task_b()
{
    ...

    int count = 0;
    for (;;) 
    {
        count++;    

        // 显示到窗体
        print_count(count);
    }
}

我们设定了这样两个任务,任务操作是一样的:都是执行累加操作。

但是我们任务设置了不同的时间片,A任务运行10ms就切换,而B任务运行20ms才切换。

运行结果:

image

可以看到B任务的累加次数大致是A任务的两倍。


任务分层

当然,仅仅有设置任务的时间片是不够的。

在现实中,往往有更紧急的任务需要去执行。

比如同时有音乐播放任务网络接收任务

那么在有网络接收任务的时候,应该优先处理完网络任务,再处理音乐播放任务。

这种优先不是说给网络任务更多的时间片,而是说在有网络任务都会优先区执行网络任务,等执行到没有了网络这种紧急级别的任务,才会考虑执行音乐播放等不重要任务。

因为网络任务是比较重要的,延时接收是可能引发协议超时重传的。

那么这样的需求下,多任务的分层管理结构应用而生。

在这种架构下,即使任务的时间片相同,也会执行层级最高的任务,等高层任务执行完毕,才会执行底层任务。

这里我们引入了层级的概念,比如说网络任务就是高层的任务,音乐播放就是底层任务。

所以这里看下任务管理表的结构体:

struct TASKLEVEL
{
 int running;
 int now;
 struct TASK *tasks[MAX_TASKS_LV];
};

running: 该层中有多少个任务在运行

now: 该层中正在运行的是哪个任务

tasks: TASK的指针数组

struct TASKCTL
{
 int now_lv;
 char lv_change;
 struct TASKLEVEL level[MAX_TASKLEVELS];
 struct TASK tasks0[MAX_TASKS];
};

now_lv: 当前任务运行在哪层上(lv==level)

lv_change: 下次任务切换时,是否需要修改level

任务切换核心操作:

//任务切换函数 
void task_switch(void)
{
    //当前层
    struct TASKLEVEL *tl = &taskctl->level[taskctl->now_lv];
    struct TASK *new_task, *now_task = tl->tasks[tl->now];
    tl->now++;

    if (tl->now == tl->running)
    {
       tl->now = 0;
    }
    if (taskctl->lv_change != 0)
    {  
        //寻找最上层的level
        task_switchsub();      
        //更新tl让它指向最上层的level
        tl = &taskctl->level[taskctl->now_lv];      
    }

    new_task = tl->tasks[tl->now];
    timer_settime(task_timer, new_task->priority);

    //只有一个任务不跳转
    if (new_task != now_task)
    {
       farjmp(0, new_task->sel);
    }
    return;
}

任务的休眠

对单个任务的休眠暂停操作就是,从任务表中删除,不参与内核任务切换。

//删除任务
void task_remove(struct TASK *task)
{
    int i;
    struct TASKLEVEL *tl = &taskctl->level[task->level];

    //寻址task所在位置 
    for (i = 0; i < tl->running; i++)
    {
       if (tl->tasks[i] == task)
       {
           break;
       }
    }

    tl->running--;
    if (i < tl->now)
    {
       tl->now--; 
    }

    if (tl->now >= tl->running)
    {  
       tl->now = 0;
    }
    //休眠中
    task->flags = 1; 

    //整理tasks数组 
    for (; i < tl->running; i++)
    {
       tl->tasks[i] = tl->tasks[i + 1];
    }

    return;
}

内核休眠任务

很多时候计算机是没有任何任务的,但计算机总要有个任务在执行,不能跑飞了。

而且这个任务不能是死循环,太费电。

所以intel出了了 HLT 这个汇编指令。

执行这条指令后,会让cpu进入休眠状态,等到有中断的情况下才会唤醒cpu

而且基于我们的任务分层机制,我们把这个任务放到最底层。

也就是没有其他任务的时候,才会执行这个任务。

在现代操作系统中的,比如windows 中的System Idle Process(系统空闲进程),大致就是这个原理。

还可以基于这个,统计出这个任务的占用时间,从而计算出现在cpu的利用率。

核心代码如下:

_io_hlt:            ;void io_hlt(void);
    HLT
    RET

// 闲置时任务
void syshlt(void)
{
    for (;;) 
    {
        io_hlt();
    }
}