负载均衡
我们先前提到过,schedule()和运行队列等等都是针对于单个处理器而言的。那么,是否存在某种机制来解决多处理器系统中负载不均的状况呢?想象某个拥有五个处理器的系统,Acpu上处理5个进程,而Bcpu仅仅处理1个,毋庸置疑地浪费了Bcpu中大量的计算资源,却又使得Acpu苦不堪言,导致整个系统的性能下降。所以内核提供了负载均衡器(load balancer)解决该难题。在<kernel/sched.c>中能看到相关代码。
负载均衡器的核心函数是load_balance(),该函数可以通过两种渠道被调用。一种是在schedule()中,当schedule()检测到当前CPU运行队列中的进程数为零时,负载均衡函数被调用。还有一种是因为定时器而被调用——比如每过一毫秒且CPU相对空闲的时候,以及类似的时候。在load_balance()运行时,将会给当前运行队列上锁,以及确保中断禁用,防止并发地访问运行队列。
两种方式的调用结果存在差异。schedule()的调用里,工作十分简单——因为当前队列是空的。所以我们只需要寻找到某个不为空的核,将其进程拉过来即可。在定时器的调用里,我们需要解决任何运行队列中的不平衡。
接下来是load_balance()的执行步骤。
1.调用find_busiest_queue()寻找最繁忙的运行队列。如果最繁忙的队列中进程的数目至少比当前运行队列中多25%,则程序继续,否则返回。
2.寻找其试图迁移的优先队列,一般来说推荐过气队列,因为过气队列中的进程已经有一段时间没有运行了,也更有可能不在当前CPU的CACHE中。如果过气队列为空,那么搜寻活动队列。
3.搜寻选定队列中的最高优先级进程的列表。这些进程更重要因此更迫切地需要运行。当然之间有一些小插曲,详细看下面的代码:
在这里稍微提一下已经多次出现的list_entry()函数,这实际上是一个双向链表相关的函数。在Linux内核中,提供了一个用来创建双向循环链表的结构 list_head。虽然Linux内核是用C语言写的,但是list_head的引入,使得内核数据结构也可以拥有面向对象的特性,通过使用操作list_head 的通用接口很容易实现代码的重用,有点类似于C++的继承机制。详情见Linux 内核list_head 学习(一)。
4.每个给定优先级列表中的任务都被分析,以此来寻找适当的任务进行迁移。“适当”指的(1)是不在当前CPU的CACHE中。(2)不被禁止迁移。(3)不是当前运行的任务。这一步骤通过调用can_migrate_task()来实现:
5.如果不平衡情况依旧存在,则跳转继续处理。否则退出函数。
进程抢占和上下文切换
上下文切换是指从当前运行进程切换到其他可执行进程。通过定义在<kernel/sched.c>的context_switch()来实现。当某个新的进程被选择而即将被运行时,该函数被schedule()调用。该函数执行了以下两个基本工作:
1.调用<asm/mmu_context.h>中定义的switch_mm()函数,切换虚拟内存映射。
2.调用<asm/system.h>定义的switch_to()函数。保存当前进程的上下文,并进行上下文切换。
进程上下文,就是进程执行时的环境。具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存信息等。
(1)用户级上下文: 正文、数据、用户堆栈以及共享存储区;
(2)寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
(3)系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
当发生进程调度时,进行进程切换就是上下文切换(context switch).操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。而系统调用进行的模式切换(mode switch)。模式切换与进程切换比较起来,容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。详情查看用户空间与内核空间,进程上下文与中断上下文[总结]一文。
回归主题,那么对于内核来说。内核必须知道什么时候该执行schedule()。如果只在代码显示地调用时调用,那么用户空间的程序就会无休无止地运行下去。所以,内核提供了nead_resched(该标志似乎存储在thread_info结构的flags里,其对应与flags取值为TIF_NEED_RESCHED)标志来表示是否需要执行调度。如果要设定这个标志,就要调用scheduler_tick(),而调用只会发生在(1)当一个进程耗尽其时间片。(2)在try_to_wake_up()函数里——当一个比当前进程优先级更高的任务被唤醒时。
以下介绍三个用于nead_resched的函数:
1.set_tsk_need_resched()
2.clear_tsk_need_resched()
3.need_resched()
除上述,当从用户空间返回,或者是从中断函数中返回的时候,nead_resched就会被检查。该标志存储在每个进程的thread_info结构中的flags成员里。如果flags的值为TIF_NEED_RESCHED时,就等价于nead_resched被置位。
用户抢占
用户级的进程抢占发生在内核将要返回用户空间时,并且nead_resched被设置时。
总而言之,用户级进程抢占可能发生在:
1.从系统调用返回用户空间时。
2.从中断处理函数返回用户空间时。
内核抢占
Linux和其他大多数的Unix系统以及其他派别的系统不同,Linux是一个完全可抢占的(先发制人的)操作系统。在不是完全可抢占的系统中,内核代码会持续执行直到全部完成。也就是说,调度进程没有能力去打断一个正在内核中运行的任务。内核进程是合作地进行(平均分配时间?),而不是引入抢占机制的。内核代码会运行直到它完成并返回到用户空间,或者是显式地阻塞。但是,我们的Linux系统是完全可抢占的:一个进程可能在任何时间点被抢占,只要任务处在安全的可切换的状态。
什么时候适合重新安排呢?当内核任务不占用“锁”时,内核就具备抢占其的能力。也就是说,如果一个内核进程不拥有锁,那么该进程就是可重入的,并且是可被抢占的。
与用户级抢占不同,内核级别的抢占引入了新的标志,就是thread_info结构中的preempt_count成员。其初始值为0,当进程每每获得锁时就增加1,每每释放锁时就减1。所以当该值为0时,内核是可抢占的。下面讨论从中断里返回的情况:如果返回的是内核空间,内核会检查当前的进程的preempt_count和nead_resched的值。如果都满足需求——这意味着某任务继续调度,并且当前进程处在安全(可以重新调度)的状态,就会调用schedule()。如果不满足上述条件。中断程序结束后,将会常规地返回被中断处继续执行。当前内核进程释放完所有锁时,也会检查nead_resched是否被设置。
总而言之,内核级抢占可能发生在:
1.当中断进程返回到内核空间时。
2.当内核程序再次变得可抢占时。
3.内核态运行的任务显示调用schedule()时。
4.内核态进程阻塞时。(是任务代码的自我实现吗?还是某种机制检测到该任务阻塞后启动调度进程?)
实时
Linux提供了两种实时调度算法SCHED_FIFO和SCHED_RR。前者就是简单地先到先执行原则,在该调度算法中不存在时间片的说法,当这样的任务变为可运行状态,会一直运行直到它阻塞或者是显式地放弃cpu。并且用SCHED_FIFO的进程总比SCHED_NORMAL的优先调度。采用SCHED_FIFO算法的进程只能被同为SCHED_FIFO的或者是SCHED_RR的进程抢占。两个以上的具有相同优先级的FIFO类进程才用轮流执行的方式。如果一个SCHED_FIFO的进程执行,那么所有优先级低于它的进程无法翻身,直到该进程结束。
SCHED_RR与SCHED_FIFO的区别在于,使用该算法的进程拥有时间片。进程们只能在耗尽自己预定义的时间片前运行。同一优先级的进程轮流执行。因此在SCHED_RR中时间片仅对相同优先级的进程有意义。一个较低优先级的进程永远不可能抢占一个SCHED_RR的进程,尽管这个进程耗尽了自己的时间片(但未结束)。
SCHED_NORMAL就是我们先前讨论的一般进程调度方法。
我们用实时优先级表示实时进程的优先级。正如先前提到,其值为[0,99(MAX_RT_PRIO-1)]。而我们知道常规的进程优先级是[-20,19]。后来为了体系里表示的简单,我们将Nice的值映射到了[MAX_RT_PRIO,MAX_RT_PRIO+40]。