操作系统-进程

什么是进程

什么是进程

进程是一个应用程序运行时刻的实例(从进程的结构看);进程是应用程序运行时所需资源的容器(从进程的功能看);甚至进程是一堆数据结构(从操作系统对进程实现的角度来说)。

进程的结构

首先,进程是一个应用程序运行时刻的实例,它的目的就是操作系统用于管理和运行多个应用程序的;其次,从前面我们实现的内存管理组件角度看,操作系统是给应用程序提供服务的。所以,从这两个角度看,进程必须要有一个地址空间,这个地址空间至少包括两部分内容:一部分是内核,一部分是用户的应用程序。最后,结合 x86 硬件平台对虚拟地址空间的制约,我给你画了一幅图,如下所示。

上图中有 8 个进程,每个进程拥有 x86 CPU 的整个虚拟地址空间,这个虚拟地址空间被分成了两个部分,上半部分是所有进程都共享的内核部分 ,里面放着一份内核代码和数据,下半部分是应用程序,分别独立,互不干扰。

当 CPU 在 R0 特权级运行时,就运行在上半部分内核的地址空间中,当 CPU 在 R3 特权级时,就运行在下半部分的应用程序地址空间中。各进程的虚拟地址空间是相同的,它们之间物理地址不同,是由 MMU 页表进行隔离的,所以每个进程的应用程序的代码绝对不能随意访问内核的代码和数据。

以上是整体结构,下面我们来细化一下进程需要实现哪些功能?

我们先从应用程序和内核的关系看。应用程序需要内核提供资源,而内核需要控制应用程序的运行。那么内核必须能够命令应用程序,让它随时中断(进入内核地址空间)或恢复执行,这就需要保存应用程序的机器上下文和它运行时刻的栈。

接着,我们深入内核提供服务的机制。众所周知,内核是这样提供服务的:通过停止应用程序代码运行,进入内核地址空间运行内核代码,然后返回结果。就像活动组织者会用表格备案一样,内核还需要记录一个应用程序都访问了哪些资源,比如打开了某个文件,或是访问了某个设备。而这样的“记录表”,我们就用“资源描述符”来表示。

而我们前面已经说了,进程是一个应用程序运行时刻的实例。那这样一来,一个细化的进程结构,就可以像下图这样设计。

上图中表示了一个进程详细且必要的结构,其中带 * 号是每个进程都有独立一份,有了这样的设计结构,多个进程就能并发运行了。前面这些内容还是纸上谈兵,你重点搞明白进程的概念和结构就行了。

实现进程

前面我们简单介绍了进程的概念和结构,之所以简单,是为了不在理论层面就把问题复杂化,这对我们实现 Cosmos 的进程组件没有任何好处。但只懂理论还是空中阁楼,我们可以一步步在设计实现中,由浅到深地理解什么是进程。我们这就把前面的概念和设计,一步步落实到代码,设计出对应的数据结构。

如何表示一个进程

对于一个进程,它有状态,id,运行时间,优先级,应用程序栈,内核栈,机器上下文,资源描述符,地址空间,我们将这些信息组织在一起,就形成了一个进程的数据结构。下面我带你把它变成代码,在 cosmos/include/knlinc/ 目录下建立一个 krlthread_t.h 文件,在其中写上代码,如下所示。

typedef struct s_THREAD
{
    spinlock_t  td_lock;           //进程的自旋锁
    list_h_t    td_list;           //进程链表 
    uint_t      td_flgs;           //进程的标志
    uint_t      td_stus;           //进程的状态
    uint_t      td_cpuid;          //进程所在的CPU的id
    uint_t      td_id;             //进程的id
    uint_t      td_tick;           //进程运行了多少tick
    uint_t      td_privilege;      //进程的权限
    uint_t      td_priority;       //进程的优先级
    uint_t      td_runmode;        //进程的运行模式
    adr_t       td_krlstktop;      //应用程序内核栈顶地址
    adr_t       td_krlstkstart;    //应用程序内核栈开始地址
    adr_t       td_usrstktop;      //应用程序栈顶地址
    adr_t       td_usrstkstart;    //应用程序栈开始地址
    mmadrsdsc_t* td_mmdsc;         //地址空间结构
    context_t   td_context;        //机器上下文件结构
    objnode_t*  td_handtbl[TD_HAND_MAX];//打开的对象数组
}thread_t;

在 Cosmos 中,我们就使用 thread_t 结构的一个实例变量代表一个进程。进程的内核栈和进程的应用程序栈是两块内存空间,进程的权限表示一个进程是用户进程还是系统进程。进程的权限不同,它们能完成功能也不同。

万事都有轻重缓急,进程也一样,进程有 64 个优先级,td_priority 数值越小优先级越高。td_handtbl 只是一个 objnode_t 结构的指针类型数组。

比方说,一个进程打开一个文件内核就会创建一个对应的 objnode_t 结构的实例变量,这个 objnode_t 结构的地址就保存在 td_handtbl 数组中。你可以这么理解:这个 objnode_t 结构就是进程打开资源的描述符。

进程的地址空间

在 thread_t 结构中有个 mmadrsdsc_t 结构的指针,在这个结构中有虚拟地址区间结构和 MMU 相关的信息。

typedef struct s_MMADRSDSC
{
    spinlock_t msd_lock;               //保护自身的自旋锁
    list_h_t msd_list;                 //链表
    uint_t msd_flag;                   //状态和标志
    uint_t msd_stus;
    uint_t msd_scount;                 //计数,该结构可能被共享
    sem_t  msd_sem;                    //信号量
    mmudsc_t msd_mmu;                  //MMU页表相关的信息
    virmemadrs_t msd_virmemadrs;       //虚拟地址空间结构
    adr_t msd_stext;                   //应用的指令区的开始、结束地址
    adr_t msd_etext;
    adr_t msd_sdata;                   //应用的数据区的开始、结束地址
    adr_t msd_edata;
    adr_t msd_sbss;                    //应用初始化为0的区域开始、结束地址
    adr_t msd_ebss;
    adr_t msd_sbrk;                    //应用的堆区的开始、结束地址
    adr_t msd_ebrk;
}mmadrsdsc_t;

上述代码中,注释已经很清楚了,mmadrsdsc_t 结构描述了一个进程的完整的地址空间。需要搞清楚的是:在常规情况下,新建一个进程就要建立一个 mmadrsdsc_t 结构,让 thread_t 结构的 td_mmdsc 的指针变量指向它。

进程的机器上下文

进程的机器上下文分为几个部分,一部分是 CPU 寄存器,一部分是内核函数调用路径。CPU 的通用寄存器,是中断发生进入内核时,压入内核栈中的,从中断入口处开始调用的函数,都是属于内核的函数。函数的调用路径就在内核栈中,整个过程是这样的:进程调度器函数会调用进程切换函数,完成切换进程这个操作,而在进程切换函数中会保存栈寄存器的值。好,下面我们来设计这样一个结构来保存这些信息。

typedef struct s_CONTEXT
{  
    uint_t       ctx_nextrip; //保存下一次运行的地址
    uint_t       ctx_nextrsp; //保存下一次运行时内核栈的地址 
    x64tss_t*    ctx_nexttss; //指向tss结构
}context_t;

context_t 结构中的字段不多,我们相对陌生的就是 x64tss_t 结构的指针,这个结构是 CPU 要求的一个结构,这个结构它本身的地址放在一个 GDT 表项中,由 CPU 的 tr 寄存器指向,tr 寄存器中的值是 GDT 中 x64tss_t 结构项对应的索引。x64tss_t 结构的代码如下所示。

// cosmos/hal/x86/halglobal.c
// 每个CPU核心一个tss 
HAL_DEFGLOB_VARIABLE(x64tss_t,x64tss)[CPUCORE_MAX]; 

typedef struct s_X64TSS
{
    u32_t reserv0; //保留
    u64_t rsp0;  //R0特权级的栈地址
    u64_t rsp1;  //R1特权级的栈地址,我们未使用
    u64_t rsp2;  //R2特权级的栈地址,我们未使用
    u64_t reserv28;//保留
    u64_t ist[7];  //我们未使用
    u64_t reserv92;//保留
    u16_t reserv100;//保留
    u16_t iobase;   //我们未使用
}__attribute__((packed)) x64tss_t;

CPU 在发生中断时,会根据中断门描述里的目标段选择子,进行必要的特权级切换,特权级的切换就必须要切换栈,CPU 硬件会自己把当前 rsp 寄存器保存到内部的临时寄存器 tmprsp;然后从 x64tss_t 结构体中找出对应的栈地址,装入 rsp 寄存器中;接着,再把当前的 ss、tmprsp、rflags、cs、rip,依次压入当前 rsp 指向的栈中。

建立进程

之前我们已经设计好了进程相关的数据结构,现在我们要讨论如何建立一个新的进程了。建立进程非常简单,就是在内存中建立起对应的数据结构的实例变量。但是对进程来说,并不是建立 thread_t 结构的实例变量就完事了,还要建立进程的应用程序栈和进程的内核栈,进程地址空间等。下面我们一起来实现建立进程的功能。

建立进程接口

我们先从建立进程的接口开始写起,先在 cosmos/kernel/ 目录下新建一个文件 krlthread.c,在其中写上一个函数。接口函数总是简单的,代码如下所示。

thread_t *krlnew_thread(void *filerun, uint_t flg, uint_t prilg, uint_t prity, size_t usrstksz, size_t krlstksz)
{
    size_t tustksz = usrstksz, tkstksz = krlstksz;
    //对参数进行检查,不合乎要求就返回NULL表示创建失败
    if (filerun == NULL || usrstksz > DAFT_TDUSRSTKSZ || krlstksz > DAFT_TDKRLSTKSZ)
    {
        return NULL;
    }
    if ((prilg != PRILG_USR && prilg != PRILG_SYS) || (prity >= PRITY_MAX))
    {
        return NULL;
    }
    //进程应用程序栈大小检查,大于默认大小则使用默认大小
    if (usrstksz < DAFT_TDUSRSTKSZ)
    {
        tustksz = DAFT_TDUSRSTKSZ;
    }
    //进程内核栈大小检查,大于默认大小则使用默认大小
    if (krlstksz < DAFT_TDKRLSTKSZ)
    {
        tkstksz = DAFT_TDKRLSTKSZ;
    }
    //是否建立内核进程
    if (KERNTHREAD_FLG == flg)
    {
        return krlnew_kern_thread_core(filerun, flg, prilg, prity, tustksz, tkstksz);
    }
    //是否建立普通进程
    else if (USERTHREAD_FLG == flg)
    {
        return krlnew_user_thread_core(filerun, flg, prilg, prity, tustksz, tkstksz);
    }
    return NULL;
}

上述代码中的 krlnew_thread 函数的流程非常简单,对参数进行合理检查,其参数从左到右分别是应用程序启动运行的地址、创建标志、进程权限和进程优先级、进程的应用程序栈和内核栈大小。进程对栈的大小有要求,如果小于默认大小 8 个页面就使用默认的栈大小,最后根据创建标志确认是建立内核态进程还是建立普通进程。

建立内核进程

内核进程就是用进程的方式去运行一段内核代码,那么这段代码就可以随时暂停或者继续运行,又或者和其它代码段并发运行,只是这种进程永远不会回到进程应用程序地址空间中去,只会在内核地址空间中运行。下面我来写代码实现建立一个内核态进程,如下所示。

thread_t *krlnew_kern_thread_core(void *filerun, uint_t flg, uint_t prilg, uint_t prity, size_t usrstksz, size_t krlstksz)
{
    thread_t *ret_td = NULL;
    bool_t acs = FALSE;
    adr_t krlstkadr = NULL;
    //分配内核栈空间
    krlstkadr = krlnew(krlstksz);
    if (krlstkadr == NULL)
    {
        return NULL;
    }
    //建立thread_t结构体的实例变量
    ret_td = krlnew_thread_dsc();
    if (ret_td == NULL)
    {//创建失败必须要释放之前的栈空间
        acs = krldelete(krlstkadr, krlstksz);
        if (acs == FALSE)
        {
            return NULL;
        }
        return NULL;
    }
    //设置进程权限 
    ret_td->td_privilege = prilg;
    //设置进程优先级
    ret_td->td_priority = prity;
    //设置进程的内核栈顶和内核栈开始地址
    ret_td->td_krlstktop = krlstkadr + (adr_t)(krlstksz - 1);
    ret_td->td_krlstkstart = krlstkadr;
    //初始化进程的内核栈
    krlthread_kernstack_init(ret_td, filerun, KMOD_EFLAGS);
    //加入进程调度系统
    krlschdclass_add_thread(ret_td);
    //返回进程指针
    return ret_td;
}

上述代码的逻辑非常简单,首先分配一个内核栈的内存空间,接着创建 thread_t 结构的实例变量,然后对 thtead_t 结构体的字段进行设置,最后,初始化进程内核栈把这个新进程加入到进程的调度系统之中,下面来一步步写入实现这些逻辑的代码。

创建 thread_t 结构

创建 thread_t 结构,其实就是分配一块内存用于存放 thread_t 结构的实例变量。类似这样的操作我们课程里做过多次,相信现在你已经能驾轻就熟了。下面我们来写代码实现这个操作,如下所示。

//初始化context_t结构
void context_t_init(context_t *initp)
{
    initp->ctx_nextrip = 0;
    initp->ctx_nextrsp = 0;
    //指向当前CPU的tss
    initp->ctx_nexttss = &x64tss[hal_retn_cpuid()];
    return;
}
//返回进程id其实就thread_t结构的地址
uint_t krlretn_thread_id(thread_t *tdp)
{
    return (uint_t)tdp;
}
//初始化thread_t结构
void thread_t_init(thread_t *initp)
{
    krlspinlock_init(&initp->td_lock);
    list_init(&initp->td_list);
    initp->td_flgs = TDFLAG_FREE;
    initp->td_stus = TDSTUS_NEW;//进程状态为新建
    initp->td_cpuid = hal_retn_cpuid();
    initp->td_id = krlretn_thread_id(initp);
    initp->td_tick = 0;
    initp->td_privilege = PRILG_USR;//普通进程权限
    initp->td_priority = PRITY_MIN;//最高优先级
    initp->td_runmode = 0;
    initp->td_krlstktop = NULL;
    initp->td_krlstkstart = NULL;
    initp->td_usrstktop = NULL;
    initp->td_usrstkstart = NULL;
    initp->td_mmdsc = &initmmadrsdsc;//指向默认的地址空间结构

    context_t_init(&initp->td_context);
    //初始化td_handtbl数组
    for (uint_t hand = 0; hand < TD_HAND_MAX; hand++)
    {
        initp->td_handtbl[hand] = NULL;
    }
    return;
}
//创建thread_t结构
thread_t *krlnew_thread_dsc()
{
    //分配thread_t结构大小的内存空间
    thread_t *rettdp = (thread_t *)(krlnew((size_t)(sizeof(thread_t))));
    if (rettdp == NULL)
    {
        return NULL;
    }
    //初始化刚刚分配的thread_t结构
    thread_t_init(rettdp);
    return rettdp;
}

相信凭你现在的能力,上述代码一定是超级简单的。不过我们依然要注意这样几点。首先,我们以 thread_t 结构的地址作为进程的 ID,这个 ID 具有唯一性;其次,我们目前没有为一个进程分配 mmadrsdsc_t 结构体,而是指向了默认的地址空间结构 initmmadrsdsc;最后,hal_retn_cpuid 函数在目前的情况下永远返回 0,这是因为我们使用了一个 CPU。

初始化内核栈

为什么要初始化进程的内核栈呢?你也许会想,进程的内核栈无非是一块内存,其实只要初始化为 0 就好。当然不是这么简单,我们初始化进程的内核栈,其实是为了在进程的内核栈中放置一份 CPU 的寄存器数据。

这份 CPU 寄存器数据是一个进程机器上下文的一部分,当一个进程开始运行时,我们将会使用“pop”指令从进程的内核栈中弹出到 CPU 中,这样 CPU 就开始运行进程了,CPU 的一些寄存器是有位置关系的,所以我们要定义一个结构体来操作它们,如下所示。

typedef struct s_INTSTKREGS
{
    uint_t r_gs;
    uint_t r_fs;
    uint_t r_es;
    uint_t r_ds;  //段寄存器
    uint_t r_r15;
    uint_t r_r14;
    uint_t r_r13;
    uint_t r_r12;
    uint_t r_r11;
    uint_t r_r10;
    uint_t r_r9;
    uint_t r_r8;
    uint_t r_rdi;
    uint_t r_rsi;
    uint_t r_rbp;
    uint_t r_rdx; //通用寄存器
    uint_t r_rcx;
    uint_t r_rbx;
    uint_t r_rax;
    uint_t r_rip_old;//程序的指针寄存器
    uint_t r_cs_old;//代码段寄存器
    uint_t r_rflgs; //rflags标志寄存
    uint_t r_rsp_old;//栈指针寄存器
    uint_t r_ss_old; //栈段寄存器
}intstkregs_t;

intstkregs_t 结构中,每个字段都是 8 字节 64 位的,因为 x86 CPU 在长模式下 rsp 栈指针寄存器始终 8 字节对齐。栈是向下伸长的(从高地址向低地址)所以这个结构是反向定义(相对于栈)

intstkregs_t 结构已经定义好了,下面我们来写代码初始化内核栈,如下所示。

void krlthread_kernstack_init(thread_t *thdp, void *runadr, uint_t cpuflags)
{
    //处理栈顶16字节对齐
    thdp->td_krlstktop &= (~0xf);
    thdp->td_usrstktop &= (~0xf);
    //内核栈顶减去intstkregs_t结构的大小
    intstkregs_t *arp = (intstkregs_t *)(thdp->td_krlstktop - sizeof(intstkregs_t));
    //把intstkregs_t结构的空间初始化为0
    hal_memset((void*)arp, 0, sizeof(intstkregs_t));
    //rip寄存器的值设为程序运行首地址 
    arp->r_rip_old = (uint_t)runadr;
    //cs寄存器的值设为内核代码段选择子 
    arp->r_cs_old = K_CS_IDX;
    arp->r_rflgs = cpuflags;
    //返回进程的内核栈
    arp->r_rsp_old = thdp->td_krlstktop;
    arp->r_ss_old = 0;
    //其它段寄存器的值设为内核数据段选择子
    arp->r_ds = K_DS_IDX;
    arp->r_es = K_DS_IDX;
    arp->r_fs = K_DS_IDX;
    arp->r_gs = K_DS_IDX;
    //设置进程下一次运行的地址为runadr
    thdp->td_context.ctx_nextrip = (uint_t)runadr;
    //设置进程下一次运行的栈地址为arp
    thdp->td_context.ctx_nextrsp = (uint_t)arp;
    return;
}

上述代码没什么难点,就是第 7 行我要给你解释一下,arp 为什么要用内核栈顶地址减去 intstkregs_t 结构的大小呢?C 语言处理结构体时,从结构体第一个字段到最后一个字段,这些字段的地址是从下向上(地址从到高)伸长的,而栈正好相反,所以要减去 intstkregs_t 结构的大小,为 intstkregs_t 结构腾出空间,如下图所示。

因为我们建立的是内核态进程,所以上面初始化的内核栈是不能返回到进程的应用程序空间的。而如果要返回到进程的应用程序空间中,内核栈中的内容是不同的,但是内核栈结构却一样。下面我们动手写代码,初始化返回进程应用程序空间的内核栈。请注意,初始化的还是内核栈,只是内容不同,代码如下所示。

void krlthread_userstack_init(thread_t *thdp, void *runadr, uint_t cpuflags)
{
    //处理栈顶16字节对齐
    thdp->td_krlstktop &= (~0xf);
    thdp->td_usrstktop &= (~0xf);
    //内核栈顶减去intstkregs_t结构的大小
    intstkregs_t *arp = (intstkregs_t *)(thdp->td_krlstktop - sizeof(intstkregs_t));
    //把intstkregs_t结构的空间初始化为0
    hal_memset((void*)arp, 0, sizeof(intstkregs_t));
    //rip寄存器的值设为程序运行首地址 
    arp->r_rip_old = (uint_t)runadr;
    //cs寄存器的值设为应用程序代码段选择子 
    arp->r_cs_old = U_CS_IDX;
    arp->r_rflgs = cpuflags;
    //返回进程应用程序空间的栈
    arp->r_rsp_old = thdp->td_usrstktop;
    //其它段寄存器的值设为应用程序数据段选择子
    arp->r_ss_old = U_DS_IDX;
    arp->r_ds = U_DS_IDX;
    arp->r_es = U_DS_IDX;
    arp->r_fs = U_DS_IDX;
    arp->r_gs = U_DS_IDX;
    //设置进程下一次运行的地址为runadr
    thdp->td_context.ctx_nextrip = (uint_t)runadr;
    //设置进程下一次运行的栈地址为arp
    thdp->td_context.ctx_nextrsp = (uint_t)arp;
    return;
}

上述代码中初始化进程的内核栈,所使用的段选择子指向的是应用程序的代码段和数据段,这个代码段和数据段它们特权级为 R3,CPU 正是根据这个代码段、数据段选择子来切换 CPU 工作特权级的。这样,CPU 的执行流就可以返回到进程的应用程序空间了。

建立普通进程

        在建立进程的接口函数 krlnew_thread 的流程中,会根据参数 flg 的值,选择调用不同的函数,来建立不同类型的进程。前面我们已经写好了建立内核进程的函数,接下来我们还要写好建立普通进程的函数,如下所示。

thread_t *krlnew_user_thread_core(void *filerun, uint_t flg, uint_t prilg, uint_t prity, size_t usrstksz, size_t krlstksz)
{
    thread_t *ret_td = NULL;
    bool_t acs = FALSE;
    adr_t usrstkadr = NULL, krlstkadr = NULL;
    //分配应用程序栈空间
    usrstkadr = krlnew(usrstksz);
    if (usrstkadr == NULL)
    {
        return NULL;
    }
    //分配内核栈空间
    krlstkadr = krlnew(krlstksz);
    if (krlstkadr == NULL)
    {
        if (krldelete(usrstkadr, usrstksz) == FALSE)
        {
            return NULL;
        }
        return NULL;
    }
    //建立thread_t结构体的实例变量
    ret_td = krlnew_thread_dsc();
    //创建失败必须要释放之前的栈空间
    if (ret_td == NULL)
    {
        acs = krldelete(usrstkadr, usrstksz);
        acs = krldelete(krlstkadr, krlstksz);
        if (acs == FALSE)
        {
            return NULL;
        }
        return NULL;
    }
    //设置进程权限 
    ret_td->td_privilege = prilg;
    //设置进程优先级
    ret_td->td_priority = prity;
    //设置进程的内核栈顶和内核栈开始地址
    ret_td->td_krlstktop = krlstkadr + (adr_t)(krlstksz - 1);
    ret_td->td_krlstkstart = krlstkadr;
    //设置进程的应用程序栈顶和内核应用程序栈开始地址
    ret_td->td_usrstktop = usrstkadr + (adr_t)(usrstksz - 1);
    ret_td->td_usrstkstart = usrstkadr;
    //初始化返回进程应用程序空间的内核栈
    krlthread_userstack_init(ret_td, filerun, UMOD_EFLAGS);
    //加入调度器系统
    krlschdclass_add_thread(ret_td);
    return ret_td;
}

        和建立内核进程相比,建立普通进程有两点不同。第一,多分配了一个应用程序栈。因为内核进程不会返回到进程的应用程序空间,所以不需要应用程序栈,而普通进程则需要;第二,在最后调用的是 krlthread_userstack_init 函数,该函数初始化返回进程应用程序空间的内核栈,这在前面已经介绍过了。到此为止,我们建立进程的功能已经实现了。但是最后将进程加入到调度系统的函数,我们还没有写,这个函数是进程调度器模块的函数

重点回顾

        首先,我们在 Linux 系统上,用 ps 命令列出 Linux 系统上所有的进程,直观的感受了一下什么进程,从理论上了解了一下进程的结构。

        然后我们把进程相关的信息,做了归纳整理,设计出一系列相应的数据结构,这其中包含了表示进程的数据结构,与进程相关内存地址空间结构,还有进程的机器上下文数据结构。这些数据结构综合起来就表示了进程。

        最后进入建立进程的环节。有了进程相关的数据结构就可以写代码建立一个进程了,我们的建立进程的接口函数,既能建立普通进程又能建立内核进程,而建立进程的过程无非是创建进程结构体、分配进程的内核栈与应用程序栈,并对进程的内核栈进行初始化,最后将进程加入调度系统,以便后面将进程投入运行。

        请问,各个进程是如何共享同一份内核代码和数据的?

        各个进程内核空间都使用同一份页表,通过页表映射到同一物理内存。

多进程如何调度

为什么需要多进程调度

我们先来搞清楚多进程调度的原因是什么,我来归纳一下。第一,CPU 同一时刻只能运行一个进程,而 CPU 个数总是比进程个数少,这就需要让多进程共用一个 CPU,每个进程在这个 CPU 上运行一段时间。第二点原因,当一个进程不能获取某种资源,导致它不能继续运行时,就应该让出 CPU。当然你也可以把第一点中的 CPU 时间,也归纳为一种资源,这样就合并为一点:进程拿不到资源就要让出 CPU。我来为你画幅图就明白了,如下所示。

 

上图中,有五个进程,其中浏览器进程和微信进程依赖于网络和键盘的数据资源,如果不能满足它们,就应该通过进程调度让出 CPU。

而两个科学计算进程,则更多的依赖于 CPU,但是如果它们中的一个用完了自己的 CPU 时间,也得借助进程调度让出 CPU,不然它就会长期霸占 CPU,导致其它进程无法运行。需要注意的是,每个进程都会依赖一种资源,那就是 CPU 时间,你可以把 CPU 时间理解为它就是 CPU,一个进程必须要有 CPU 才能运行。这里我们只需要明白,多个进程为什么要进行调度,就可以了。

进程的生命周期

人有生老病死,对于一个进程来说也是一样。一个进程从建立开始,接着运行,然后因为资源问题不得不暂停运行,最后退出系统。这一过程,我们称为进程的生命周期。在系统实现中,通常用进程的状态表示进程的生命周期。进程的状态我们用几个宏来定义,如下所示。

#define TDSTUS_RUN 0        //进程运行状态
#define TDSTUS_SLEEP 3      //进程睡眠状态
#define TDSTUS_WAIT 4       //进程等待状态
#define TDSTUS_NEW 5        //进程新建状态
#define TDSTUS_ZOMB 6       //进程僵死状态

可以发现,我们的进程有 5 个状态。其中进程僵死状态,表示进程将要退出系统不再进行调度。那么进程状态之间是如何转换的,别急,我来给画一幅图解释,如下所示。

 

上图中已经为你展示了,从建立进程到进程退出系统各状态之间的转换关系和需要满足的条件。

如何组织进程

首先我们来研究如何组织进程。由于系统中会有许多个进程,在上节课中我们用 thread_t 结构表示一个进程,因此会有多个 thread_t 结构。而根据刚才我们对进程生命周期的解读,我们又知道了进程是随时可能建立或者退出的,所以系统中会随时分配或者删除 thread_t 结构。

要应对这样的情况,最简单的办法就是使用链表数据结构,而且我们的进程有优先级,所以我们可以设计成每个优先级对应一个链表头。

下面我们来把设计落地成数据结构,由于这是调度器模块,所以我们要建立几个文件 krlsched.h、krlsched.c,在其中写上代码,如下所示。

typedef struct s_THRDLST
{
    list_h_t    tdl_lsth;                //挂载进程的链表头
    thread_t*   tdl_curruntd;            //该链表上正在运行的进程
    uint_t      tdl_nr;                  //该链表上进程个数
}thrdlst_t;
typedef struct s_SCHDATA
{
    spinlock_t  sda_lock;                //自旋锁
    uint_t      sda_cpuid;               //当前CPU id
    uint_t      sda_schdflgs;            //标志
    uint_t      sda_premptidx;           //进程抢占计数
    uint_t      sda_threadnr;            //进程数
    uint_t      sda_prityidx;            //当前优先级
    thread_t*   sda_cpuidle;             //当前CPU的空转进程
    thread_t*   sda_currtd;              //当前正在运行的进程
    thrdlst_t   sda_thdlst[PRITY_MAX];   //进程链表数组
}schdata_t;
typedef struct s_SCHEDCALSS
{
    spinlock_t  scls_lock;                //自旋锁
    uint_t      scls_cpunr;               //CPU个数
    uint_t      scls_threadnr;            //系统中所有的进程数
    uint_t      scls_threadid_inc;        //分配进程id所用
    schdata_t   scls_schda[CPUCORE_MAX];  //每个CPU调度数据结构
}schedclass_t;

从上述代码中,我们发现 schedclass_t 是个全局数据结构,这个结构里包含一个 schdata_t 结构数组,数组大小根据 CPU 的数量决定。在每个 schdata_t 结构中,又包含一个进程优先级大小的 thrdlst_t 结构数组。我画幅图,你就明白了。这幅图能让你彻底理清以上数据结构之间的关系。

 

下面我们就去定义这个 schedclass_t 数据结构并初始化。

管理进程的初始化

管理进程的初始化非常简单,就是对 schedclass_t 结构的变量的初始化。通过前面的学习,你也许已经发现了,schedclass_t 结构的变量应该是个全局变量,所以先得在 cosmos/kernel/krlglobal.c 文件中定义一个 schedclass_t 结构的全局变量,如下所示。

KRL_DEFGLOB_VARIABLE(schedclass_t,osschedcls);

有了 schedclass_t 结构的全局变量 osschedcls,接着我们在 cosmos/kernel/krlsched.c 文件中写好初始化 osschedcls 变量的代码,如下所示。

void thrdlst_t_init(thrdlst_t *initp)
{
    list_init(&initp->tdl_lsth); //初始化挂载进程的链表
    initp->tdl_curruntd = NULL; //开始没有运行进程 
    initp->tdl_nr = 0;  //开始没有进程
    return;
}
void schdata_t_init(schdata_t *initp)
{
    krlspinlock_init(&initp->sda_lock);
    initp->sda_cpuid = hal_retn_cpuid(); //获取CPU id
    initp->sda_schdflgs = NOTS_SCHED_FLGS;
    initp->sda_premptidx = 0;
    initp->sda_threadnr = 0;
    initp->sda_prityidx = 0;
    initp->sda_cpuidle = NULL; //开始没有空转进程和运行的进程
    initp->sda_currtd = NULL;
    for (uint_t ti = 0; ti < PRITY_MAX; ti++)
    {//初始化schdata_t结构中的每个thrdlst_t结构
        thrdlst_t_init(&initp->sda_thdlst[ti]);
    }
    return;
}
void schedclass_t_init(schedclass_t *initp)
{
    krlspinlock_init(&initp->scls_lock);
    initp->scls_cpunr = CPUCORE_MAX;  //CPU最大个数
    initp->scls_threadnr = 0;   //开始没有进程
    initp->scls_threadid_inc = 0;
    for (uint_t si = 0; si < CPUCORE_MAX; si++)
    {//初始化osschedcls变量中的每个schdata_t
        schdata_t_init(&initp->scls_schda[si]);
    }
    return;
}
void init_krlsched()
{   //初始化osschedcls变量
    schedclass_t_init(&osschedcls);
    return;
}

上述代码非常简单,由 init_krlsched 函数调用 schedclass_t_init 函数,对 osschedcls 变量进行初始化工作,但是 init_krlsched 函数由谁调用呢?

在这个函数中来调用 init_krlsched 函数,代码如下所示。

void init_krl()
{
    init_krlsched();
    die(0);//控制不让init_krl函数返回
    return;
}

至此,管理进程的初始化就完成了,其实这也是我们进程调度器的初始化,就是这么简单吗?当然不是,还有重要的进程调度等我们搞定。

设计实现进程调度器

管理进程的数据结构已经初始化好了,现在我们开始设计实现进程调度器。进程调度器是为了在合适的时间点,合适的代码执行路径上进行进程调度。说白了,就是从当前运行进程切换到另一个进程上运行,让当前进程停止运行,由 CPU 开始执行另一个进程的代码。这个事情说来简单,但做起来并不容易,下面我将带领你一步步实现进程调度器。

进程调度器入口

首先请你想象一下,进程调度器是什么样子的。其实,进程调度器不过是个函数,和其它函数并没有本质区别,你在其它很多代码执行路径上都可以调用它。只是它会从一个进程运行到下一个进程。

那这个函数的功能就能定下来了:无非是确定当前正在运行的进程,然后选择下一个将要运行的进程,最后从当前运行的进程,切换到下一个将要运行的进程。下面我们先来写好进程调度器的入口函数,如下所示。

void krlschedul()
{
    thread_t *prev = krlsched_retn_currthread(),//返回当前运行进程
             *next = krlsched_select_thread();//选择下一个运行的进程
    save_to_new_context(next, prev);//从当前进程切换到下一个进程
    return;
}

我们只要在任何需要调度进程的地方,调用上述代码中的函数就可以了。下面我们开始实现 krlschedul 函数中的其它功能逻辑。

如何获取当前运行的进程

获取当前正在运行的进程,目的是为了保存当前进程的运行上下文,确保在下一次调度到当前运行的进程时能够恢复运行。后面你就会看到,每次切换到下一个进程运行时,我们就会将下一个运行的进程设置为当前运行的进程。这个获取当前运行进程的函数,它的代码是这样的。

thread_t *krlsched_retn_currthread()
{
    uint_t cpuid = hal_retn_cpuid();
    //通过cpuid获取当前cpu的调度数据结构
    schdata_t *schdap = &osschedcls.scls_schda[cpuid];
    if (schdap->sda_currtd == NULL)
    {//若调度数据结构中当前运行进程的指针为空,就出错死机
        hal_sysdie("schdap->sda_currtd NULL");
    }
    return schdap->sda_currtd;//返回当前运行的进程
}

上述代码非常简单,如果你认真了解过前面组织进程的数据结构,就会发现,schdata_t 结构中的 sda_currtd 字段正是保存当前正在运行进程的地址。返回这个字段的值,就能取得当前正在运行的进程。

选择下一个进程

根据调度器入口函数的设计,取得了当前正在运行的进程之后,下一步就是选择下个将要投入运行的进程。在商业系统中,这个过程极为复杂。因为这个过程是进程调度算法的核心,它关乎到进程的吞吐量,能否及时响应请求,CPU 的利用率,各个进程之间运行获取资源的公平性,这些问题综合起来就会影响整个操作系统的性能、可靠性。

作为初学者,我们不必搞得如此复杂,可以使用一个简单的优先级调度算法,就是始终选择优先级最高的进程,作为下一个运行的进程。完成这个功能的代码,如下所示。

thread_t *krlsched_select_thread()
{
    thread_t *retthd, *tdtmp;
    cpuflg_t cufg;
    uint_t cpuid = hal_retn_cpuid();
    schdata_t *schdap = &osschedcls.scls_schda[cpuid];
    krlspinlock_cli(&schdap->sda_lock, &cufg);
    for (uint_t pity = 0; pity < PRITY_MAX; pity++)
    {//从最高优先级开始扫描
        if (schdap->sda_thdlst[pity].tdl_nr > 0)
        {//若当前优先级的进程链表不为空
            if (list_is_empty_careful(&(schdap->sda_thdlst[pity].tdl_lsth)) == FALSE)
            {//取出当前优先级进程链表下的第一个进程
                tdtmp = list_entry(schdap->sda_thdlst[pity].tdl_lsth.next, thread_t, td_list);
                list_del(&tdtmp->td_list);//脱链
                if (schdap->sda_thdlst[pity].tdl_curruntd != NULL)
                {//将这sda_thdlst[pity].tdl_curruntd的进程挂入链表尾
                    list_add_tail(&(schdap->sda_thdlst[pity].tdl_curruntd->td_list), &schdap->sda_thdlst[pity].tdl_lsth);
                }
                schdap->sda_thdlst[pity].tdl_curruntd = tdtmp;
                retthd = tdtmp;//将选择的进程放入sda_thdlst[pity].tdl_curruntd中,并返回
                goto return_step;
            }
            if (schdap->sda_thdlst[pity].tdl_curruntd != NULL)
            {//若sda_thdlst[pity].tdl_curruntd不为空就直接返回它
                retthd = schdap->sda_thdlst[pity].tdl_curruntd;
                goto return_step;
            }
        }
    }
    //如果最后也没有找到进程就返回默认的空转进程
    schdap->sda_prityidx = PRITY_MIN;
    retthd = krlsched_retn_idlethread();
return_step:
    //解锁并返回进程
    krlspinunlock_sti(&schdap->sda_lock, &cufg);
    return retthd;
}

上述代码的逻辑非常简单,我来给你梳理一下。首先,从高到低扫描优先级进程链表,然后若当前优先级进程链表不为空,就取出该链表上的第一个进程,放入 thrdlst_t 结构中的 tdl_curruntd 字段中,并把之前 thrdlst_t 结构的 tdl_curruntd 字段中的进程挂入该链表的尾部,并返回。最后,当扫描到最低优先级时也没有找到进程,就返回默认的空转进程。

获取空转进程

在选择下一个进程的函数中,如果没有找到合适的进程,就返回默认的空转进程。你可以想一下,为什么要有一个空转进程,直接返回 NULL 不行吗?还真不行,因为调度器的功能必须完成从一个进程到下一个进程的切换,如果没有下一个进程,而上一个进程又不能运行了,调度器将无处可去,整个系统也将停止运行,这当然不是我们要的结果,所以我们要给系统留下最后一条路。下面我们先来实现获取空转进程的函数,如下所示。

thread_t *krlsched_retn_idlethread()
{
    uint_t cpuid = hal_retn_cpuid();
    //通过cpuid获取当前cpu的调度数据结构
    schdata_t *schdap = &osschedcls.scls_schda[cpuid];
    if (schdap->sda_cpuidle == NULL)
    {//若调度数据结构中空转进程的指针为空,就出错死机
        hal_sysdie("schdap->sda_cpuidle NULL");
    }
    return schdap->sda_cpuidle;//返回空转进程
}

进程切换

经过前面的流程,我们已经找到了当前运行的进程 P1,和下一个将要运行的进程 P2,现在就进入最重要的进程切换流程。

在进程切换前,我们还要了解另一个重要的问题:进程在内核中函数调用路径,那什么是函数调用路径。

举个例子,比如进程 P1 调用了函数 A,接着在函数 A 中调用函数 B,然后在函数 B 中调用了函数 C,最后在函数 C 中调用了调度器函数 S,这个函数 A 到函数 S 就是进程 P1 的函数调用路径。

再比如,进程 P2 开始调用了函数 D,接着在函数 D 中调用函数 E,然后在函数 E 中又调用了函数 F,最后在函数 F 中调用了调度器函数 S,函数 D、E、F 到函数 S 就是进程 P2 的函数调用路径。

函数调用路径是通过栈来保存的,对于运行在内核空间中的进程,就是保存在对应的内核栈中。我为你准备了一幅图帮助理解。

以上就是进程 P1,P2 的函数调用路径,也是它们调用函数时各自内核栈空间状态的变化结果。说个题外话,你有没有发现。C 语言栈才是最高效内存管理,而且变量的生命周期也是妥妥的,比很多高级语言的内存垃圾回收器都牛。

有了前面的基础,现在我们来动手实现进程切换的函数。在这个函数中,我们要干这几件事。

首先,我们把当前进程的通用寄存器保存到当前进程的内核栈中;然后,保存 CPU 的 RSP 寄存器到当前进程的机器上下文结构中,并且读取保存在下一个进程机器上下文结构中的 RSP 的值,把它存到 CPU 的 RSP 寄存器中;接着,调用一个函数切换 MMU 页表;最后,从下一个进程的内核栈中恢复下一个进程的通用寄存器。

这样下一个进程就开始运行了,代码如下所示。

void save_to_new_context(thread_t *next, thread_t *prev)
{
    __asm__ __volatile__(
        "pushfq \n\t"//保存当前进程的标志寄存器
        "cli \n\t"  //关中断
        //保存当前进程的通用寄存器
        "pushq %%rax\n\t"
        "pushq %%rbx\n\t"
        "pushq %%rcx\n\t"
        "pushq %%rdx\n\t"
        "pushq %%rbp\n\t"
        "pushq %%rsi\n\t"
        "pushq %%rdi\n\t"
        "pushq %%r8\n\t"
        "pushq %%r9\n\t"
        "pushq %%r10\n\t"
        "pushq %%r11\n\t"
        "pushq %%r12\n\t"
        "pushq %%r13\n\t"
        "pushq %%r14\n\t"
        "pushq %%r15\n\t"
        //保存CPU的RSP寄存器到当前进程的机器上下文结构中
        "movq %%rsp,%[PREV_RSP] \n\t"
        //把下一个进程的机器上下文结构中的RSP的值,写入CPU的RSP寄存器中
        "movq %[NEXT_RSP],%%rsp \n\t"//事实上这里已经切换到下一个进程了,因为切换进程的内核栈    
        //调用__to_new_context函数切换MMU页表
        "callq __to_new_context\n\t"
        //恢复下一个进程的通用寄存器
        "popq %%r15\n\t"
        "popq %%r14\n\t"
        "popq %%r13\n\t"
        "popq %%r12\n\t"
        "popq %%r11\n\t"
        "popq %%r10\n\t"
        "popq %%r9\n\t"
        "popq %%r8\n\t"
        "popq %%rdi\n\t"
        "popq %%rsi\n\t"
        "popq %%rbp\n\t"
        "popq %%rdx\n\t"
        "popq %%rcx\n\t"
        "popq %%rbx\n\t"
        "popq %%rax\n\t"
        "popfq \n\t"      //恢复下一个进程的标志寄存器
        //输出当前进程的内核栈地址
        : [ PREV_RSP ] "=m"(prev->td_context.ctx_nextrsp)
        //读取下一个进程的内核栈地址
        : [ NEXT_RSP ] "m"(next->td_context.ctx_nextrsp), "D"(next), "S"(prev)//为调用__to_new_context函数传递参数
        : "memory");
    return;
}

通过切换进程的内核栈,导致切换进程,因为进程的函数调用路径就保存在对应的内核栈中,只要调用 krlschedul 函数,最后的函数调用路径一定会停在 save_to_new_context 函数中,当 save_to_new_context 函数一返回,就会导致回到调用 save_to_new_context 函数的下一行代码开始运行,在这里就是返回到 krlschedul 函数中,最后层层返回。

结合上图,你就能理解这个进程切换的原理了。同时你也会发现一个问题,就是这个切换机制能够正常运行,必须保证下一个进程已经被调度过,也就是它调用执行过 krlschedul 函数。那么已知新建进程绝对没有调用过 krlschedul 函数,所以它得进行特殊处理。我们在 __to_new_context 函数中完成这个特殊处理,代码如下所示。

void __to_new_context(thread_t *next, thread_t *prev)
{
    uint_t cpuid = hal_retn_cpuid();
    schdata_t *schdap = &osschedcls.scls_schda[cpuid];
    //设置当前运行进程为下一个运行的进程
    schdap->sda_currtd = next;
    //设置下一个运行进程的tss为当前CPU的tss
    next->td_context.ctx_nexttss = &x64tss[cpuid];
    //设置当前CPU的tss中的R0栈为下一个运行进程的内核栈
    next->td_context.ctx_nexttss->rsp0 = next->td_krlstktop;
    //装载下一个运行进程的MMU页表
    hal_mmu_load(&next->td_mmdsc->msd_mmu);
    if (next->td_stus == TDSTUS_NEW)
    {   //如果是新建进程第一次运行就要进行处理
        next->td_stus = TDSTUS_RUN;
        retnfrom_first_sched(next);
    }
    return;
}

上面代码的注释已经很清楚了,__to_new_context 负责设置当前运行的进程,处理 CPU 发生中断时需要切换栈的问题,又切换了一个进程的 MMU 页表(即使用新进程的地址空间),最后如果是新建进程第一次运行,就调用 retnfrom_first_sched 函数进行处理。下面我们来写好这个函数。

void retnfrom_first_sched(thread_t *thrdp)
{
    __asm__ __volatile__(
        "movq %[NEXT_RSP],%%rsp\n\t"  //设置CPU的RSP寄存器为该进程机器上下文结构中的RSP
        //恢复进程保存在内核栈中的段寄存器
        "popq %%r14\n\t"
        "movw %%r14w,%%gs\n\t"
        "popq %%r14\n\t"
        "movw %%r14w,%%fs\n\t"
        "popq %%r14\n\t"
        "movw %%r14w,%%es\n\t"
        "popq %%r14\n\t"
        "movw %%r14w,%%ds\n\t"
        //恢复进程保存在内核栈中的通用寄存器
        "popq %%r15\n\t"
        "popq %%r14\n\t"
        "popq %%r13\n\t"
        "popq %%r12\n\t"
        "popq %%r11\n\t"
        "popq %%r10\n\t"
        "popq %%r9\n\t"
        "popq %%r8\n\t"
        "popq %%rdi\n\t"
        "popq %%rsi\n\t"
        "popq %%rbp\n\t"
        "popq %%rdx\n\t"
        "popq %%rcx\n\t"
        "popq %%rbx\n\t"
        "popq %%rax\n\t"
        //恢复进程保存在内核栈中的RIP、CS、RFLAGS,(有可能需要恢复进程应用程序的RSP、SS)寄存器
        "iretq\n\t"
        :
        : [ NEXT_RSP ] "m"(thrdp->td_context.ctx_nextrsp)
        : "memory");
}

retnfrom_first_sched 函数不会返回到调用它的 __to_new_context 函数中,而是直接运行新建进程的相关代码(如果你不理解这段代码的原理,可以回顾上一课,看看建立进程时,对进程内核栈进行的初始化工作)。

重点回顾

这节课我们从了解为什么需要多进程调度开始,随后实现子调度管理多个进程,最终实现了进程调度器,这里面有很多重要的知识点,我来为你梳理一下。

1.为什么需要多进程调度?我们分析了系统中总有些资源不能满足每个进程的需求,所以一些进程必须要走走停停,这就需要不同的进程来回切换到 CPU 上运行,为了实现这个机制就需要多讲行调度。

2.组织多个进程。为了实现进程管理,必须要组织多个进程。我们设计了调度器数据结构,在该结构中,我们使用优先级链表数组来组织多个进程,并且对这些数据结构的变量进行了初始化。

3.进程调度。有了多个进程就需要进程调度,我们的进程调度器是一个函数,在这个函数中选择了当前运行进程和下一个将要运行的进程,如果实在没有可运行的进程就选择空转进程,最后关键是进程间切换,我们是通过切换进程的内核栈来切换进程的函数调用路径,当调度器函数返回的时候已经是另一个进程了。

如何实现进程的等待与唤醒机制

进程的等待与唤醒

进程得不到所需的资源时就会进入等待状态,直到这种资源可用,才会被唤醒。那进程的等待与唤醒机制该如何设计?

进程等待结构

在实现进程的等待与唤醒机制之前,需要设计一种数据结构,用于挂载等待的经常,在唤醒的时候才可以找到那些等待的进程,代码如下:

typedef struct s_KWLST
{   
    spinlock_t wl_lock;  //自旋锁
    uint_t   wl_tdnr;    //等待进程的个数
    list_h_t wl_list;    //挂载等待进程的链表头
}kwlst_t;

这个结构在讲信号量时见过,因为它经常被包含在信号量等上层结构中,而信号量结构,通常用于保护访问受限的共享资源。

进程等待

让进程进入等待状态的机制,它也是一个函数。这个函数会设置进程状态为等待状态,让进程从调度系统数据结构中脱离,最后让进程加入到 kwlst_t 等待结构中,代码如下所示。

void krlsched_wait(kwlst_t *wlst)
{
    cpuflg_t cufg, tcufg;
    uint_t cpuid = hal_retn_cpuid();
    schdata_t *schdap = &osschedcls.scls_schda[cpuid];
    //获取当前正在运行的进程
    thread_t *tdp = krlsched_retn_currthread();
    uint_t pity = tdp->td_priority;
    krlspinlock_cli(&schdap->sda_lock, &cufg);
    krlspinlock_cli(&tdp->td_lock, &tcufg);
    tdp->td_stus = TDSTUS_WAIT;//设置进程状态为等待状态
    list_del(&tdp->td_list);//脱链
    krlspinunlock_sti(&tdp->td_lock, &tcufg);
    if (schdap->sda_thdlst[pity].tdl_curruntd == tdp)
    {
        schdap->sda_thdlst[pity].tdl_curruntd = NULL;
    }
    schdap->sda_thdlst[pity].tdl_nr--;
    krlspinunlock_sti(&schdap->sda_lock, &cufg);
    krlwlst_add_thread(wlst, tdp);//将进程加入等待结构中
    return;
}

有一点需要注意,这个函数使进程进入等待状态,而这个进程是当前正在运行的进程,而当前正在运行的进程正是调用这个函数的进程,所以一个进程想要进入等待状态,只要调用这个函数就好了。

进程唤醒

进程的唤醒则是进程等待的反向操作行为,即从等待数据结构中获取进程,然后设置进程的状态为运行状态,最后将这个进程加入到进程调度系统数据结构中。这个函数的代码如下所示。

void krlsched_up(kwlst_t *wlst)
{
    cpuflg_t cufg, tcufg;
    uint_t cpuid = hal_retn_cpuid();
    schdata_t *schdap = &osschedcls.scls_schda[cpuid];
    thread_t *tdp;
    uint_t pity;
    //取出等待数据结构第一个进程并从等待数据结构中删除
    tdp = krlwlst_del_thread(wlst);
    pity = tdp->td_priority;//获取进程的优先级
    krlspinlock_cli(&schdap->sda_lock, &cufg);
    krlspinlock_cli(&tdp->td_lock, &tcufg);
    tdp->td_stus = TDSTUS_RUN;//设置进程的状态为运行状态
    krlspinunlock_sti(&tdp->td_lock, &tcufg);
    list_add_tail(&tdp->td_list, &(schdap->sda_thdlst[pity].tdl_lsth));//加入进程优先级链表
    schdap->sda_thdlst[pity].tdl_nr++;
    krlspinunlock_sti(&schdap->sda_lock, &cufg);
    return;
}

空转进程

空转进程是系统下的第一个进程。空转进程是操作系统在没任何进程可以调度运行的时候,选择调度空转进程运行,可以说空转进程是进程调度器的最后的选择
注:这个最后的选择一定要有,现在几乎所有的操作系统,都有一个或者几个空转进程(多CPU的情况,每个CPU一个空转进程)

建立空转进程

我们的Cosmos的空转进程是个内核进程,按照常理,只要调用上节实现的建立进程的接口,创建一个黑河进程就好了。

但是我们的空转进程有点特殊,它是内核进程没错,但它不加入调度系统,而是一个专门的指针指向它。

由于空转进程是个独立的模块,需要建立一个新的C语言文件 Cosmos/kernel/krlcpuidle.c,代码如下:


thread_t *new_cpuidle_thread()
{

    thread_t *ret_td = NULL;
    bool_t acs = FALSE;
    adr_t krlstkadr = NULL;
    uint_t cpuid = hal_retn_cpuid();
    schdata_t *schdap = &osschedcls.scls_schda[cpuid];
    krlstkadr = krlnew(DAFT_TDKRLSTKSZ);//分配进程的内核栈
    if (krlstkadr == NULL)
    {
        return NULL;
    }
    //分配thread_t结构体变量
    ret_td = krlnew_thread_dsc();
    if (ret_td == NULL)
    {
        acs = krldelete(krlstkadr, DAFT_TDKRLSTKSZ);
        if (acs == FALSE)
        {
            return NULL;
        }
        return NULL;
    }
    //设置进程具有系统权限
    ret_td->td_privilege = PRILG_SYS;
    ret_td->td_priority = PRITY_MIN;
    //设置进程的内核栈顶和内核栈开始地址
    ret_td->td_krlstktop = krlstkadr + (adr_t)(DAFT_TDKRLSTKSZ - 1);
    ret_td->td_krlstkstart = krlstkadr;
    //初始化进程的内核栈
    krlthread_kernstack_init(ret_td, (void *)krlcpuidle_main, KMOD_EFLAGS);
    //设置调度系统数据结构的空转进程和当前进程为ret_td
    schdap->sda_cpuidle = ret_td;
    schdap->sda_currtd = ret_td;
    return ret_td;
}
//新建空转进程
void new_cpuidle()
{
    thread_t *thp = new_cpuidle_thread();//建立空转进程
    if (thp == NULL)
    {//失败则主动死机
        hal_sysdie("newcpuilde err");
    }
    kprint("CPUIDLETASK: %x\n", (uint_t)thp);
    return;
}

建立空转进程由 new_cpuidle 函数调用 new_cpuidle_thread 函数完成,new_cpuidle_thread 函数的操作和前面建立内核进程差不多,只不过在函数的最后,让调度系统数据结构的空转进程和当前进程的指针,指向了刚刚建立的进程。

上述代码中调用初始内核栈函数时,将 krlcpuidle_main 函数传了进去,这就是空转进程的主函数,下面我们来写好。

void krlcpuidle_main()
{
    uint_t i = 0;
    for (;; i++)
    {
        kprint("空转进程运行:%x\n", i);//打印
        krlschedul();//调度进程
    }
    return;
}

空转进程的主函数本质就是个死循环,在死循环中打印一行信息,然后进行进程调度,这个函数就是永无休止地执行这两个步骤。

空转进程运行

由于是第一进程,所以没法用调度器来调度它,我们得手动启动它,才可以运行。上节已经写了启动一个新建进程运行的函数,这里只需要调用它就好:

void krlcpuidle_start()
{
    uint_t cpuid = hal_retn_cpuid();
    schdata_t *schdap = &osschedcls.scls_schda[cpuid];
    //取得空转进程
    thread_t *tdp = schdap->sda_cpuidle;
    //设置空转进程的tss和R0特权级的栈
    tdp->td_context.ctx_nexttss = &x64tss[cpuid];
    tdp->td_context.ctx_nexttss->rsp0 = tdp->td_krlstktop;
    //设置空转进程的状态为运行状态
    tdp->td_stus = TDSTUS_RUN;
    //启动进程运行
    retnfrom_first_sched(tdp);
    return;
}

首先就是取出空转进程,然后设置一下机器上下文结构和运行状态,最后调用 retnfrom_first_sched 函数,恢复进程内核栈中的内容,让进程启动运行。

不过这还没完,我们应该把建立空转进程和启动空转进程运行函数封装起来,放在一个初始化空转进程的函数中,并在内核层初始化 init_krl 函数的最后调用,代码如下所示。

void init_krl()
{
    init_krlsched();//初始化进程调度器
    init_krlcpuidle();//初始化空转进程
    die(0);//防止init_krl函数返回
    return;
}
//初始化空转进程
void init_krlcpuidle()
{
    new_cpuidle();//建立空转进程
    krlcpuidle_start();//启动空转进程运行
    return;
}

效果图:

 

现在空转进程和调度器输出的信息在屏幕上交替滚动出现,这说明我们的空转进程和进程调度器都已经正常工作了。

多进程运行

虽然我们的空转进程和调度器已经正常工作了,但你可能心里会有疑问,我们系统中就一个空转进程,那怎么证明我们进程调度器是正常工作的呢?

现在想要看看多个进程会是什么情况,就需要建立多个进程。下面我们马上就来实现这个想法,代码如下。

void thread_a_main()//进程A主函数
{
    uint_t i = 0;
    for (;; i++) {
        kprint("进程A运行:%x\n", i);
        krlschedul();
    }
    return;
}
void thread_b_main()//进程B主函数
{
    uint_t i = 0;
    for (;; i++) {
        kprint("进程B运行:%x\n", i);
        krlschedul();
    }
    return;
}
void init_ab_thread()
{
    krlnew_thread((void*)thread_a_main, KERNTHREAD_FLG, 
                PRILG_SYS, PRITY_MIN, DAFT_TDUSRSTKSZ, DAFT_TDKRLSTKSZ);//建立进程A
    krlnew_thread((void*)thread_b_main, KERNTHREAD_FLG, 
                PRILG_SYS, PRITY_MIN, DAFT_TDUSRSTKSZ, DAFT_TDKRLSTKSZ);//建立进程B
    return;
}
void init_krlcpuidle()
{
    new_cpuidle();//建立空转进程
    init_ab_thread();//初始化建立A、B进程
    krlcpuidle_start();//开始运行空转进程
    return;
}

在 init_ab_thread 函数中建立两个内核进程,分别运行两个函数,这两个函数会打印信息,init_ab_thread 函数由 init_krlcpuidle 函数调用。这样在初始化空转进程的时候,就建立了进程 A 和进程 B。

效果如下:

 

进程 A 和进程 B 在调度器的调度下交替运行,而空转进程不再运行,这表明我们的多进程机制完全正确。

小结

本节实现了进程的等待与唤醒机制,然后建立了空转进程,最后对进程调度进行了测试。

  • 1、等待唤醒机制。为了让进程能进入等待状态随后又能在其它条件满足的情况下被唤醒,我们实现了进程等待和唤醒机制。

  • 2、空转进程。是我们 Cosmos 系统下的第一个进程,它只干一件事情就是调用调度器函数调度进程,在系统中没有其它可以运行进程时,调度器又会调度空转进程,形成了一个闭环。

  • 3、测试。为了验证我们的进程调度器是否是正常工作的,我们建立了两个进程,让它们运行,结果在屏幕上出现了它们交替输出的信息。这证明了我们的进程调度器是功能正常的。

你也许发现了,我们的进程中都调用了 krlschedul 函数,不调用它就是始终只有一个进程运行了,你在开发应用程序中,需要调用调度器主动让出 CPU 吗?
这是什么原因呢?这是因为我们的 Cosmos 没有定时器驱动,系统的 TICK 机制无法工作,一旦我们系统 TICK 机开始工作,就能控制进程运行了多长时间,然后强制调度进程。

Linux如何实现进程与进程调度?

Linux如何表示进程

在Cosmos中,设计了一个thread_t数据结构来代表一个进程,Linux也同样是用一个数据结构表示进程。

Linux进程的数据结构

在Linux系统下,把运行中的应用程序抽象成一个数据结构task_struct,一个应用程序所需要的各种资源,如内存、文件等都包含在task_struct结构中。

因此,task_struct结构是一个非常巨大的数据结构,代码如下:

struct task_struct {
    struct thread_info thread_info;//处理器特有数据 
    volatile long   state;       //进程状态 
    void            *stack;      //进程内核栈地址 
    refcount_t      usage;       //进程使用计数
    int             on_rq;       //进程是否在运行队列上
    int             prio;        //动态优先级
    int             static_prio; //静态优先级
    int             normal_prio; //取决于静态优先级和调度策略
    unsigned int    rt_priority; //实时优先级
    const struct sched_class    *sched_class;//指向其所在的调度类
    struct sched_entity         se;//普通进程的调度实体
    struct sched_rt_entity      rt;//实时进程的调度实体
    struct sched_dl_entity      dl;//采用EDF算法调度实时进程的调度实体
    struct sched_info       sched_info;//用于调度器统计进程的运行信息 
    struct list_head        tasks;//所有进程的链表
    struct mm_struct        *mm;  //指向进程内存结构
    struct mm_struct        *active_mm;
    pid_t               pid;            //进程id
    struct task_struct __rcu    *parent;//指向其父进程
    struct list_head        children; //链表中的所有元素都是它的子进程
    struct list_head        sibling;  //用于把当前进程插入到兄弟链表中
    struct task_struct      *group_leader;//指向其所在进程组的领头进程
    u64             utime;   //用于记录进程在用户态下所经过的节拍数
    u64             stime;   //用于记录进程在内核态下所经过的节拍数
    u64             gtime;   //用于记录作为虚拟机进程所经过的节拍数
    unsigned long           min_flt;//缺页统计 
    unsigned long           maj_flt;
    struct fs_struct        *fs;    //进程相关的文件系统信息
    struct files_struct     *files;//进程打开的所有文件
    struct vm_struct        *stack_vm_area;//内核栈的内存区
  };

在task_struct 结构体中,省略了进程的权能、性能跟踪、信号、numa、cgroup等相关的仅500行内容,可参考代码,在这里,只需要知道,在内存中,一个task_struct结构体的实例变量代表一个Linux进程

创建task_struct结构

Linux创建task_struct结构体的实例变量,这里只关注早期和最新的创建方式。

Linux早期创建task_struct结构体的实例变量:找伙伴内存管理系统,分配两个连续的页面(即8KB),作为进程的内核栈,再把task_struct结构体的实例变量放在这8KB内存空间的开始地址处。内核栈则是从上向下伸长的,task_struct数据结构是从下向上伸长的。
示意图如下:进程内核栈

Linux 把 task_struct 结构和内核栈放在了一起 ,所以我们只要把 RSP 寄存器的值读取出来,然后将其低 13 位清零,就得到了当前 task_struct 结构体的地址。由于内核栈比较大,而且会向下伸长,覆盖掉 task_struct 结构体内容的概率就很小。

 

随着 Linux 版本的迭代,task_struct 结构体的体积越来越大,从前 task_struct 结构体和内核栈放在一起的方式就不合适了。

最新的版本是分开放的,代码如下

static unsigned long *alloc_thread_stack_node(struct task_struct *tsk, int node)
{
    struct page *page = alloc_pages_node(node, THREADINFO_GFP,
                         THREAD_SIZE_ORDER);//分配两个页面
    if (likely(page)) {
        tsk->stack = kasan_reset_tag(page_address(page));
        return tsk->stack;//让task_struct结构的stack字段指向page的地址
    }
    return NULL;
}

static inline struct task_struct *alloc_task_struct_node(int node)
{
    return kmem_cache_alloc_node(task_struct_cachep, GFP_KERNEL, node);//在task_struct_cachep内存对象中分配一个task_struct结构休对象 
}
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
    struct task_struct *tsk; unsigned long *stack;
    tsk = alloc_task_struct_node(node);//分配task_struct结构体
    if (!tsk)
        return NULL;
    stack = alloc_thread_stack_node(tsk, node);//分配内核栈
    tsk->stack = stack;
    return tsk;
}
static __latent_entropy struct task_struct *copy_process(
                    struct pid *pid, int trace, int node,
                    struct kernel_clone_args *args)
{
    int pidfd = -1, retval;
    struct task_struct *p;
    //……
    retval = -ENOMEM;
    p = dup_task_struct(current, node);//分配task_struct和内核栈
    //……
    return ERR_PTR(retval);
}

pid_t kernel_clone(struct kernel_clone_args *args)
{
    u64 clone_flags = args->flags;
    struct task_struct *p;
    pid_t nr;
    //……
    //复制进程
    p = copy_process(NULL, trace, NUMA_NO_NODE, args);
    //……
    return nr;
}
//建立进程接口
SYSCALL_DEFINE0(fork)
{
    struct kernel_clone_args args = {
        .exit_signal = SIGCHLD,
    };
    return kernel_clone(&args);
}

这里不会讨论Linux的fork函数,只要知道,它负责建立一个与父进程相同的进程,也就是复制了父进程的一系列数据

要复制父进程的数据必须要分配内存,上面的代码完整展示了从SLAB中分配task_struct结构,以及从伙伴内存系统分配内核栈的过程

Linux进程地址空间

Linux也是支持虚拟内存的操作系统内核,采用mm_struct结构描述一个进程的地址空间的数据结构。代码如下:

struct mm_struct {
        struct vm_area_struct *mmap; //虚拟地址区间链表VMAs
        struct rb_root mm_rb;   //组织vm_area_struct结构的红黑树的根
        unsigned long task_size;    //进程虚拟地址空间大小
        pgd_t * pgd;        //指向MMU页表
        atomic_t mm_users; //多个进程共享这个mm_struct
        atomic_t mm_count; //mm_struct结构本身计数 
        atomic_long_t pgtables_bytes;//页表占用了多个页
        int map_count;      //多少个VMA
        spinlock_t page_table_lock; //保护页表的自旋锁
        struct list_head mmlist; //挂入mm_struct结构的链表
        //进程应用程序代码开始、结束地址,应用程序数据的开始、结束地址 
        unsigned long start_code, end_code, start_data, end_data;
        //进程应用程序堆区的开始、当前地址、栈开始地址 
        unsigned long start_brk, brk, start_stack;
        //进程应用程序参数区开始、结束地址
        unsigned long arg_start, arg_end, env_start, env_end;
};

当然,整个mm_struct结构也精简了很多,其中vm_area_struct结构,相当于我们Cosmos的kmvarsdsc_t结构,用来描述一段虚拟地址空间。mm_struct结构中也包含了MMU页表相关的信息

mm_struct 结构是如何建立对应的实例变量呢?代码如下所示。

//在mm_cachep内存对象中分配一个mm_struct结构休对象
#define allocate_mm()   (kmem_cache_alloc(mm_cachep, GFP_KERNEL))
static struct mm_struct *dup_mm(struct task_struct *tsk,
                struct mm_struct *oldmm)
{
    struct mm_struct *mm;
    //分配mm_struct结构
    mm = allocate_mm();
    if (!mm)
        goto fail_nomem;
    //复制mm_struct结构
    memcpy(mm, oldmm, sizeof(*mm));
    //……
    return mm;
}
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
    struct mm_struct *mm, *oldmm;
    int retval;
    tsk->min_flt = tsk->maj_flt = 0;
    tsk->nvcsw = tsk->nivcsw = 0;
    retval = -ENOMEM;
    mm = dup_mm(tsk, current->mm);//分配mm_struct结构的实例变量
    if (!mm)
        goto fail_nomem;
good_mm:
    tsk->mm = mm;
    tsk->active_mm = mm;
    return 0;
fail_nomem:
    return retval;
}

上述代码的 copy_mm 函数正是在 copy_process 函数中被调用的, copy_mm 函数调用 dup_mm 函数,把当前进程的 mm_struct 结构复制到 allocate_mm 宏分配的一个 mm_struct 结构中。这样,一个新进程的 mm_struct 结构就建立了。

Linux进程文件表

在 Linux 系统中,可以说万物皆为文件,比如文件、设备文件、管道文件等。一个进程对一个文件进行读写操作之前,必须先打开文件,这个打开的文件就记录在进程的文件表中,它由 task_struct 结构中的 files 字段指向。这里指向的其实是个 files_struct结构,代码如下:

struct files_struct {
    atomic_t count;//自动计数
    struct fdtable __rcu *fdt;
    struct fdtable fdtab;
    spinlock_t file_lock; //自旋锁
    unsigned int next_fd;//下一个文件句柄
    unsigned long close_on_exec_init[1];//执行exec()时要关闭的文件句柄
    unsigned long open_fds_init[1];
    unsigned long full_fds_bits_init[1];
    struct file __rcu * fd_array[NR_OPEN_DEFAULT];//默认情况下打开文件的指针数组
};

从上述代码中,可以推想出我们在应用软件中调用:int fd = open("/tmp/test.txt"); 实际 Linux 会建立一个 struct file 结构体实例变量与文件对应,然后把 struct file 结构体实例变量的指针放入 fd_array 数组中。

那么 Linux 在建立一个新进程时,怎样给新进程建立一个 files_struct 结构呢?其实很简单,也是复制当前进程的 files_struct 结构,代码如下所示。

static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
    struct files_struct *oldf, *newf;
    int error = 0;
    oldf = current->files;//获取当前进程的files_struct的指针
    if (!oldf)
        goto out;


    if (clone_flags & CLONE_FILES) {
        atomic_inc(&oldf->count);
        goto out;
    }
    //分配新files_struct结构的实例变量,并复制当前的files_struct结构
    newf = dup_fd(oldf, NR_OPEN_MAX, &error);
    if (!newf)
        goto out;


    tsk->files = newf;//新进程的files_struct结构指针指向新的files_struct结构
    error = 0;
out:
    return error;

同样的,copy_files 函数由 copy_process 函数调用,copy_files 最终会复制当前进程的 files_struct 结构到一个新的 files_struct 结构实例变量中,并让新进程的 files 指针指向这个新的 files_struct 结构实例变量。

Linux进程调度

Linux 支持多 CPU 上运行多进程,这就要说到多进程调度了。Linux 进程调度支持多种调度算法,有基于优先级的调度算法,有实时调度算法,有完全公平调度算法(CFQ)

以CFQ为例,了解CFQ相关的数据结构,探讨CFQ算法怎样实现。

进程调度实体

先来看看什么是进程调度实体,它是干什么的呢?

它其实是 Linux 进程调度系统的一部分,被嵌入到了 Linux 进程数据结构中,与调度器进行关联,能间接地访问进程,这种高内聚低耦合的方式,保证了进程数据结构和调度数据结构相互独立,该代码如下:

struct sched_entity {
    struct load_weight load;//表示当前调度实体的权重
    struct rb_node run_node;//红黑树的数据节点
    struct list_head group_node;// 链表节点,被链接到 percpu 的 rq->cfs_tasks
    unsigned int on_rq; //当前调度实体是否在就绪队列上
    u64 exec_start;//当前实体上次被调度执行的时间
    u64 sum_exec_runtime;//当前实体总执行时间
    u64 prev_sum_exec_runtime;//截止到上次统计,进程执行的时间
    u64 vruntime;//当前实体的虚拟时间
    u64 nr_migrations;//实体执行迁移的次数 
    struct sched_statistics statistics;//统计信息包含进程的睡眠统计、等待延迟统计、CPU迁移统计、唤醒统计等。
#ifdef CONFIG_FAIR_GROUP_SCHED
    int depth;// 表示当前实体处于调度组中的深度
    struct sched_entity *parent;//指向父级调度实体
    struct cfs_rq *cfs_rq;//当前调度实体属于的 cfs_rq.
    struct cfs_rq *my_q;
#endif
#ifdef CONFIG_SMP
    struct sched_avg avg ;// 记录当前实体对于CPU的负载
#endif
};

现在需要知道的是在 task_struct 结构中,会包含至少一个 sched_entity 结构的变量,如下图所示。
调度实体在进程结构中的位置示意图:

 

结合图示,我们只要通过 sched_entity 结构变量的地址,减去它在 task_struct 结构中的偏移(由编译器自动计算),就能获取到 task_struct 结构的地址。这样就能达到通过 sched_entity 结构,访问 task_struct 结构的目的了。

进程运行队列

那么,在 Linux 中,又是怎样组织众多调度实体,进而组织众多进程,方便进程调度器找到调度实体呢?

首先,Linux 定义了一个进程运行队列结构,每个 CPU 分配一个这样的进程运行队列结构实例变量,进程运行队列结构的代码如下。

struct rq {
    raw_spinlock_t      lock;//自旋锁
    unsigned int        nr_running;//多个就绪运行进程
    struct cfs_rq       cfs; //作用于完全公平调度算法的运行队列
    struct rt_rq        rt;//作用于实时调度算法的运行队列
    struct dl_rq        dl;//作用于EDF调度算法的运行队列
    struct task_struct __rcu    *curr;//这个运行队列当前正在运行的进程
    struct task_struct  *idle;//这个运行队列的空转进程
    struct task_struct  *stop;//这个运行队列的停止进程
    struct mm_struct    *prev_mm;//这个运行队列上一次运行进程的mm_struct
    unsigned int        clock_update_flags;//时钟更新标志
    u64         clock; //运行队列的时间 
    //后面的代码省略
};

重点理解的是,其中 task_struct 结构指针是为了快速访问特殊进程,而 rq 结构并不直接关联调度实体,而是包含了 cfs_rq、rt_rq、dl_rq,通过它们来关联调度实体。

有三个不同的运行队列,是因为作用于三种不同的调度算法,这里只需要关注cfs_rq,

struct rb_root_cached {
    struct rb_root rb_root;   //红黑树的根
    struct rb_node *rb_leftmost;//红黑树最左子节点
};
struct cfs_rq {
    struct load_weight  load;//cfs_rq上所有调度实体的负载总和
    unsigned int nr_running;//cfs_rq上所有的调度实体不含调度组中的调度实体
    unsigned int h_nr_running;//cfs_rq上所有的调度实体包含调度组中所有调度实体
    u64         exec_clock;//当前 cfs_rq 上执行的时间 
    u64         min_vruntime;//最小虚拟运行时间
    struct rb_root_cached   tasks_timeline;//所有调度实体的根
    struct sched_entity *curr;//当前调度实体
    struct sched_entity *next;//下一个调度实体
    struct sched_entity *last;//上次执行过的调度实体
    //省略不关注的代码
};

为了简化问题,上述代码中我省略了调度组和负载相关的内容。你也许已经看出来了,其中 load、exec_clock、min_vruntime、tasks_timeline 字段是 CFS 调度算法得以实现的关键,你甚至可以猜出所有的调度实体,都是通过红黑树组织起来的,即 cfs_rq 结构中的 tasks_timeline 字段。

调度实体和运行队列的关系

rq、cfs_rq、rb_root_cached、sched_entity、task_struct 等数据结构,下面我们来看看它的组织关系
运行队列框架示意图:

结合图片我们发现,task_struct 结构中包含了 sched_entity 结构。sched_entity 结构是通过红黑树组织起来的,红黑树的根在 cfs_rq 结构中,cfs_rq 结构又被包含在 rq 结构,每个 CPU 对应一个 rq 结构。这样,我们就把所有运行的进程组织起来了。

 

调度器类

从前面的rq数据结构发现,Linux是同时支持多个进程调度器的,不同的进程挂载不同的运行队列中,如rq结构中的cfs、rt、dl,然后针对他们这些结构,使用不同的调度器。

为了支持不同的调度器,Linux 定义了调度器类数据结构,它定义了一个调度器要实现哪些函数,代码如下所示。

struct sched_class {
    //向运行队列中添加一个进程,入队
    void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
    //向运行队列中删除一个进程,出队
    void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
    //检查当前进程是否可抢占
    void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);
    //从运行队列中返回可以投入运行的一个进程
    struct task_struct *(*pick_next_task)(struct rq *rq);
} ;

这个 sched_class 结构定义了一组函数指针,为了让你抓住重点,这里我删除了调度组和负载均衡相关的函数指针。Linux 系统一共定义了五个 sched_class 结构的实例变量,这五个 sched_class 结构紧靠在一起,形成了 sched_class 结构数组。

为了找到相应的 sched_class 结构实例,可以用以下代码遍历所有的 sched_class 结构实例变量。

//定义在链接脚本文件中
extern struct sched_class __begin_sched_classes[];
extern struct sched_class __end_sched_classes[];

#define sched_class_highest (__end_sched_classes - 1)
#define sched_class_lowest  (__begin_sched_classes - 1)

#define for_class_range(class, _from, _to) \
    for (class = (_from); class != (_to); class--)
//遍历每个调度类
#define for_each_class(class) \
    for_class_range(class, sched_class_highest, sched_class_lowest)

extern const struct sched_class stop_sched_class;//停止调度类
extern const struct sched_class dl_sched_class;//Deadline调度类
extern const struct sched_class rt_sched_class;//实时调度类
extern const struct sched_class fair_sched_class;//CFS调度类
extern const struct sched_class idle_sched_class;//空转调度类

这些类是有优先级的,它们的优先级是:stop_sched_class > dl_sched_class > rt_sched_class > fair_sched_class > idle_sched_class。

CFS 调度器(这个调度器我们稍后讨论)所需要的 fair_sched_class,代码如下所示。

const struct sched_class fair_sched_class
    __section("__fair_sched_class") = {
    .enqueue_task       = enqueue_task_fair,
    .dequeue_task       = dequeue_task_fair,
    .check_preempt_curr = check_preempt_wakeup,
    .pick_next_task     = __pick_next_task_fair,
};

实现一个新的调度器,就是实现这些对应的函数

Linux的CFS调度器

Linux 支持多种不同的进程调度器,比如 RT 调度器、Deadline 调度器、CFS 调度器以及 Idle 调度器。CFS 调度器,也就是完全公平调度器,CFS 的设计理念是在有限的真实硬件平台上模拟实现理想的、精确的多任务 CPU。

普通进程的权重

Linux 会使用 CFS 调度器调度普通进程,CFS 调度器与其它进程调度器的不同之处在于没有时间片的概念,它是分配 CPU 使用时间的比例。比如,4 个相同优先级的进程在一个 CPU 上运行,那么每个进程都将会分配 25% 的 CPU 运行时间。这就是进程要的公平。

那CFS调度器如何在公平之下,实现“不公平”的呢?

首先,CFS 调度器下不叫优先级,而是叫权重,权重表示进程的优先级,各个进程按权重的比例分配 CPU 时间。

举个例子,现在有 A、B 两个进程。进程 A 的权重是 1024,进程 B 的权重是 2048。那么进程 A 获得 CPU 的时间比例是 1024/(1024+2048) = 33.3%。进程 B 获得的 CPU 时间比例是 2048/(1024+2048)=66.7%。

因此,权重越大,分配的时间比例越大,就相当于进程的优先级越高。

有了权重之后,分配给进程的时间计算公式如下:
进程的时间 = CPU 总时间 * 进程的权重 / 就绪队列所有进程权重之和

但是进程对外的编程接口中使用的是一个 nice 值,大小范围是(-20~19),数值越小优先级越大,意味着权重值越大,nice 值和权重之间可以转换的。Linux 提供了后面这个数组,用于转换 nice 值和权重。

const int sched_prio_to_weight[40] = {
 /* -20 */     88761,     71755,     56483,     46273,     36291,
 /* -15 */     29154,     23254,     18705,     14949,     11916,
 /* -10 */      9548,      7620,      6100,      4904,      3906,
 /*  -5 */      3121,      2501,      1991,      1586,      1277,
 /*   0 */      1024,       820,       655,       526,       423,
 /*   5 */       335,       272,       215,       172,       137,
 /*  10 */       110,        87,        70,        56,        45,
 /*  15 */        36,        29,        23,        18,        15,
};

一个进程每降低一个 nice 值,就能多获得 10% 的 CPU 时间。1024 权重对应 nice 值为 0,被称为 NICE_0_LOAD。默认情况下,大多数进程的权重都是 NICE_0_LOAD。

进程调度延迟

什么是调度延迟?就是保证每一个可运行的进程,都至少运行一次的时间间隔

结合实例理解,系统中有 3 个可运行进程,每个进程都运行 10ms,那么调度延迟就是 30ms;如果有 10 个进程,那么调度延迟就是 100ms;如果现在保证调度延迟不变,固定是 30ms;如果系统中有 3 个进程,则每个进程可运行 10ms;如果有 10 个进程,则每个进程可运行 3ms。

随着进程的增加,每个进程分配的时间在减少,进程调度次数会增加,调度器占用的时间就会增加。因此,CFS 调度器的调度延迟时间的设定并不是固定的。

当运行进程少于 8 个的时候,调度延迟是固定的 6ms 不变。当运行进程个数超过 8 个时,就要保证每个进程至少运行一段时间,才被调度。这个“至少一段时间”叫作最小调度粒度时间

在 CFS 默认设置中,最小调度粒度时间是 0.75ms,用变量 sysctl_sched_min_granularity 记录。由 __sched_period 函数负责计算,如下所示。

unsigned int sysctl_sched_min_granularity           = 750000ULL;
static unsigned int normalized_sysctl_sched_min_granularity = 750000ULL;
static unsigned int sched_nr_latency = 8;
static u64 __sched_period(unsigned long nr_running)
{
    if (unlikely(nr_running > sched_nr_latency))
        return nr_running * sysctl_sched_min_granularity;
    else
        return sysctl_sched_latency;
}

上述代码中,参数 nr_running 是 Linux 系统中可运行的进程数量,当超过 sched_nr_latency 时,我们无法保证调度延迟,因此转为保证最小调度粒度

虚拟时间

调度实体中的 vruntime 么?它就是用来表示虚拟时间的

例如,调度延迟是 10ms,系统一共 2 个相同优先级的进程,那么各进程都将在 10ms 的时间内各运行 5ms。

现在进程 A 和进程 B 他们的权重分别是 1024 和 820(nice 值分别是 0 和 1)。进程 A 获得的运行时间是 10x1024/(1024+820)=5.6ms,进程 B 获得的执行时间是 10x820/(1024+820)=4.4ms。进程 A 的 cpu 使用比例是 5.6/10x100%=56%,进程 B 的 cpu 使用比例是 4.4/10x100%=44%。

很明显,这两个进程的实际执行时间是不等的,但 CFS 调度器想保证每个进程的运行时间相等。因此 CFS 调度器引入了虚拟时间,也就是说,上面的 5.6ms 和 4.4ms 经过一个公式,转换成相同的值,这个转换后的值就叫虚拟时间。这样的话,CFS 只需要保证每个进程运行的虚拟时间是相等的。
虚拟时间 vruntime 和实际时间(wtime)转换公式如下:
vruntime = wtime*( NICE_0_LOAD/weight)

根据上面的公式,可以发现 nice 值为 0 的进程,这种进程的虚拟时间和实际时间是相等的,那么进程 A 的虚拟时间为:5.6(1024/1024)=5.6,进程 B 的虚拟时间为:4.4(1024/820)=5.6。虽然进程 A 和进程 B 的权重不一样,但是计算得到的虚拟时间是一样的。

所以,CFS 调度主要保证每个进程运行的虚拟时间一致即可。在选择下一个即将运行的进程时,只需要找到虚拟时间最小的进程就行了。这个计算过程由 calc_delta_fair 函数完成,如下所示。

static u64 __calc_delta(u64 delta_exec, unsigned long weight, struct load_weight *lw)
{
    u64 fact = scale_load_down(weight);
    int shift = WMULT_SHIFT;
    __update_inv_weight(lw);
    if (unlikely(fact >> 32)) {
        while (fact >> 32) {
            fact >>= 1;
            shift--;
        }
    }
    //为了避免使用浮点计算
    fact = mul_u32_u32(fact, lw->inv_weight);
    while (fact >> 32) {
        fact >>= 1;
        shift--;
    }
    return mul_u64_u32_shr(delta_exec, fact, shift);
}
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
    if (unlikely(se->load.weight != NICE_0_LOAD))
        delta = __calc_delta(delta, NICE_0_LOAD, &se->load);

    return delta;
}

按照上面的理论,调用 __calc_delta 函数的时候,传递的 weight 参数是 NICE_0_LOAD,lw 参数正是调度实体中的 load_weight 结构体。

到这里,我要公开一个问题,在运行队列中用红黑树结构组织进程的调度实体,这里进程虚拟时间正是红黑树的 key,这样进程就以进程的虚拟时间被红黑树组织起来了。红黑树的最左子节点,就是虚拟时间最小的进程,随着时间的推移进程会从红黑树的左边跑到右,然后从右边跑到左边,就像舞蹈一样优美。

CFS调度进程

CFS 调度器就是要维持各个可运行进程的虚拟时间相等,不相等就需要被调度运行。如果一个进程比其它进程的虚拟时间小,它就应该运行达到和其它进程的虚拟时间持平,直到它的虚拟时间超过其它进程,这时就要停下来,这样其它进程才能被调度运行。

定时周期调度

虚拟时间就是一个数据,如果没有任何机制对它进行更新,就会导致一个进程永远运行下去,因为那个进程的虚拟时间没有更新,虚拟时间永远最小,这当然不行。

因此定时周期调度机制应运而生。Linux 启动会启动定时器,这个定时器每 1/1000、1/250、1/100 秒(根据配置不同选取其一),产生一个时钟中断,在中断处理函数中最终会调用一个 scheduler_tick 函数,代码如下所示。


static void update_curr(struct cfs_rq *cfs_rq)
{
    struct sched_entity *curr = cfs_rq->curr;
    u64 now = rq_clock_task(rq_of(cfs_rq));//获取当前时间 
    u64 delta_exec;
    delta_exec = now - curr->exec_start;//间隔时间 
    curr->exec_start = now;
    curr->sum_exec_runtime += delta_exec;//累计运行时间 
    curr->vruntime += calc_delta_fair(delta_exec, curr);//计算进程的虚拟时间 
    update_min_vruntime(cfs_rq);//更新运行队列中的最小虚拟时间,这是新建进程的虚拟时间,避免一个新建进程因为虚拟时间太小而长时间占用CPU
}
static void entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
{
    update_curr(cfs_rq);//更新当前运行进程和运行队列相关的时间
    if (cfs_rq->nr_running > 1)//当运行进程数量大于1就检查是否可抢占
        check_preempt_tick(cfs_rq, curr);
}
#define for_each_sched_entity(se) \
        for (; se; se = NULL)
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
    struct cfs_rq *cfs_rq;
    struct sched_entity *se = &curr->se;//获取当前进程的调度实体 
    for_each_sched_entity(se) {//仅对当前进程的调度实体
        cfs_rq = cfs_rq_of(se);//获取当前进程的调度实体对应运行队列
        entity_tick(cfs_rq, se, queued);
    }
}
void scheduler_tick(void)
{
    int cpu = smp_processor_id();
    struct rq *rq = cpu_rq(cpu);//获取运行CPU运行进程队列
    struct task_struct *curr = rq->curr;//获取当进程
    update_rq_clock(rq);//更新运行队列的时间等数据
    curr->sched_class->task_tick(rq, curr, 0);//更新当前时间的虚拟时间
}

上述代码中,scheduler_tick 函数会调用进程调度类的 task_tick 函数,对于 CFS 调度器就是 task_tick_fair 函数。但是真正做事的是 entity_tick 函数,entity_tick 函数中调用了 update_curr 函数更新当前进程虚拟时间,这个函数我们在之前讨论过了,还更新了运行队列的相关数据。

entity_tick 函数的最后,调用了 check_preempt_tick 函数,用来检查是否可以抢占调度,代码如下。

static void check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    unsigned long ideal_runtime, delta_exec;
    struct sched_entity *se;
    s64 delta;
    //计算当前进程在本次调度中分配的运行时间
    ideal_runtime = sched_slice(cfs_rq, curr);
    //当前进程已经运行的实际时间
    delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
    //如果实际运行时间已经超过分配给进程的运行时间,就需要抢占当前进程。设置进程的TIF_NEED_RESCHED抢占标志。
    if (delta_exec > ideal_runtime) {
        resched_curr(rq_of(cfs_rq));
        return;
    }
    //因此如果进程运行时间小于最小调度粒度时间,不应该抢占
    if (delta_exec < sysctl_sched_min_granularity)
        return;
    //从红黑树中找到虚拟时间最小的调度实体
    se = __pick_first_entity(cfs_rq);
    delta = curr->vruntime - se->vruntime;
    //如果当前进程的虚拟时间仍然比红黑树中最左边调度实体虚拟时间小,也不应该发生调度
    if (delta < 0)
        return;
}

刚才的代码你可以这样理解,如果需要抢占就会调用 resched_curr 函数设置进程的抢占标志,但是这个函数本身不会调用进程调度器函数,而是在进程从中断或者系统调用返回到用户态空间时,检查当前进程的调度标志,然后根据需要调用进程调度器函数。

调度器入口

如果设计需要进行进程抢占调度,Linux 就会在适当的时机进行进程调度,进程调度就是调用进程调度器入口函数,该函数会选择一个最合适投入运行的进程,然后切换到该进程上运行。
进程调度器入口函数的代码长什么样。

static void __sched notrace __schedule(bool preempt)
{
    struct task_struct *prev, *next;
    unsigned long *switch_count;
    unsigned long prev_state;
    struct rq_flags rf;
    struct rq *rq;
    int cpu;
    cpu = smp_processor_id();
    rq = cpu_rq(cpu);//获取当前CPU的运行队列
    prev = rq->curr; //获取当前进程 
    rq_lock(rq, &rf);//运行队列加锁
    update_rq_clock(rq);//更新运行队列时钟
    switch_count = &prev->nivcsw;
    next = pick_next_task(rq, prev, &rf);//获取下一个投入运行的进程
    clear_tsk_need_resched(prev); //清除抢占标志
    clear_preempt_need_resched();
    if (likely(prev != next)) {//当前运行进程和下一个运行进程不同,就要进程切换
        rq->nr_switches++; //切换计数统计
        ++*switch_count;
        rq = context_switch(rq, prev, next, &rf);//进程机器上下文切换
    } else {
        rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP);
        rq_unlock_irq(rq, &rf);//解锁运行队列
    }
}
void schedule(void)
{
    struct task_struct *tsk = current;//获取当前进程
    do {
        preempt_disable();//关闭内核抢占
        __schedule(false);//进程调用
        sched_preempt_enable_no_resched();//开启内核抢占
    } while (need_resched());//是否需要再次重新调用
}

之所以在循环中调用 __schedule 函数执行真正的进程调度,是因为在执行调度的过程中,有些更高优先级的进程进入了可运行状态,因此它就要抢占当前进程。

__schedule 函数中会更新一些统计数据,然后调用 pick_next_task 函数挑选出下一个进程投入运行。最后,如果当前进程和下一个要运行的进程不同,就要进行进程机器上下文切换,其中会切换地址空间和 CPU 寄存器。

挑选下一个进程

在 __schedule 函数中,获取了正在运行的进程,更新了运行队列的时钟,下面就要挑选出下一个投入运行的进程。显然,不是随便挑选一个,我们这就来看看调度器是如何挑选的。

挑选下一个运行进程这个过程,是在 pick_next_task 函数中完成的,如下所示。

static inline struct task_struct *pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
    const struct sched_class *class;
    struct task_struct *p;
    //这是对CFS的一种优化处理,因为大部分进程属于CFS管理
    if (likely(prev->sched_class <= &fair_sched_class &&
           rq->nr_running == rq->cfs.h_nr_running)) {
        p = pick_next_task_fair(rq, prev, rf);//调用CFS的对应的函数
        if (unlikely(p == RETRY_TASK))
            goto restart;
        if (!p) {//如果没有获取到运行进程
            put_prev_task(rq, prev);//将上一个进程放回运行队列中
            p = pick_next_task_idle(rq);//获取空转进程
        }
        return p;
    }
restart:
    for_each_class(class) {//依次从最高优先级的调度类开始遍历
        p = class->pick_next_task(rq);
        if (p)//如果在一个调度类所管理的运行队列中挑选到一个进程,立即返回
            return p;
    }
    BUG();//出错
}

pick_next_task 函数只是个框架函数,它的逻辑也很清楚,会依照优先级调用具体调度器类的函数完成工作,对于 CFS 则会调用 pick_next_task_fair 函数,代码如下所示

struct task_struct *pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
    struct cfs_rq *cfs_rq = &rq->cfs;
    struct sched_entity *se;
    struct task_struct *p;
    if (prev)
        put_prev_task(rq, prev);//把上一个进程放回运行队列
    do {
        se = pick_next_entity(cfs_rq, NULL);//选择最适合运行的调度实体
        set_next_entity(cfs_rq, se);//对选择的调度实体进行一些处理
        cfs_rq = group_cfs_rq(se);
    } while (cfs_rq);//在没有调度组的情况下,循环一次就结束了
    p = task_of(se);//通过se获取包含se的进程task_struct
    return p;
}

上述代码中调用 pick_next_entity 函数选择虚拟时间最小的调度实体,然后调用 set_next_entity 函数,对选择的调度实体进行一些必要的处理,主要是将这调度实体从运行队列中拿出来。

pick_next_entity 函数具体要怎么工作呢?
首先,它调用了相关函数,从运行队列上的红黑树中查找虚拟时间最少的调度实体,然后处理要跳过调度的情况,最后决定挑选的调度实体是否可以抢占并返回它。

struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
    struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);//先读取在tasks_timeline中rb_node指针
    if (!left)
        return NULL;//如果为空直接返回NULL
    //通过红黑树结点指针取得包含它的调度实体结构地址
    return rb_entry(left, struct sched_entity, run_node);
}
static struct sched_entity *__pick_next_entity(struct sched_entity *se)
{    //获取当前红黑树节点的下一个结点
    struct rb_node *next = rb_next(&se->run_node);
    if (!next)
        return NULL;//如果为空直接返回NULL
    return rb_entry(next, struct sched_entity, run_node);
}
static struct sched_entity *pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    //获取Cfs_rq中的红黑树上最左节点上调度实体,虚拟时间最小
    struct sched_entity *left = __pick_first_entity(cfs_rq);
    struct sched_entity *se;
    if (!left || (curr && entity_before(curr, left)))
        left = curr;//可能当前进程主动放弃CPU,它的虚拟时间比红黑树上的还小,所以left指向当前进程调度实体
    se = left; 
    if (cfs_rq->skip == se) { //如果选择的调度实体是要跳过的调度实体
        struct sched_entity *second;
        if (se == curr) {//如果是当前调度实体
            second = __pick_first_entity(cfs_rq);//选择运行队列中虚拟时间最小的调度实体
        } else {//否则选择红黑树上第二左的进程节点
            second = __pick_next_entity(se);
            //如果次优的调度实体的虚拟时间,还是比当前的调度实体的虚拟时间大
            if (!second || (curr && entity_before(curr, second)))
                second = curr;//让次优的调度实体也指向当前调度实体
        }
        //判断left和second的虚拟时间的差距是否小于sysctl_sched_wakeup_granularity
        if (second && wakeup_preempt_entity(second, left) < 1)
            se = second;
    }
    if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1) {
        se = cfs_rq->next;
    } else if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1) {
             se = cfs_rq->last;
    }
    clear_buddies(cfs_rq, se);//需要清除掉last、next、skip指针
    return se;
}

代码的调用路径最终会返回到 __schedule 函数中,这个函数中就是上一个运行的进程和将要投入运行的下一个进程,最后调用 context_switch 函数,完成两个进程的地址空间和机器上下文的切换,一次进程调度工作结束。

至此 CFS 调度器的基本概念与数据结构,还有算法实现,我们就搞清楚了,核心就是让虚拟时间最小的进程最先运行, 一旦进程运行虚拟时间就会增加,最后尽量保证所有进程的虚拟时间相等,谁小了就要多运行,谁大了就要暂停运行。

小结

Linux中如何表示一个进程及如何进行多个进程调度,

 

为什么要用红黑树来组织调度实体?这是因为要维护虚拟时间的顺序,又要从中频繁的删除和插入调度实体,这种情况下红黑树这种结构无疑是非常好

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值