更新一下这个系列
目录
啥是进程调度呢?简单的讲就是决定进程之间,如何和谐高效的在一起工作——对于进程自己而言,也就是自己何时被选中执行,何时被暂时停止让出CPU等行为。进程调度是一个并发操作系统的概念,我们这里首先需要引入并发操作系统(MultiTasking Operating System
)
多任务操作系统
定义很简单:就是那些可以并发的(同时的)执行多个任务的操作系统。他给用户制造了一个幻象:也就是说好像有很多个任务在并发执行,但是对于那一些线程数大于CPU核数的电脑而言(事实上就是大部分的电脑)只有CPU核数的进程数实际上是真正在运行的!其余的都只是在等待被执行。只是这些进程的切换速度非常快,以至于你根本无法察觉到!
一个特例就是那些比较古老的单核CPU电脑。只有一个进程实际上在真正的运行,但是进程调度的切换速度很快以至于你无法察觉到 。
多任务操作系统有两类:一类为协同的(cooperate
)并发任务操作系统。另一种则是抢占式(preemptive
)并发任务操作系统 。我们重点关心后者,绝大多数的操作系统都是抢占式的。
对于协作式的操作系统:一个任务得到了 CPU 时间,除非它自己放弃使用 CPU ,否则将完全霸占 CPU ,所以任务之间需要协作使用一段时间的 CPU ,放弃使用,其它的任务也如此,才能保证系统的正常运行。如果有一个任务死锁,则系统也同样死锁。
对于抢占式的操作系统,操作系统将会协调各个进程之间的运行操作系统会轮流询问每一个任务是否需要使用 CPU ,需要使用的话就让它用,不过在一定时间后,操作系统会剥夺当前任务的 CPU 使用权,把它排在询问队列的最后,再去询问下一个任务。如果有一个任务死锁,系统仍能正常运行。抢占式多任务处理是将同时进行的各项任务,依照重要程度来排定优先顺序。具有最高优先级的线程就是当前正在运行的那个线程。驻留在CPU内正在运行的线程会在什么时候中止呢?
-
属于它的时间片已经到期
-
是加入了另一个优先级更高的线程。
通过不断从一个线程到另一个线程的切换和线程的迅速执行给人的感觉是线程的执行是同时进行的。线程并非随时需要运行,经常发生的情况是某个线程需要等待用户的输入,另一个线程的信息或等待I/O请求,否则便无法执行下去。有些线程也许本来就处于挂起状态。
Linux调度器
我们的调度器就是来实际的完成进程调度这个工作。在古老的Linux里:我们使用的是O(1)
调度器,但是我们不讲,他尽管可以很好的在具备极多的非交互性的进程中工作,但是对于同等桌面操作系统上它工作的并不理想。因此,我们现在更加关心的是一个更加好的调度器:CFS调度器(完全公平调度器)
IO密集型与CPU密集型
我们把进程分为两种类型:IO密集型和CPU密集型。我们“望词生意”很容易理解到:
IO密集型的进程:也就是那一些实际上在CPU上并没有跑多久的进程。它们的生命周期大部分时间都在等待IO,或者说是被IO所阻塞
另一方面对于CPU密集型程序:他们大部分的生命周期则是跑在CPU上执行,主要是在执行一些计算等任务。
但是这并不绝对,一些任务可能是两者的复合(XWindowsServer)
进程优先级
一个实现抢占式操作系统的重要抽象就是进程优先级。
对于一个高优先级的进程更容易被CPU选中执行,同样的。在其他的操作系统里面一些高优先级的进程还会有被分配到更长的时间片(即被CPU执行的时间比其他的进程更长才会被放弃选选择去执行其他的进程)
Linux内核实现的两种独立的优先级分配子系统::
一种是nice值,其值大小为负二十到正19(默认值是零)。一个大的nice值则表明它有一个较低的优先级,也就是说这个进程很友好愿意主动让出CPU
另一个则是实时的进程优先级。这个体系则是跟上面所阐述的nice值体系相反。一个高实时优先级值则表明它有更高的优先级。
时间片
时间片可以被认为是CPU选择执行当前进程时间的一种量化。值得注意的是:太过长的一个时间片将会导致系统有很低的交互性能,而太过短的时间片则将会导致大量的CPU执行时间被浪费在进程切换上。(仔细思考一下,你在点击桌面的时候一直没有反应,因为他在处理Word进程;同样的,分配过短的时间片导致真正的任务压根难以被执行!执行一小会就被切换,然后又被切换,导致CPU实际上在切换进程而不是执行进程)
对于那些IO密集型的进程并不需要很长的时间片,而相反:CPU密集型的进程则需要较长的时间片。
Linux的完全公平调度器并不直接赋予这些进程时间片,相反它则是在规划这些进程占有单位时间CPU时间的份额。这个调度器更加倾向于很快被执行完毕的进程。对于那些对比与当前进程的所占据CPU份额较小的进程:它会抢占当前进程并且快速的执行完毕程序。如果不是,那么它将会被安排在稍后才执行
Linux调度算法
每种调度类都有自己的调度策略,他们将会决定进程如何被调度。CFS是其中的一个,代号是SCHED_NORMAL
传统unix调度缺陷
为了引出CFS,我们需阐述传统的Unix调度类缺陷:
过于静态,无法根据当前调度器的现状进行动态的调整
过于依赖初始情况
过于依赖系统时钟,时间片的大小必须是整数倍
很容易被用户所愚弄(伪造IO调度骗取时间片)
CFS
CFS调度器没有时间片的概念了,而是根据实际的运行时间和虚拟运行时间来对任务进行排序,从而选择调度。 CFS要求在一个调度周期内每一个进程都能被执行至少一次。
time_slice[i] = (time_latency × 进程的权重 / 运行队列中所有进程的权重总和)
这样计算的话,$优先级高 \rightarrow 权重大 \rightarrow 获得的cpu时间多$。 上面的 `time_slice[i]` 仍然是真实的时间,是实实在在分配给进程的时间,但是我们后面要引出一个虚拟运行时间,以动态的衡量相对进程时间:
vruntime衡量了进程在CPU上运行的相对时间,与进程的实际运行时间和权重有关,如果把cpu运行时间也看作一种资源,为了保证资源得到公平分配,运行时间少的进程将得到更多的资源以弥补它们在过去获得的资源的不足。所以CFS定义了一个 “虚拟运行时间” 来定义一个进程获得资源的多与少,每一次调度都选择 vruntime 最小的进程,把CPU资源分配给他。
计算公式为:vruntime = real_runtime * 优先级为0的进程的权重 / 进程权重, 其中权重由优先级决定,可看代码中的sched_nice_to_weight
数组,优先级为0
的进程权重为1024
,每个优先级之间权重比值大概为 1.25
。
根据计算公式,每一次执行完成,都需要更新:
vruntime[i] += time_slice[i] * (优先级为0的进程的权重 / 进程权重)
、
Linux调度实现
调度实体
CFS中用于记录进程运行时间的数据结构为“调度实体”,源代码在这里:
struct sched_entity { /* 用于进行调度均衡的相关变量,主要跟红黑树有关 */ struct load_weight load; // 权重,跟优先级有关 unsigned long runnable_weight; // 在所有可运行进程中所占的权重 struct rb_node run_node; // 红黑树的节点 struct list_head group_node; // 所在进程组 unsigned int on_rq; // 标记是否处于红黑树运行队列中 u64 exec_start; // 进程开始执行的时间 u64 sum_exec_runtime; // 进程总运行时间 u64 vruntime; // 虚拟运行时间,下面会给出详细解释 u64 prev_sum_exec_runtime; // 进程在切换CPU时的sum_exec_runtime,简单说就是上个调度周期中运行的总时间 u64 nr_migrations; struct sched_statistics statistics; // 以下省略了一些在特定宏条件下才会启用的变量 }
这是CFS的调度实体,从而追踪我们的进程
vruntime
刚刚提到的vruntime在这里:
我们称它为“虚拟运行时间”,该运行时间的计算是经过了所有可运行进程总数的标准化(简单说就是加权的)。它以ns为单位,与定时器节拍不再相关。
可以认为这是CFS为了能够实现理想多任务处理而不得不虚拟的一个新的时钟,具体地讲,一个进程的vruntime会随着运行时间的增加而增加,但这个增加的速度由它所占的权重
load
来决定。结果就是权重越高,增长越慢:所得到的调度时间也就越小 —— CFS用它来记录一个程序到底运行了多长时间以及还应该运行多久。
/* * Update the current task's runtime statistics. */ 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; if (unlikely(!curr)) return; // 获得从最后一次修改负载后当前任务所占用的运行总时间 delta_exec = now - curr->exec_start; if (unlikely((s64)delta_exec <= 0)) return; // 更新执行开始时间 curr->exec_start = now; schedstat_set(curr->statistics.exec_max, max(delta_exec, curr->statistics.exec_max)); curr->sum_exec_runtime += delta_exec; schedstat_add(cfs_rq->exec_clock, delta_exec); // 计算虚拟时间,具体的转换算法写在clac_delta_fair函数中 curr->vruntime += calc_delta_fair(delta_exec, curr); update_min_vruntime(cfs_rq); if (entity_is_task(curr)) { struct task_struct *curtask = task_of(curr); trace_sched_stat_runtime(curtask, delta_exec, curr->vruntime); cgroup_account_cputime(curtask, delta_exec); account_group_exec_runtime(curtask, delta_exec); } account_cfs_rq_runtime(cfs_rq, delta_exec); }
进程选择
我们需要根据vruntime选择进程执行,为了快,我们选择红黑树来构建数据结构、 可以看快速的查找进程执行。
CFS要找到下一个需要调度的进程,那么就是要找到这棵红黑树上键值最小的那个节点:就是最左叶子节点
static struct sched_entity * pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr) { struct sched_entity *left = __pick_first_entity(cfs_rq); struct sched_entity *se; /* * If curr is set we have to see if its left of the leftmost entity * still in the tree, provided there was anything in the tree at all. */ if (!left || (curr && entity_before(curr, left))) left = curr; se = left; /* ideally we run the leftmost entity */ /* * 下面的过程主要针对一些特殊情况,我们在此不做讨论 */ 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; } if (second && wakeup_preempt_entity(second, left) < 1) se = second; } if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1) se = cfs_rq->last; if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1) se = cfs_rq->next; clear_buddies(cfs_rq, se); return se; }
向进程树添加进程
static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { bool renorm = !(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_MIGRATED); bool curr = cfs_rq->curr == se; /* * 如果要加入的进程就是当前正在运行的进程,重新规范化vruntime * 然后更新当前任务的运行时统计数据 */ if (renorm && curr) se->vruntime += cfs_rq->min_vruntime; update_curr(cfs_rq); /* * Otherwise, renormalise after, such that we're placed at the current * moment in time, instead of some random moment in the past. Being * placed in the past could significantly boost this task to the * fairness detriment of existing tasks. */ if (renorm && !curr) se->vruntime += cfs_rq->min_vruntime; /* * 更新对应调度器实体的各种记录值 */ update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH); update_cfs_group(se); enqueue_runnable_load_avg(cfs_rq, se); account_entity_enqueue(cfs_rq, se); if (flags & ENQUEUE_WAKEUP) place_entity(cfs_rq, se, 0); check_schedstat_required(); update_stats_enqueue(cfs_rq, se, flags); check_spread(cfs_rq, se); if (!curr) __enqueue_entity(cfs_rq, se); // 真正的插入过程 se->on_rq = 1; if (cfs_rq->nr_running == 1) { list_add_leaf_cfs_rq(cfs_rq); check_enqueue_throttle(cfs_rq); } }
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se) { struct rb_node **link = &cfs_rq->tasks_timeline.rb_root.rb_node; struct rb_node *parent = NULL; struct sched_entity *entry; bool leftmost = true; /* * 在红黑树中搜索合适的位置 */ while (*link) { parent = *link; entry = rb_entry(parent, struct sched_entity, run_node); /* * 具有相同键值的节点会被放在一起 */ if (entity_before(se, entry)) { link = &parent->rb_left; } else { link = &parent->rb_right; leftmost = false; } } rb_link_node(&se->run_node, parent, link); rb_insert_color_cached(&se->run_node, &cfs_rq->tasks_timeline, leftmost); }
删除一个进程
static void dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { /* * 更新“当前进程”的运行统计数据 */ update_curr(cfs_rq); /* * When dequeuing a sched_entity, we must: * - Update loads to have both entity and cfs_rq synced with now. * - Substract its load from the cfs_rq->runnable_avg. * - Substract its previous weight from cfs_rq->load.weight. * - For group entity, update its weight to reflect the new share * of its group cfs_rq. */ update_load_avg(cfs_rq, se, UPDATE_TG); dequeue_runnable_load_avg(cfs_rq, se); update_stats_dequeue(cfs_rq, se, flags); clear_buddies(cfs_rq, se); if (se != cfs_rq->curr) __dequeue_entity(cfs_rq, se); se->on_rq = 0; account_entity_dequeue(cfs_rq, se); /* * 重新规范化vruntime */ if (!(flags & DEQUEUE_SLEEP)) se->vruntime -= cfs_rq->min_vruntime; /* return excess runtime on last dequeue */ return_cfs_rq_runtime(cfs_rq); update_cfs_group(se); /* * Now advance min_vruntime if @se was the entity holding it back, * except when: DEQUEUE_SAVE && !DEQUEUE_MOVE, in this case we'll be * put back on, and if we advance min_vruntime, we'll be placed back * further than we started -- ie. we'll be penalized. */ if ((flags & (DEQUEUE_SAVE | DEQUEUE_MOVE)) == DEQUEUE_SAVE) update_min_vruntime(cfs_rq); }
和插入一样,实际对树节点操作的工作由__dequeue_entity()
实现:
rust复制代码static void __dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se) { rb_erase_cached(&se->run_node, &cfs_rq->tasks_timeline); }
进程的休眠与唤醒
那些被打入冷宫的进程,就需要好好休息(不是。
在OS里,我们认为这个就是进程的休眠与唤醒。他们需要被标记状态,穿梭于数据结构当中。
在Linux下:
-
睡眠:进程将自己标记成休眠状态,然后从可执行红黑树中移除,放入等待队列,然后调用
schedule()
选择和执行一个其他进程。 -
唤醒:进程被设置为可执行状态,然后从等待队列移到可执行红黑树中去。
等待队列
先来说说等待队列:和可运行队列的复杂结构不同,等待队列在linux中的实现只是一个简单的链表。所有有关等待队列的数据结构被定义在include/linux/wait.h
中,具体的实现代码则被定义在kernel/sched/wait.c
中。内核使用wait_queue_head_t
结构来表示一个等待队列,它其实就是一个链表的头节点,但是加入了一个自旋锁来保持一致性(等待队列在中断时可以被随时修改)
struct wait_queue_head { spinlock_t lock; struct list_head head; }; typedef struct wait_queue_head wait_queue_head_t;
而休眠的过程需要进程自己把自己加入到一个等待队列中,这是一个可能的流程:
调用宏
DEFINE_WAIT()
创建一个等待队列的项(链表的节点)调用
add_wait_queue()
把自己加到队列中去。该队列会在进程等待的条件满足时唤醒它,当然唤醒的具体操作需要进程自己定义好(你可以理解为一个回调)调用
prepare_to_wait()
方法把自己的状态变更为上面说到的两种休眠状态中的其中一种。
下面是上述提到的方法的源码:
void add_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry) { unsigned long flags; wq_entry->flags &= ~WQ_FLAG_EXCLUSIVE; spin_lock_irqsave(&wq_head->lock, flags); __add_wait_queue(wq_head, wq_entry); spin_unlock_irqrestore(&wq_head->lock, flags); } static inline void __add_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry) { list_add(&wq_entry->entry, &wq_head->head); } prepare_to_wait(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry, int state) { unsigned long flags; wq_entry->flags &= ~WQ_FLAG_EXCLUSIVE; spin_lock_irqsave(&wq_head->lock, flags); if (list_empty(&wq_entry->entry)) __add_wait_queue(wq_head, wq_entry); // 标记自己的进程状态 set_current_state(state); spin_unlock_irqrestore(&wq_head->lock, flags); }
唤醒
唤醒操作主要通过wake_up()
实现,它会唤醒指定等待队列上的所有进程。内部由try_to_wake_up()
函数将对应的进程标记为TASK_RUNNING
状态,接着调用enqueue_task()
将进程加入红黑树中。
wake_up()
系函数由宏定义,一般具体内部由下面这个函数实现:
/* * The core wakeup function. Non-exclusive wakeups (nr_exclusive == 0) just * wake everything up. If it's an exclusive wakeup (nr_exclusive == small +ve * number) then we wake all the non-exclusive tasks and one exclusive task. * * There are circumstances in which we can try to wake a task which has already * started to run but is not in state TASK_RUNNING. try_to_wake_up() returns * zero in this (rare) case, and we handle it by continuing to scan the queue. */ static int __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode, int nr_exclusive, int wake_flags, void *key, wait_queue_entry_t *bookmark) { wait_queue_entry_t *curr, *next; int cnt = 0; if (bookmark && (bookmark->flags & WQ_FLAG_BOOKMARK)) { curr = list_next_entry(bookmark, entry); list_del(&bookmark->entry); bookmark->flags = 0; } else curr = list_first_entry(&wq_head->head, wait_queue_entry_t, entry); if (&curr->entry == &wq_head->head) return nr_exclusive; list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) { unsigned flags = curr->flags; int ret; if (flags & WQ_FLAG_BOOKMARK) continue; ret = curr->func(curr, mode, wake_flags, key); if (ret < 0) break; if (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive) break; if (bookmark && (++cnt > WAITQUEUE_WALK_BREAK_CNT) && (&next->entry != &wq_head->head)) { bookmark->flags = WQ_FLAG_BOOKMARK; list_add_tail(&bookmark->entry, &next->entry); break; } } return nr_exclusive; }
抢占与上下文切换
上下文切换
上下文切换是指从一个可执行进程切换到另一个可执行进程。
当发生进程切换的时候,它实际上做两个基础的工作:
第一个工作是调用函数
switch_mm()
它用来切换虚拟地址映射随后调用函数
switch_to()
,它用来切换进程的状态,以及保存先前进程的堆栈上下文和处理器寄存器状态和其他的跟处理架构有关系的一切信息。这是为了进程切换回来时可以正确的加载先前的正确的上下文
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); // 把虚拟内存从上一个内存映射切换到新进程中 if (!mm) { next->active_mm = oldmm; mmgrab(oldmm); enter_lazy_tlb(oldmm, next); } else switch_mm_irqs_off(oldmm, mm, next); 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); }
上下文切换由schedule()
函数在切换进程时调用。但是内核必须知道什么时候调用schedule()
,如果只靠用户代码显式地调用,代码可能会永远地执行下去。
为此,内核为每个进程设置了一个need_resched
标志来表明是否需要重新执行一次调度,当某个进程应该被抢占时,scheduler_tick()
会设置这个标志,当一个优先级高的进程进入可执行状态的时候,try_to_wake_up()
也会设置这个标志位,内核检查到此标志位就会调用schedule()
重新进行调度。
用户抢占
内核即将返回用户空间的时候,如果need_reshced
标志位被设置,会导致schedule()
被调用,此时就发生了用户抢占。意思是说,既然要重新进行调度,那么可以继续执行进入内核态之前的那个进程,也完全可以重新选择另一个进程来运行,所以如果设置了need_resched
,内核就会选择一个更合适的进程投入运行。
简单来说有以下两种情况会发生用户抢占:
-
从系统调用返回用户空间
-
从中断处理程序返回用户空间
内核抢占
Linux和其他大部分的Unix变体操作系统不同的是,它支持完整的内核抢占。不支持内核抢占的系统意味着:内核代码可以一直执行直到它完成为止,内核级的任务执行时无法重新调度,各个任务是以协作方式工作的,并不存在抢占的可能性。
在Linux中,只要重新调度是安全的,内核就可以在任何时间抢占正在执行的任务,这个安全是指,只要没有持有锁,就可以进行抢占。
为了支持内核抢占,Linux做出了如下的变动:
-
为每个进程的
thread_info
引入了preempt_count
计数器,用于记录持有锁的数量,当它为0的时候就意味着这个进程是可以被抢占的。 -
从中断返回内核空间的时候,会检查
need_resched
和preempt_count
的值,如果need_resched
被标记,并且preempt_count
为0,就意味着有一个更需要调度的进程需要被调度,而且当前情况是安全的,可以进行抢占,那么此时调度程序就会被调用。
除了响应中断后返回,还有一种情况会发生内核抢占,那就是内核中的进程由于阻塞等原因显式地调用schedule()
来进行显式地内核抢占:当然,这个进程显式地调用调度进程,就意味着它明白自己是可以安全地被抢占的,因此我们不用任何额外的逻辑去检查安全性问题。
下面罗列可能的内核抢占情况:
中断处理正在执行,且返回内核空间之前
内核代码再一次具有可抢占性时
内核中的任务显式地调用
schedule()
内核中的任务被阻塞
Reference
Linux进程调度逻辑与源码分析 - 掘金 (juejin.cn)
完全公平调度算法 | 操作系统实验 (dragonos-community.github.io)
完全公平调度器 — The Linux Kernel documentation