【1.5】系统调用、内核调度

一、系统调用

系统调用是用户进程与内核进行交互的一组接口,让应用程序受限地访问硬件设备。其主要作用有3个:

  • 为用户空间提供硬件的抽象接口。
  • 保证了系统的稳定和安全。
  • 是用户空间访问内核的唯一手段,除异常和陷入外。

1、系统调用在内核空间的处理层次模型

系统调用在核心空间中所要经历的层次模型。从图中看出:对于磁盘的一次读请求,首先经过虚拟文件系统层(vfs layer),其次是具体的文件系统层(例如 ext2),接下来是 cache 层(page cache 层)、通用块层(generic block layer)、IO 调度层(I/O scheduler layer)、块设备驱动层(block device driver layer),最后是物理块设备层(block device layer)。

Read

  • 虚拟文件系统层的作用:屏蔽下层具体文件系统操作的差异,为上层的操作提供一个统一的接口。正是因为有了这个层次,所以可以把设备抽象成文件,使得操作设备就像操作文件一样简单。
  • 具体的文件系统层中,不同的文件系统(例如 ext2 和 NTFS)具体的操作过程也是不同的。每种文件系统定义了自己的操作集合。关于文件系统的更多内容,请参见参考资料。
  • 引入 cache 层的目的是为了提高 linux 操作系统对磁盘访问的性能。 Cache 层在内存中缓存了磁盘上的部分数据。当数据的请求到达时,如果在 cache 中存在该数据且是最新的,则直接将数据传递给用户程序,免除了对底层磁盘的操作,提高了性能。
  • 通用块层的主要工作是:接收上层发出的磁盘请求,并最终发出 IO 请求。该层隐藏了底层硬件块设备的特性,为块设备提供了一个通用的抽象视图。
  • IO 调度层的功能:接收通用块层发出的 IO 请求,缓存请求并试图合并相邻的请求(如果这两个请求的数据在磁盘上是相邻的)。并根据设置好的调度算法,回调驱动层提供的请求处理函数,以处理具体的 IO 请求。
  • 驱动层中的驱动程序对应具体的物理块设备。它从上层中取出 IO 请求,并根据该 IO 请求中指定的信息,通过向具体块设备的设备控制器发送命令的方式,来操纵设备传输数据。
  • 设备层中都是具体的物理设备。定义了操作具体设备的规范。

2、 read系统调用的过程

2.1、虚拟文件系统层的处理

read系统调用对应的内核函数是sys_read。实现如下(read_write.c):

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
    struct file *file;
    ssize_t ret = -EBADF;
    int fput_needed;
    file = fget_light(fd, &fput_needed);//根据 fd 指定的索引,从当前进程描述符中取出相应的 file 对象。
    if (file) {
        loff_t pos = file_pos_read(file); //函数取出此次读写文件的当前位置
        ret = vfs_read(file, buf, count, &pos);//执行文件读取操作,而这个函数最终调用 file->f_op.read() 指向的函数
        file_pos_write(file, pos); //更新文件的当前读写位置
        fput_light(file, fput_needed); //更新文件的引用计数。
    }
    return ret; //最后返回读取数据的字节数。
}

2.2. ext2层的处理

do_generic_file_read做的工作:

  • 根据文件当前的读写位置,在 page cache 中找到缓存请求数据的 page
  • 如果该页已经最新,将请求的数据拷贝到用户空间
  • 否则, Lock 该页
  • 调用 readpage 函数向磁盘发出添页请求(当下层完成该 IO 操作时会解锁该页),代码:error = mapping->a_ops->readpage(filp, page);
  • 再一次 lock 该页,操作成功时,说明数据已经在 page cache 中了,因为只有 IO 操作完成后才可能解锁该页。此处是一个同步点,用于同步数据从磁盘到内存的过程。
  • 解锁该页
  • 到此为止数据已经在 page cache 中了,再将其拷贝到用户空间中(之后 read 调用可以在用户空间返回了)

到此,我们知道:当页上的数据不是最新的时候,该函数调用 mapping->a_ops->readpage 所指向的函数(变量 mapping 为 inode 对象中的 address_space 对象),那么这个函数到底是什么呢?在Ext2文件系统中,readpage指向ext2_readpage。

2.3. page cache层的处理

从上文得知:ext2_readpage 函数是该层的入口点。该函数调用 mpage_readpage 函数,如下mpage_readpage 函数的代码。

int mpage_readpage(struct page *page, get_block_t get_block)
{
     struct bio *bio = NULL;
     sector_t last_block_in_bio = 0;
     struct buffer_head map_bh;
     unsigned long first_logical_block = 0;

     map_bh.b_state = 0;
     map_bh.b_size = 0;
     bio = do_mpage_readpage(bio, page, 1, &last_block_in_bio,
     &map_bh, &first_logical_block, get_block);
     if (bio)
     mpage_bio_submit(READ, bio);
     return 0;
}

该函数首先调用函数 do_mpage_readpage 函数创建了一个 bio 请求,该请求指明了要读取的数据块所在磁盘的位置、数据块的数量以及拷贝该数据的目标位置——缓存区中 page 的信息。然后调用 mpage_bio_submit 函数处理请求。 mpage_bio_submit 函数则调用 submit_bio 函数处理该请求,后者最终将请求传递给函数 generic_make_request ,并由 generic_make_request 函数将请求提交给通用块层处理。

到此为止, page cache 层的处理结束。

2.4. 通用块层的处理

generic_make_request 函数是该层的入口点,该层只有这一个函数处理请求。函数的代码参见blk-core.c。

主要操作:
根据 bio 中保存的块设备号取得请求队列 q
检测当前 IO 调度器是否可用,如果可用,则继续;否则等待调度器可用
调用 q->make_request_fn 所指向的函数将该请求(bio)加入到请求队列中
到此为止,通用块层的操作结束。

2.5. IO调度层的处理

make_request_fn 函数的调用可以认为是 IO 调度层的入口,该函数用于向请求队列中添加请求。该函数是在创建请求队列时指定的,代码如下(blk_init_queue 函数中):
q->request_fn = rfn;
blk_queue_make_request(q, __make_request);
函数 blk_queue_make_request 将函数 __make_request 的地址赋予了请求队列 q 的 make_request_fn 成员,那么, __make_request 函数才是 IO 调度层的真实入口。
__make_request 函数的主要工作为:

  1. 检测请求队列是否为空,若是,延缓驱动程序处理当前请求(其目的是想积累更多的请求,这样就有机会对相邻的请求进行合并,从而提高处理的性能),并跳到3,否则跳到2。
  2. 试图将当前请求同请求队列中现有的请求合并,如果合并成功,则函数返回,否则跳到3。
  3. 该请求是一个新请求,创建新的请求描述符,并初始化相应的域,并将该请求描述符加入到请求队列中,函数返回。

将请求放入到请求队列中后,何时被处理就由 IO 调度器的调度算法决定了(有关 IO 调度器的算法内容请参见参考资料)。一旦该请求能够被处理,便调用请求队列中成员 request_fn 所指向的函数处理。这个成员的初始化也是在创建请求队列时设置的:
q->request_fn = rfn;
blk_queue_make_request(q, __make_request);
第一行是将请求处理函数 rfn 指针赋给了请求队列的 request_fn 成员。而 rfn 则是在创建请求队列时通过参数传入的。
对请求处理函数 request_fn 的调用意味着 IO 调度层的处理结束了。

2.6. 块设备驱动层的处理

request_fn 函数是块设备驱动层的入口。它是在驱动程序创建请求队列时由驱动程序传递给 IO 调度层的。

IO 调度层通过回调 request_fn 函数的方式,把请求交给了驱动程序。而驱动程序从该函数的参数中获得上层发出的 IO 请求,并根据请求中指定的信息操作设备控制器(这一请求的发出需要依据物理设备指定的规范进行)。

到此为止,块设备驱动层的操作结束。

2.7. 块设备层的处理

接受来自驱动层的请求,完成实际的数据拷贝工作等等。同时规定了一系列规范,驱动程序必须按照这个规范操作硬件。

2.8. 后续工作

当设备完成了 IO 请求之后,通过中断的方式通知 cpu ,而中断处理程序又会调用 request_fn 函数进行处理。

当驱动再次处理该请求时,会根据本次数据传输的结果通知上层函数本次 IO 操作是否成功,如果成功,上层函数解锁 IO 操作所涉及的页面。

该页被解锁后, 就可以再次成功获得该锁(数据的同步点),并继续执行程序了。之后,函数 sys_read 可以返回了。最终 read 系统调用也可以返回了。

至此, read 系统调用从发出到结束的整个处理过程就全部结束了。

二、内核调度(cfs:完全公平调度)

从entity_key()的定义可以看出,key=vruntime-cfs_rq->min_vruntime。

其实它本质和我们的抽象模型一样的,只不过对进程进行排序时不是比较各个进程的vruntime值,而是将各个进程的vruntime减去一个相同的数再比较大小。

在这里只需要知道真实模型中比较的是键值就行了,不用深究,其本质也只是比较每个进程对应的vruntime值,vruntime小的排在队列(红黑树)前面。红黑树是二叉搜索树的一种,树上的成员的先后顺序是在插入每个成员时就决定的(为了控制树的高度,系统需要对红黑树做一些调整,但是这并不改变各个成员的先后顺序)。

__enqueue_entity()函数用于将各个执行完毕的进程(调度器选择某个进程执行前,会先把它从就绪队列中移除,执行完毕后再放回),或新的进程或刚睡眠的进程加入到就绪队列中。__enqueue_entity()函数将进程(准确地说应该是可调度实例)放在就绪队列中的位置决定了被调度的先后顺序:

weight

原理概述

普通进程(非实时进程)被分为40个等级(优先级越高权重大)。其中,优先级为[0~99]的是实时进程(值越大优先级越高),优先级[100~139]的是普通进程(值越小优先级越高)。每个等级的进程对应一个权重值,权重值用一个整数来表示。这些权重值被定义在下述数组中:

kernel/sched.c

static const int 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,

};

普通进程的权重值最大为88761,最小为15。默认情况下,普通进程的权重值为1024(由NICE_O_LOAD指定)。顺便说一下实时进程,实时进程也有权重值,它们的权重值为普通进程最大权重值的两倍(即,2X88761)。

  • prio表示进程的有效优先级。static_prio和rt_priority分别表示普通进程和实时进程固有的的优先级。normal_prio是将实时进程和普通进程的优先级统一单位,值越小优先级越高,它仍然代表进程静态的优先属性。
  • 因此对于实时进程来说:prio=effective_prio()=normal_prio。normal_prio=MAX_RT_PRIO-1-rt_priority
  • 对于优先级没有提高的普通进程来说:prio=effective_prio()=normal_prio=static_prio
  • 对于优先级提高的普通进程来说:prio=effective_prio(),normal_prio=static_prio。prio的值被其他函数更改过,所以与初始时不同。

nice值

    nice值也用来用来表示普通进程的优先等级,它介于[-20~19]之间,也是值越小优先级越高。之前讲过普通进程的优先值范围是[100~139],刚好和nice值一一对应起来:优先等级=nice值+120。nice值并不是表示进程优先级的一种新的机制,只是优先级的另一个表示而已。

vruntime行走速度

原理概述

权重值有了,我们还需要知道各个进程的vruntime行走的速度。系统规定:默认权重值(1024)对应的进程的vruntime行走时间与实际运行时间runtime是1:1的关系。由于vruntime的行走速度和权重值成反比,那么其他进程的vruntime行走速度都可以通过:1.该进程的权重值,2.默认进程的权重值两个参数计算得到。优先级越大的,vruntime行走速度越慢。优先调度vruntime值小的进程执行。
在进程执行期间周期性调度器周期性地启动,它的工作只是更新一些相关数据,并不负责进程之间的切换。它会调用update_curr()函数,完成相关数据的更新。

第一条语句:

delta_exec = (unsigned long)(now - curr->exec_start);

是计算周期性调度器上次执行时到周期性这次执行之间,进程实际执行的CPU时间(如果周期性调度器每1ms执行一次,delta_exec就表示每1ms内进程消耗的CPU时间,这个在前面讲了),它是一个实际运行时间。

update_curr()函数内只负责计算delta_exec以及更新exec_start。更新其他相关数据的任务交给了__update_curr()函数。
__update_curr()函数主要完成了三个任务:1.更新当前进程的实际运行时间。2.更新当前进程的虚拟时间vruntime。3.更新cfs_rq->min_vruntime。

更新当前进程的vruntime。

delta_exec_weighted表示虚拟时间vruntime的增量。权重值为1024(NICE_0_LOAD)的进程,vruntime行走速度和runtime行走速度相同,那么vruntime的增量也就等于runtime的增量。所以先将 delta_exec_weighted设置为delta_exec。

下一句是判断当前进程的权重值是否是1024,如果不是1024则需要计算。这个计算任务交给calc_delta_fair()函数完成,它又定义为calc_delta_mine()函数,这个函数的实现过程稍有些复杂,这个我们暂时不关心,我们只需要知道calc_delta_fair()返回的是:

在上面的公式中,curr->load.weight出现在分母上,就意味着要做除法。除法的效率很低,如果我们把curr->load.weight的倒数保存起来,那么“除以weight”的计算就可以转换为乘以它倒数的计算了。就是基于这个想法(实际实现时并不是存的weight的倒数,只是基于这个想法而已,因为weight的倒数是小数,涉及到浮点运算,消耗更大),load_weight结构体中除了weight成员之外,还多了一个inv_weight成员。每个进程的inv_weight值约等于 4294967268/weight。当然每个进程对应的inv_weight值也不是临时计算的,而是提前计算好了放到数组里的:

kernel/sched.c

static const u32 prio_to_wmult[40] = {

 /* -20 */    48388,     59856,     76040,     92818,    118348,

 /* -15 */   147320,    184698,    229616,    287308,    360437,

 /* -10 */   449829,    563644,    704093,    875809,   1099582,

 /*  -5 */   1376151,   1717300,   2157191,   2708050,   3363326,

 /*   0 */   4194304,   5237765,   6557202,   8165337,  10153587,

 /*   5 */  12820798,  15790321,  19976592,  24970740,  31350126,

 /*  10 */  39045157,  49367440,  61356676,  76695844,  95443717,

 /*  15 */ 119304647, 148102320, 186737708, 238609294, 286331153,

};

更新cfs_rq->min_vruntime

在当前进程和下一个将要被调度的进程中选择vruntime较小的值(因为下一个要执行的进程的vruntime是就绪队列中vruntime值最小的,那么在它和当前进程中选择vruntime更小的意味着选出的是可运行进程中vruntime最小的值)。然后用该值和cfs_rq->min_vruntime比较,如果比min_vruntime大,则更新cfs_rq为它(保证了min_vruntime值单调增加)。

place_entity

细节

来分析一下place_entity()这个函数,通过它可以更进一步了解进程在就绪队列中排序的机制。下面是它的相关源代码,实际代码中会根据sched_feature查询的结果来执行部分代码,这里我把它们去掉了,这不影响我们对基本原理的研究。

kernel/Sched_fair.c

place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)

{

......

   vruntime = cfs_rq->min_vruntime;

    if (initial)

       vruntime += sched_vslice_add(cfs_rq, se);

       

    if (!initial) {

       /* sleeps upto asingle latency don&#39t count. */

           vruntime -= sysctl_sched_latency;

       /* ensure we nevergain time by being placed backwards. */

        vruntime = max_vruntime(se->vruntime, vruntime);

    }

   se->vruntime = vruntime;
}

place_entity()函数的功能是调整进程的虚拟时间。当新进程被创建或者进程被唤醒时都需要调整它的vruntime值(新进程在创建时需要合理设置它的vruntime值,这个我们在抽象模型中就讨论过,跟新进程一样,进程被唤醒时也是需要调整vruntime值的)。那我们看看真实模型中是怎么实现的。

 

新进程被创建

原理概述

进程的ideal_time长度和weight成正比,vruntime行走速度和weight值成反比。那么,如果每个进程在period时间内,都执行了自己对应的ideal_time这么长的时间,那么他们的vruntime的增量delta_vruntime相等。又由于nice值等于0(即weight值等于1024)的进程vruntime行走速度等于runtime行走速度,如果每个进程都运行他自己对应的ideal_runtime那么长时间,那么他们vruntime的增量都等于nice值为0的进程的ideal_runtime。

例如之前举过的例子:A,B,C,D四个进程,weight值分别为1,2,3,4。period长度为20ms。那么他们的ideal_time分别为2ms,4ms,6ms,8ms。如果令weight值为1的进程的vruntime行走速度和runtime相同。那么进程A运行2ms,它的delta_vruntime便是2ms;进程B执行4ms,它的delta_vruntime也是2ms;同样的,进程C,D各自分别执行6ms,8ms它们的delta_vruntime也都是2ms。

那么,假设初始情况下A,B,C,D四个进程就按这个顺序排在就绪队列上的,它们的vruntime值都等于0。当A执行了2ms(即它的ideal_runtime)后,它的vruntime值变成了2ms,后果是:它直接被排到就绪队列的最后,只要其它进程运行的总时间没有达到自己对应的ideal_runtime值,那么它始终是排在进程A前面的。

如果初始的时候,人为地给A的vruntime加上2ms,那会起到什么效果呢?效果是:意味着它被标记为"它在本period内已经执行了它对应的ideal_time那么长的时间",只有等其他进程都执行了它们各自对应的ideal_runtime这么长的时间之后它才能被调度。对于新创建的进程,系统就是这么做的

如果是新进程,那么initial参数为1。if(initial)中的语句会执行,它的vruntime值被设置为min_vruntime+sched_vslice_add(cfs_rq,se),sched_vslice_add()函数便是计算nice值为0的进程的ideal_runtime(即,该进程运行它对应的ideal_time这么长时间时的delta_vruntime值)。作用是将新加入的进程标记为"它在本period内已经运行了它对应的ideal_time那么长的时间",这致使它理论上会被排到就绪队列的最后,意思就是说,新加入的进程理论上(如果有所进程都执行它对应的ideal_runtime那么长的时间,且没有发生睡眠,进程终止等等特殊事件的情况下)只有等待period之后才能被调度。

细节

sched_vslice_add()调用了__sched_vslice(),计算nice值为0的进程的ideal_runtime便是由__sched_vslice()的工作完成的代码如下:

kernel/Sched_fair.c

static u64 sched_vslice_add(struct cfs_rq *cfs_rq, struct sched_entity *se)

{

    return__sched_vslice(cfs_rq->load.weight + se->load.weight,

            cfs_rq->nr_running + 1);

}

static u64 __sched_vslice(unsigned long rq_weight, unsigned long nr_running)

{

    u64 vslice = __sched_period(nr_running);

 

    vslice *= NICE_0_LOAD;

    do_div(vslice, rq_weight);

 

    return vslice;

}

睡眠进程被唤醒

原理概述

如果进程是在睡眠之后被唤醒的,它的vruntime值也需要更新。首先将它的vruntime值设置为cfs_rq->mini_vruntime值(这个在抽象模型中就讲过了)。然后在象征性地进行一下补偿:在该vruntime中减去sysctl_sched_latency值(就绪队列中vruntime值越小的进程越靠前,所以减去一个数是对该进程的补偿)。为什么要补偿呢?因为进程进入睡眠状态时cfs_rq->min_vruntime就大于或等于该进程的vruntime值,它在睡眠过程中vruntime值是不改变的,然而cfs_rq->min_vruntime的值却是单调增加的。相当于进程醒来之后便将它的vruntime设置为一个更大的值,那么它当然受到不公平了待遇了。补偿的量由sysctl_sched_latency给出,默认情况下是20ms,相比之下这个量并不大,即便是补偿多了,也没关系(不管进程受到的不公平待遇大还是小,一律只补偿这么多)。

后面还有一句:vruntime =max_vruntime(se->vruntime, vruntime);这句代码的意思是:如果在cfs_rq->min_vruntime的基础长补偿了sysctl_sched_latency后比它原本的vruntime还小,那么这就不是补偿,而是奖励了,这种情况下直接用它原来的vruntime就行了。

总结

  1. 进程在就绪队列中用键值key来排序,它没有保存在任何变量中,而是在需要时由函数entity_key()计算得出。
  2. 其实它等是同于用进程的vruntime来进行排序的。
  3. 各个进程有不同的重要性(优先等级),越重要的进程权重值weight(task.se.load.weight)越大。
  4. 每个进程vruntime行走的速度和weight值成反比。权重值为1024(NICE_O_LOAD)的进程vruntime行走速度和runtime相同。
  5. 每个进程每次获得CPU使用权最多执行与该进程对应的ideal_runtime这么多时间。该时间长度和weight值成正比,它没有用变量来保存,而是在需要是用sched_slice()函数计算得出。
  6. ideal_runtime计算的基准是period,它也没有用变量来保存,而是在需要是用函数__sched_period()计算得出,当可运行进程数小于等于sched_nr_latency时period等于sysctl_sched_latency;当可运行进程大于sched_nr_latency时,period等于sysctl_sched_min_granularityXnr_running.
     
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值