一、调度器
Linux 内核 sched_class 调度器有五种类型:dl_sched_class/rt_sched_class/stop_sched_class/idle_sched_c lass/fair_sched_class,其中每种调度类都有自己的调度策略。主要是为方便添加新的调度策略 ,Linux内核抽象一个调度sched_class。
1、核心调度器
调度器的实现基于两个函数:周期性调度器函数和主调度器函数。这些函数根据现有进程的优先级分配CPU时间。这也是为什么整个方法称之为优先调度的原因。
a.周期性调度器函数
周期性调度器在scheduler_tick中实现,如果系统正在活动中,内核会按照频率HZ自动调用该函数。该函数主要有两个任务如下:
(1) 更新相关统计量:管理内核中与整个系统和各个进程的调度相关的 统计量。其间执行的主要操作是对各种计数器加1。
(2) 激活负责当前进程的调度类的周期性调度方法。
void scheduler_tick(void)
{
// 获取当前CPU上的全局就绪队列rq和当前运行的进程curr
// 在SMP(多核)的情况下,获得当前CPU的ID、如果不是SMP,那么返回0
int cpu = smp_processor_id();
// 获取CPU的全局就绪队列rq,每个CPU都有一个就绪队列rq
struct rq *rq = cpu_rq(cpu);
// 获取就绪队列上面正在运行的进程curr
struct task_struct *curr = rq->curr;
struct rq_flags rf;
sched_clock_tick();
rq_lock(rq, &rf);
// 更新rq当前时间戳,即相当于rq->clock变为当前时间戳
// 处理就绪队列时钟的更新,本质上就是增加struct rq当前实例的时钟时间戳
update_rq_clock(rq);
// 由于调度器的模块化结构,主要工作可以完全由特定调度器类方法,task_tick实现模式取决底层的调度器类
// 执行当前运行进程所在调度类的task_tick函数进行周期性调整
curr->sched_class->task_tick(rq, curr, 0);
// 将当前负荷加入数组的第一个位置
cpu_load_update_active(rq);
// 更新全局CPU就绪队列的calc_load_update
// 更新CPU的活动计数,主要是更新全局CPU就绪队列calc_load_uodate
calc_global_load_tick(rq);
// 解锁
rq_unlock(rq, &rf);
// 与perf计数事件有关
perf_event_task_tick();
#ifdef CONFIG_SMP
// 判断当前CPU是否为空闲状态
rq->idle_balance = idle_cpu(cpu);
// 如果进程周期性负载平衡则触发CCHED_SOFTIRQ终端
trigger_load_balance(rq);
#endif
rq_last_tick_reset(rq);
}
更新统计量函数:update_rq_clock()/calc_global_load_tick() <update_rq_clock>函数
void update_rq_clock(struct rq *rq)
{
s64 delta;
lockdep_assert_held(&rq->lock);
if (rq->clock_update_flags & RQCF_ACT_SKIP)
return;
#ifdef CONFIG_SCHED_DEBUG
if (sched_feat(WARN_DOUBLE_CLOCK))
SCHED_WARN_ON(rq->clock_update_flags & RQCF_UPDATED);
rq->clock_update_flags |= RQCF_UPDATED;
#endif
delta = sched_clock_cpu(cpu_of(rq)) - rq->clock;
if (delta < 0)
return;
rq->clock += delta;
update_rq_clock_task(rq, delta);
}
/*
* Called from scheduler_tick() to periodically update this CPU's
* active count.
*/
void calc_global_load_tick(struct rq *this_rq)
{
long delta;
if (time_before(jiffies, this_rq->calc_load_update))
return;
delta = calc_load_fold_active(this_rq, 0);
if (delta)
atomic_long_add(delta, &calc_load_tasks);
this_rq->calc_load_update += LOAD_FREQ;
}
b.主调度器函数
在内核中的许多地方,如果要将CPU分配给与当前活动进程不同的另一个进程,都会直接调用主调度器函数(schedule)
asmlinkage __visible void __sched schedule(void)
{
struct task_struct *tsk = current;
sched_submit_work(tsk);
do {
preempt_disable();
__schedule(false);
sched_preempt_enable_no_resched();
} while (need_resched());
}
EXPORT_SYMBOL(schedule);
主调度器负责将CPU的使用权从一个进程切换到另一个进程。周期性调度器只是定时更新调度相关的统计信息。cfs队列实际上是用红黑树组织的,rt 队列是用链表组织的。
2、调度类及运行队列
a.调度类
为方便添加新的调度策略,Linux内核抽象一个调度类sched_class,目前 为止实现5种调度类
1 引入停机调度类:支持限期调度类,迁移线程的优先级必须比期限进程的优先级高,能够抢占所有其它进程,才能够快速处理调度器发出的迁移请求,把进程从当前处理器迁移到其它处理器。
2 限期调度类:使用优先算法(使用红黑树)把进程按照绝对截至期限从小到大排序,每次调度时选择绝对截至期限最小的进程。
3 实时调度类:为每个调度优先级维护一个队列源码如下:
位图bitmap用来快速查找第一个非空队列,数据组queue的下标是实时进程的调度优先级,下标越小,优先级越高。
4 公平调度类:使用完全公平调度算法,引入虚拟运行时间:
虚拟运行时间=实际运行时间*nice 0对应的权重/进程的权重
nice 0对应权重1024 , nice n-1的权重大概是nice n权重的1.2倍左右。
5 空闲调度类:每个处理器上面有一个空闲的线程,即0号线程。空闲调度类的优先级最低,仅当没有其它进程可以调度的时候,才会执行调度空闲线程。
b.运行队列
每个处理器有一个运行队列,结构体是rq,定义的全局变量如下:
DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);
rq是描述就绪队列,其设计是为每一个CPU就绪队列,本地进程在本地队列上排序:
6 运行队列:struct rq中嵌入公平运行队列cfs,实时运行队列rt,限期运行队列dl、停机调度类和空闲调度类在每个处理器上只有一个内核线程,不需要运行队列,直接定义成员stop/idle分别指向迁移线程的空闲线程。
3、调度进程
主动调度进程的函数是schedule() ,它会把主要工作委托给__schedule() 去处理。
preempt:表示是否抢占调度,值为true表示抢占调度,强制剥夺当前进程对处理器的使用权
值为false表示主动调度,当前进程主动让出处理器
函数__shcedule的主要处理过程如下:
调用pick_next_task()以选择下一个进程。
调用context_switch()以切换进程。
此函数针对公平调度类优化,5种调度类:优先级从高到低:
停机、限期、实时、公平、空闲。
7 停机调度类选择下一个进程:pick_next_task_stop。期限调度类选择下一个进程:pick_next_task_dl。实时调度类选择下一个进程:pick_next_task_rt。公平调度类选择下一个进程:pick_next_task_fair。
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
struct mm_struct *mm, *oldmm;
// 执行进程切换的准备工作
prepare_task_switch(rq, prev, next);
mm = next->mm;
oldmm = prev->active_mm;
/*
* For paravirt, this is coupled with an exit in switch_to to
* combine the page table reload and the switch backend into
* one hypercall.
*/
// 开始上下文的切换,是每一种处理器架构必须定义的函数
arch_start_context_switch(prev);
// 如果下一个进程是内核线程(mm是空指针),内核线程没有用户虚拟地址空间,
if (!mm) {
next->active_mm = oldmm;
mmgrab(oldmm);
// 此函数通知处理器架构不需要切换用户虚拟地址空间,这种加速进程切换的技术TLB
enter_lazy_tlb(oldmm, next);
} else
// 如果下一个进程是用户进程,那么就调用此函数切换进程的用户虚拟地址空间。
switch_mm_irqs_off(oldmm, mm, next);
// 如果上一个进程是内核线程,把成员active_mm为空指针,断开它与借用的用户虚拟地址空间的联系,把它借用的用户虚拟地址空间保存在运行队列的成员prev_mm中。
if (!prev->mm) {
prev->active_mm = NULL;
rq->prev_mm = oldmm;
}
rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP);
/*
* Since the runqueue lock will be released by the next
* task (which is an invalid locking op but in the case
* of the scheduler it's an obvious special-case), so we
* do an early lockdep release here:
*/
rq_unpin_lock(rq, rf);
spin_release(&rq->lock.dep_map, 1, _THIS_IP_);
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
a、切换用户虚拟地址空间,ARM64架构使用默认的switch_mm_irqs_off, 其内核源码定义如下:
static inline void __switch_mm(struct mm_struct *next)
{
unsigned int cpu = smp_processor_id();
/*
* init_mm.pgd does not contain any user mappings and it is always
* active for kernel addresses in TTBR1. Just set the reserved TTBR0.
*/
if (next == &init_mm) {
cpu_set_reserved_ttbr0();
return;
}
check_and_switch_context(next, cpu);
}
b、切换寄存器,宏switch_to把这项工作委托给函数__switch_to:
用户态和内核态的切换:
主动:发生系统调用的时候。
被动:发生异常的时候:缺页中断、外设产生中断的时候。
内核态:CPU可以访问内存的所有数据,包括外围设备(网卡,硬盘等),CPU也可以将自己从一个程序切换至另一个程序。
用户态:只能受限访问,并且不允许访问外围设备,占用CPU的能力被剥夺,CPU资源可以被其它程序获取。
/*
* Thread switching.
*/
__notrace_funcgraph struct task_struct *__switch_to(struct task_struct *prev,
struct task_struct *next)
{
struct task_struct *last;
fpsimd_thread_switch(next); // 切换浮点寄存器
tls_thread_switch(next); // 切换线程本地存储相关的寄存器
hw_breakpoint_thread_switch(next); // 切换调试寄存器
contextidr_thread_switch(next); // 切换上下文标识符寄存器
entry_task_switch(next); // 使用当前处理器每处理器变量记录下一个进程的进程描述符地址
uao_thread_switch(next); //
/*
* Complete any pending TLB or cache maintenance on this CPU in case
* the thread migrates to a different CPU.
*/
// 在这个处理器上执行完前面的所有页表缓存或缓存维护操作,防止线程迁移到其它处理器上
dsb(ish);
/* the actual thread switch */
// 实际线程切换
last = cpu_switch_to(prev, next);
return last;
}
为什么要切换浮点寄存器,因为不同的处理器架构的浮点寄存器可能不同,而且有的处理器架构不支持浮点运算。
4、调度时机
调度进程的时机如下:
进程主动调用schedule()函数。
周期性地调度,抢占当前进程,强迫当前进程让出处理器。
唤醒进程的时候,被唤醒的进程可能抢占当前进程。
创建新进程的时候,新进程可能抢占当前进程。
如果我们编译内核时开启对内核抢占的支持,那么内核含增加一些指占点。
a、主动调度
进程在用户模式下运行的时候,无法直接调用schedule()函数,只能通过系统调用进入内核模式,如果系统调用需要等待某个资源,如互斥锁或信 号量,就会把进程的状态设置为睡眠状态,然后调用schedule()函数来调度进程。
进程也可以通过系统调用shced_yield()让出处理器,这种情况下进程不会睡眠。
在内核中有3种主动调度方式:
直接调用schedule()函数来调用进程。
调用有条件重调度函数cond_resched()。
如果需要等待某个资源。
b、周期调度
有些“地痞流氓”进程不主动让出处理器,内核只能依靠周期性的时钟中断夺回处理器的控制权,时钟中断是调度器的脉博。时钟中断处理程序检 查当前进程的执行时间有没有超过限额,如果超过限额,设置需要重新调度的标志。当时钟中断处理程序准备返点处理器还给被打断的进程时,如 果被打断的进程在用户模式下运行,就检查有没有设置需要重新调度的标 志,如果设置了,调用schedule函数以调度进程。
如果需要重新调度,就为当前进程的thread_info结构体的成员flags设置需要重新调度的标志。
周期调度的函数为 scheduler_tick(),调用当前进程所属调度类 task_tick()方法
二、SMP调度
在SMP系统中,进程调度器必须支持如下:
需要使用每个处理器的负载尽可能均衡。
可以设置进程的处理器亲和性,即允许进程在哪些处理器上执行。
可以把进程从一个处理器迁移到另一个处理器。
1、进程的处理器亲和性
设置进程的处理器亲和性,通俗就是把进程绑定到某些处理器,只允许进 程在某些处理器上执行,默认情况是进程可以在所有处理器上执行。应用 编程接口和使用cpuset配置具体详解分析。
9 应用编程接口:内核只有2个系统调用
sched_setaffinity:设置进程的处理器亲和性掩码。
sched_getaffinity:用来获取进程的处理器亲和性掩码。
内核线程可以使用2个函数来设置处理器亲和性掩码:
kthread_bind 用来把一个刚刚创建的内核线程绑定到一个处理器。
set_cpus_allowed_ptr 用来设置内核线程处理器亲和性掩码。
2、期限调度类的处理器负载均衡
限期调度类的处理器负载均衡简单,调度选择下一个限期进程的时候,如果当前正在执行的进程是限期进程,将会试图从限期进程超载的处理器把限期进程搞过来。
限期进程超载定义:
限期运行队列至少有两个限期进程。
至少有一个限期进程绑定到多个处理器。
3、实时调度类的处理器负载均衡
实时调度类的处理器负载均衡和限期调度类相似。调度器选择下一个实时进程时,如果当前处理器的实时运行队列中的进程的最高调度优先级比当前正在执行的进程的调度优先级低,将会试图从实时进程超载的处理器把可推送实时进程拉过来。
实时进程超载的定义:
实时运行队列至少有两个实时进程。
至少有一个可推送实时进程。
4、公平调度类的处理器负载均衡
目前多处理器系统有两种体系结构:NUMA和SMP。
处理器内部的拓扑如下:
a.核(core):一个处理器包含多个核,每个核独立的一级缓存,所有核共享二级缓存。
b.硬件线程:也称为逻辑处理器或者虚拟处理器,一个处理器或者核包含多个硬件线程,硬件线程共享一级缓存和二级缓存。MIPS处理器的叫法是同步多线程(Simultaneous Multi-Threading,SMT),英特尔对它的叫法是超线程享二级缓存。
RCU 机制与内存优化屏障
RCU(read-copy-update)为 Linux 当中的一种同步机制,则为读/拷贝更新。
写者修改对象流程:先复制生成一个副本,然后更新这个副本,最后使用新的对象替换旧的对象,在写者执行复制更新的时候读者则可以读数据。
内存屏障
内存屏障可分为两种类型:编译器内存屏障和 CPU 内存屏障。
防止编译器错误地重排,添加编译器优化屏障如下: