Linux内核源码分析之进程概要及调度时机
这篇文章从 Linux 内核层面分析进程概要及调度时机。
0.1 进程概要进程是对物理世界的建模抽象,每 个进程对应一个 task_struct 数据结构,这个数据结构包含了进程的所有的信息。在 Linux 内核中,不会区分线程和进程的概念,线程也是通过进程来实现的,线程和进程的唯一区别就是:线程没有独立的资源,进程有。所有的进程都是通过其他进程创建出来的,因此,整个进程组织为一棵进程树。0 号进程是 无中生有 凭空产生的,是静态定义出来的,是所有进程的祖先,创建了 INIT(1号)和 kthreadd(2号进程)。
0.2 进程调度时机系统调 用 yield、pause 会使得当前进程让出 CPU,随后进行一次进程调度。系统调用 futex(wait) 等待某个信号量,将进程设置为 TASK_INTERRUPTIBLE状态,然后进行一次进程调度。进程在退出的时候,会系统调用到 exit 方法,将当前进程设置为 TASK_DEAD 之后,进行一次进程调度。在创建新进程、唤醒进程、周期调度过程中,内核会给当前进程设置一个需要调度的标志,然后在下一次中断返回到用户空间时,进行一次调度。 每颗 CPU 都会绑定一个 IDLE 进程,没事就在 CPU 上无聊地空转,偶尔进行一次进程调度。 1 进程概要
1.1 进程是对物理世界的建模抽象
人们在面对一个问题束手无策的时候,经常会创造一个概念,然后基于这个概念来演化出一个系统来解决这个问题。进程的概念就是人类发明出来,为了解决物理世界想要同时做若干件事情的需求,最终演化出了进程子系统。关于进程的基本知识网上有很多,这里说下我的理解: 加载器将可执行程序文件(Linux 中是 ELF 格式)加载到操作系统,操作系统中就多了一个进程。 进程的核心由代码段和数据段组成,代码段就是进程在执行过程中按照正常流程一条条执行的指令,数据段就是指令需要的数据。 每颗 CPU 都有一个 PC(Program Counter)寄存器,这个寄存器指向了下一条要执行的指令地址,由于这个指令必然属于某个进程,所以,每个 CPU 每一时刻只能运行一个进程。 多线程在内核空间本质上也是多进程,多个进程在时间较大的尺度上给人一种可以同时执行的错觉,本质上是通过进程调度交叉执行,只不过这个时间太短,我们感觉不到而已。
1.2 进程的数据结构
由于历史原因,内核中表示进程的数据结 构叫做 task_struct,这个数据结构里面的字段有几十个,我不太想一一列出来,然后占很大篇幅。我会列几个大家比较关心的,在后面的分析过程中,会逐渐展开 task_struct 的其他字段。本篇文档对应的 Linux 内核是 5.0。 // include/linux/sched.h:592 // Linnux 进程底层对应的数据结构 struct task_struct { // 进程的 ID pid_t pid; // 进程的状态 volatile long state; // 进程的父亲 struct task_struct *parent; // 当前进程的子进程 struct list_head children; };
从上面的几个关键的字段可以看出,每个进程都有唯一的 ID 和状态,并且,在系统中,进程是通过一棵树的方式来组织的,也就是说,所有的进程都有父亲,通过我们熟悉的 fork 系统调用来创造。另外,Linux 内核中也是不区分进程和线程的,两者均使用 task_struct 数据结构,线程的本质是共享进程的资源,对应这个数据结构,只要把里面涉及共享的指针指向进程的资源即可。
1.3 特殊的进程
「所有的进程都有父亲」,这句话不一定全对,就像演绎逻辑链一样,我们一直顺着大前提往上追,总会追到第一个 大 bug,这个 大 bug 我们无法证明,只能默认它是对的,它是我们系统的第一性原理。扯远了,Linux 中,这个 大 bug 就是 0 号进程,它的另一个外号叫 IDLE,这个 大 bug 在内核初始化的时候,被显示地定义出来(而不是通过 fork),下面我们来感受一下 Linux 进程子系统中第一个进程 无中生有 的过程。// include/linux/sched/task.h:26 extern struct task_struct init_task; // 这个就是 0 号进程 // init/init_task.c:57 struct task_struct init_task = { // 这个字段没有显示定义出来,而是通过 struct pid 来描述,效果一样 .pid = 0, // 对应了 TASK_RUNNING .state = 0, // 我就是第一个进程,我没有 parent .parent = &init_task, // 初始化子进程链表 .children = LIST_HEAD_INIT(init_task.children), };
init_task 类似于盘古,系统中所有的进程都是由它开辟出来的,在后续的 Linux 内核文章中,我们会逐渐了解这个机制的妙处,我们先把注意力调回到本篇文章的重点,进程切换的机制。
1.4 进程概要小结进程是对物理世界的建模抽象,每个进程对应 一个 task_struct 数据结构,这个数据结构包含了进程的所有的信息。在 Linux 内核中,不会区分线程和进程的概念,线程也是通过进程来实现的,线程和进程的唯一区别就是:线程没有独立的资源,进程有。所有的进程都是通过其他进程创建出来的,因此,整个进程组织为一棵进程树。0 号进程是 无中生有 凭空产生的,是静态定义出来的,是所有进程的祖先。 2 进程调度时机
Linux 内核中,进程调度的时机无处不在,我们来了解几个典型的时机。
2.1 yield 和 pause 让出 cpu
通常情况下,我们的进程运行在用户空间,通过系统调用进入到内核空间,从而做一些更 高级的事情。
yield 系统调用可以让当前进程放弃 cpu,进行系统的调度。 // kernel/sched/core.c:4963 SYSCALL_DEFINE0(sched_yield) { do_sched_yield(); return 0; }
Linux 中的系统调用 通过类似 SYSCALL_DEFINEx 这种方式定义,x 表示参数的个数,sched_yield 系统调用没有参数,所以 x 是 0。
我们沿着调用链往下,来到 do_sched_yield 方法。 // kernel/sched/core.c:4942 static void do_sched_yield(void) { ... schedule(); // :4960 ... }
我们发现,在 4960 行,有一个命名非常简单的函 数调用,叫做 schedule(),这个函数就是内核中进程调度的入口,我们分析进程调度的时机,等价于查看有哪些地方调用了这个方法。
下面我们来看看 pause 这个系统调用: // kernel/signal.c:4170 SYSCALL_DEFINE0(pause) { __set_current_state(TASK_INTERRUPTIBLE); schedule(); } // include/linux/sched.h:185 #define __set_current_state(state_value) current->state = (state_value)
pause 系统调用首先将当前进程设置为 TASK_INTERRUPTIBLE 状态,其实就是给 task_struct 结构中的 state 字段赋值,附上 TASK_INTERRUPTIBLE 之后,在后续进程调度中就可以过滤掉这个进程,选择其他的进程进行调度。接着,同样是一个简单的 schedule 函数,进入到调度的逻辑。
相关视频推荐
linux内核,进程调度器的实现,完全公平调度器 CFS
Linux内核源码分析之进程管理
学习地址:C/C++Linux服务器开发/后台架构师【零声教育】-学习视频教程-腾讯课堂
需要C/C++ Linux服务器架构师学习资料加群 812855908 获取(资料包括 C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg 等),免费分享
2.2 futex 等待资源
futex (fast userspace mutex),用来给上层应用构建更高级别的同步机制,是实现信号量和锁的基础,后面有机会可以单独介绍。我们简化一下场景:一个进程在等待某个信号的时候,最终会通过系统调用进入到 futex,其中某个关键参数为 wait: // kernel/futex.c:3633 SYSCALL_DEFINE6(futex, u32 __user *, uaddr, int, op, u32, val, struct __kernel_timespec __user *, utime, u32 __user *, uaddr2, u32, val3) { ... return do_futex(... op, ...); // :3665 }
这个系统调用有 6 个参数,参数 类型和名称并列展开,上层应用在等待一个信号量的时候,给 op 这个参数的传递的是 FUTEX_WAIT_BITSET,我们通过调用链往下追。 // kernel/futex.c:3573 long do_futex(...int op,...) { int cmd = op & FUTEX_CMD_MASK; switch (cmd) { case FUTEX_WAIT_BITSET: return futex_wait(uaddr, flags, val, timeout, val3); // :3604 ... } ... }
由于中间调用链有点长,下面我们就简化一下调用逻辑,专注核心,这个在我们去阅读源码过程中,也是非常重要的一点,阅读核心逻辑的时候,不要被太多的细节干扰。 // kernel/futex.c:2679 static int futex_wait(...) { ... futex_wait_queue_me(...); // :2713 ... } // kernel/futex.c:2571 static void futex_wait_queue_me(...) { ... // 这里可以看到,调用 futex 的进程将变为睡眠状态,与我们的认知一致 set_current_state(TASK_INTERRUPTIBLE); // :2580 ... freezable_schedule(); // :2598 ... } // include/linux/freezer.h:169 static inline void freezable_schedule(void) { ... schedule(); // :180 ... }
沿着进程调用链下来,我们可以看到,进程系统 调用 futex(wait) 时,可能会将自己设置为睡眠状态并且进行一次进程调度。
2.3 exit 进程退出
多年的编程经验告诉我们,在一个进程退出的 时候会触发进程调度,我们通过内核源码来证明这一点。应用层的进程在退出时,最终会通过 exit 系统调用进入到内核,调用链如下:// kernel/exit.c:946 SYSCALL_DEFINE1(exit, int, error_code) { do_exit((error_code&0xff)<<8); } // kernel/exit.c:773 void do_exit(long code) { ... do_task_dead(); // :933 } // kernel/sched/core.c:3494 void do_task_dead(void) { // 这个地方也是给 task_struct 中的 state 字段赋值 set_special_state(TASK_DEAD); ... __schedule(false); // :3502 ... }
通过调用链,我们 可以看到,进程在退出的时候,最终调用了 __schedule 方法,这里我们可以将这个方法等价于 schedule 方法,因为 schedule 方法最终会调用到这个方法,__schedule 中描述了进程调度的核心逻辑。
2.4 中断返回时调度
除了上述调度时机,还有一类调度时机是中断返回的时候。
介绍中断之前,先描述一下什么是异常:进程的指令按照程序正常流程一直在 CPU 上跑,系统突然发生了一个带有异常号的异常,强迫 CPU 停止执行当前的指令,CPU 随后会在执行完当前指令之后,保存现场,根据异常号跳转到异常处理程序,处理完之后,回到被异常终止的下一条机器指令继续执行。
系统调用是常见一种类型的异常,也是应用代码从用户空间主动进入内核空间的唯一方式。另外一种常见的异常就是硬件中断,比如我们点下鼠标、按下键盘、网卡接收到数据、磁盘数据读写完毕等,都会触发一次硬件中断,运行在用户空间的进程会被动陷入到内核空间,进行中断处理程序的处理。
而中断处理程序处理完之后,势必要返回到用户空间,在返回至用户空间之前,会顺带做一件事情,判断是否要进行进程调度,如果需要,则顺带做一次进程调度。我们通过调用链来分析一下这个过程。
我们拿 arm64 处理器为例,中断处理程序的的 入口是 el0_irq,这里看不懂汇编没有关系,我们抓关键部分即可。 // arch/arm64/kernel/entry.S:838 // 这里即是 arm64 的中断入口 el0_irq: ... 处理中断 ... // 回到用户空间 b ret_to_user // :834 // arch/arm64/kernel/entry.S:895 ret_to_user: ... ldr x1, [tsk, #TSK_TI_FLAGS] // :890 and x2, x1, #_TIF_WORK_MASK cbnz x2, work_pending
8 90 行代码想要表述的是,将 tsk(也就是被中断暂停的当前进程)数据结构中,偏移量为 TSK_TI_FLAGS 传递给 x1 寄存器,顺带说一下,arm64 中有 x0 ~ x31 寄存器。
TSK_TI_FLAGS 常量在 asm-offsets.c 文件中被定义。 // arch/arm64/kernel/asm-offsets.c:48 int main(void) { ... DEFINE(TSK_TI_FLAGS, offsetof(struct task_struct, thread_info.flags)) // :442 ... }
本质上, 就是 task_struct 结构中的 thread_info 结构中的 flags 字段的偏移量: // include/linux/sched.h:592 struct task_struct { ... struct thread_info thread_info; // :598 ... } // arch/arm64/include/asm/thread_info.h:39 struct thread_info { ... unsigned long flags; // :40 ... }
所以 ret_to_user 中的这个逻辑就是,取出 task_struct->thread_info->flags字段,然后通过与 _TIF_WORK_MASK 进行 and 操作: // arch/arm64/include/asm/thread_info.h:118 #define _TIF_WORK_MASK (_TIF_NEED_RESCHED | _TIF_SIGPENDING | _TIF_NOTIFY_RESUME | _TIF_FOREIGN_FPSTATE | _TIF_UPROBE | _TIF_FSCHECK)
进程 中的 flags 与 _TIF_WORK_MASK 进行 and 操作之后,如果二进制位的值不为 0,就跳转(cbnz)到 work_pending 方法。 // arch/arm64/kernel/entry.S:884 work_pending: ... bl do_notify_resume // :886 ... // arch/arm64/kernel/signal.c:915 // 参数中 thread_flags 的值就是上面保存在 x1 寄存器中的值,也就是 `task_struct->thread_info->flags` void do_notify_resume(... long thread_flags) { ... if (thread_flags & _TIF_NEED_RESCHED) { schedule(); // :933 } ... }
到了这里,中断返回到用户空间的调度逻辑大家应该比较清楚了。我们总结一点就是:当中断处理程序返回用户空间的时候,如果被中断的进程被设置了需要进程调度标志,那么就进行一次进程调度。
那么,什么时候当前进程会被设置这个标志?
只有进入到内核空间才能够设置当前进程的需要调度标志,而系统调用是我们主动从用户空间进入内核空间的唯一方式,下面我们就来分析有哪些系统调用会设置当前进程需要调度的标志。
2.4.1 创建新进程
第一类是是 通过 fork 系统调用创建新的进程。相信大家应该或多或少听过,大多数编程语言创建线程,最后都会落到 fork 系统调用。
接下来,我们来分析 fork 系统调用是如何来设置进程需要调度的标识的。// kernel/fork.c:2291 SYSCALL_DEFINE0(fork) { ... return _do_fork(...); } // kernel/fork.c:2196 long _do_fork(...) { struct task_struct *p; ... // 大多数数据结构都是 copy 的父进程,也就是当前进程 p = copy_process(...); // :2227 ... // 创建完子进程之后,让子进程 "苏醒" wake_up_new_task(p); // :2252 ... }
这里我们可以看到,创建子进程的时候,有部分工作是复制父进程(2227 行),也就是当前进程的数据结构,线程和进程的本质区别就在这个方法里面,用一个参数确定要复制哪些资源,我们在后面的文章中会详细分析进程创建过程,这里我们点到为止。
创建完新进程之后,调 用 wake_up_new_task 唤醒新进程,我们来看内核是如何唤醒新进程的。// kernel/sched/core.c:2413 void wake_up_new_task(struct task_struct *p) { ... // 将当前进程设置为 RUNNING 状态,后续即可调度 p->state = TASK_RUNNING; // :2419 ... // 判断是否要抢占当前进程 check_preempt_curr(rq, p, WF_FORK); // :2439 ... }
check_preempt_curr 会根据当前进程的调度类型,执行对应的方法: // kernel/sched/core.c:854 void check_preempt_curr(struct rq *rq, struct task_struct *p, int flags) { ... // rq 是当前 cpu 上的进程队列 // curr 是当前正在 cpu 运行的进程 // sched_class 是当前进程的调度 rq->curr->sched_class->check_preempt_curr(rq, p, flags); // :858 ... }
sched_class 表示进程的调度类型,这个字段在每个 task_struct 中。 // include/linux/sched.h:592 struct task_struct { ... // sched_class 在进程的数据结构中 // 表示调度类型,我们后面的系列文章再详细分析 const struct sched_class *sched_class; // :643 ... } // kernel/sched/sched.h:1715 // Linux 中所有的调度类型 extern const struct sched_class stop_sched_class; extern const struct sched_class dl_sched_class; extern const struct sched_class rt_sched_class; extern const struct sched_class fair_sched_class; extern const struct sched_class idle_sched_class;
可以看到,Linux 中一 共有五种调度类型,fair_sched_class 是一般进程的调度类型,称为公平调度,我们后面的文章中再详细分析这五个调度类型,这里,我们还是聚焦重点。
我们跟随调用链,来到 fair_sched_class 的 check_preempt_check 方法。 // kernel/sched/fair.c:10506 const struct sched_class fair_sched_class = { .check_preempt_curr = check_preempt_wakeup // :10513 } // kernel/sched/fair.c:6814 static void check_preempt_wakeup(rq *rq, task_struct *p...) { struct task_struct *curr = rq->curr; struct sched_entity *se = &curr->se, *pse = &p->se; // 如果 pse 的虚拟时间小于当前进程的虚拟时间,就抢占 if (wakeup_preempt_entity(se, pse) == 1) { // :6867 goto preempt; } preempt: // :6879 // 没有在这里直接调度,而是设置了一个标志,在异常处理返回的时候统一调度 resched_curr(rq); }
check_preempt_wakeup 方法中一处关键的地方,se 表示当前进程的调度实体,pse 表示 fork 出来的进程的调度实体。
调度实体这个对象也定义在进程的数据结构中。 // include/linux/sched.h:592 struct task_struct { ... struct sched_entity se; // :644 ... }
调度实体是为了防止一 个进程不断地 fork 多个子进程,从而无限霸占 cpu,内核可以将一组线程绑定到一起进行统一调度,这里我们不用关心太多细节,仍然聚焦核心。
下面我们来看下 check_preempt_wakeup 方法中 6867 行的 wakeup_preempt_entity 代码做了什么事情。// kernel/sched/fair.c:6767 static int wakeup_preempt_entity(struct sched_entity *curr, struct sched_entity *se) { s64 gran, vdiff = curr->vruntime - se->vruntime; if (vdiff <= 0) return -1; // gran 可以理解为进程运行的最小时间片 gran = wakeup_gran(se); if (vdiff > gran) return 1; return 0; }
公平调度类默认会通过进程的优先级和历史运行情况来计算出一个进程运行的虚拟时间,虚拟时间小的进程可以抢占虚拟时间大的进程。
当然,为了防止频繁抢占调度,要保证进程在 cpu 上的一个最小的运行时间,这个时间默认在 v5.0 内核中是 100 毫秒。
上面这段代码的逻辑,总结来说就是,如果当前进程的时间片已到,并且当前进程的虚拟时间 小于 fork 出来的进程的虚拟时间片(显然是 0),则返回 1,然后进入到标记为 preempt 的代码,即 resched_curr。// kernel/sched/core.c:465 void resched_curr(struct rq *rq) { ... set_tsk_need_resched(curr); // :483 ... } // include/linux/sched.h:1676 static inline void set_tsk_need_resched(struct task_struct *tsk) { set_tsk_thread_flag(tsk,TIF_NEED_RESCHED); }
resched_curr 给当前进程设置一个标志,需要进行一次调度,根据我们上一节的分析,下一次中断返回到用户空间的时候,就会进行一次调度。
2.4.2 futex 唤醒进程
除了 fork 系统调用,在 futex 系统调用的时候,也会设置需要调度的标志。 // kernel/futex.c:3633 SYSCALL_DEFINE6(futex, ... op, ...) { ... return do_futex(... op, ...); // :3665 }
这种情况下,用户传递的 op 参 数是 FUTEX_WAKE_OP,即用户需要进行唤醒操作,我们通过调用链往下追: // kernel/futex.c:3573 long do_futex(...int op,...) { int cmd = op & FUTEX_CMD_MASK; switch (cmd) { case FUTEX_WAKE_OP: return futex_wake_op(...); // :3615 ... } ... } // kernel/futex.c:1683 static int futex_wake_op(...) { ... wake_up_q(...); // :1766 ... } // kernel/sched/core.c:436 void wake_up_q(...) { wake_up_process(task); // :453 } // 后续调用链路有些长,我们中间的代码描述简化处理,最终会落到下面的代码 // kernel/sched/core.c:1667 static void ttwu_do_wakeup(...) { check_preempt_curr(...); }
可以看到 ,futex 的 wake 操作,最后同样会落到和 fork 一样的方法 check_preempt_curr,这个方法我们上面刚分析过,做的事情就是给当前线程设置一个需要调度的标志,在下一次中断返回时进行一次调度。
2.4.3 周期调度
除了系统调用,内核还有一个定时调度机制:周期调度,内核会周期地调用 scheduler_tick 方法执行调度逻辑,我们来分析一下这个过程。 // kernel/sched/core.c:3049 /* * This function gets called by the timer code, with HZ frequency. */ void scheduler_tick(void) { ... // 当前是哪个 cpu? int cpu = smp_processor_id(); // 拿到 cpu 上的进程队列 struct rq *rq = cpu_rq(cpu); // 拿到 cpu 上当前运行的进程 struct task_struct *curr = rq->curr; ... curr->sched_class->task_tick(rq, curr, 0); // :3061 ... }
scheduler_tick 调用当前进程的调度类的 task_tick 方法,我们还是分析常见的公平调度类的 task_tick 方法。// kernel/sched/fair.c:10506 const struct sched_class fair_sched_class = { ... .task_tick = task_tick_fair, // :10530 ... } // kernel/sched/fair.c:10030 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; ... // cfs_rq 可以理解为当前 cpu 上公平调度类的进程队列 cfs_rq = cfs_rq_of(se); entity_tick(cfs_rq, se, queued); // :10037 ... } // kernel/sched/fair.c:4179 static void entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued) { // 更新当前进程的运行时间 update_curr(cfs_q); ... // 更新当前进程的 load update_load_avg(cfs_rq, curr, UPDATE_TG); ... // 如果 cpu 有就绪进程 if (cfs_rq->nr_running > 1) check_preempt_tick(cfs_rq, curr); }
cfs_rq->nr_running 可以理解为当前 cpu 上,公平调度类型的就绪进程和运行进程的和,大于 1 表示有待调度的就绪进程,于是调用 check_preempt_tick:// kernel/sched/fair.c:4023 static void check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr) { unsigned long ideal_runtime, delta_exec; struct sched_entity *se; ... ideal_runtime = sched_slice(cfs_rq, curr); delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime; if (delta_exec > ideal_runtime) { resched_curr(rq_of(cfs_rq)); // :4056 } ... }
check_preempt_tick 方法中,会计算一个进程的理想运行时间,理想运行时间是调度周期 * 当前调度实体权重 / 所有实体权重,如果当前进程运行的时间超过了这个理想运行时间,就尝试一次调度,即调用 resched_curr,这个方法我们在上面分析过:给当前进程设置一个需要调度的标志,这样在下一次中断处理返回时,就会进行一次调度。
2.4.4 中断处理返回时调度小结异常的本质就是程序不按照正常的流程走。系统调用是一种异常,硬件中断也是一种异常,比如我们点击了鼠标,按下了键盘,都触发了一次异常。 内核在处理中断处理返回到用户空间时,会判断当前进程是否有设置需要调度的标志,如果有,就进行一次进程调度。 某些系统调用,如 fork、futex 会在系统调用处理逻辑中设置需要调度的标记,这样在下一次中断返回就可以进行调度。 除了系统调度,内核会周期性地给内核设置需要调度的标记,一旦当前进程总运行时间超了,就设置这个标记,下一次中断返回就可以进行调度。
2.5 IDLE 进程调度
本文开篇提到了操作系统中的第一个进程,0 号进程, 内核 无中生有 地创建完这个进程,这个进程总得干点啥。
其中一件事情就是不断进行进程调度,我们来分析一下这个过程。
2.5.1 第一颗 CPU 上的 IDLE 进程
内核在启动过程中,第一颗 CPU 进入到 start_kernel 方法,这个方法可以看做初始化整个内核的入口,在调用这个方法之前,0 号进程已经静态地绑在了当前的 CPU 上,参考本文 1.3 小节。 // init/main.c:537 // 在第一颗 CPU 上执行,当前进程的是 0 号进程 void start_kernel(void) { ... // 一系列初始化操作 ... arch_call_rest_init(); // :739 }
关于内核的初始化,我们后面再分析,这里我们还是聚焦于 0 号进程的调度逻辑。 // init/main.c:532 void arch_call_rest_init(void) { rest_init(); // :534 } // init/main.c:397 void rest_init(void) { int pid; ... // 0 号进程创建了 1 号进程 init pid = kernel_thread(kernel_init,...); // :408 ... // 0 号进程创建了 2 号进程 kthreadd pid = kernel_thread(kthreadd,...); // :420 ... // 调度逻辑 cpu_startup_entry(CPUHP_ONLINE); }
0 号进程创建了 1 号进程和 2 号进程, 我们通过 ps -ef 指令是可以看到这两个进程,如下图所示。
1 号进程和 2 号进程
其中的 PPID 就是指的父进程的进程 ID。用户空间的所有的进程的祖先都是 1 号进程,读者可以在自己的 Linux 系统上使用 ps -ef 验证这一点。
关乎这两个顶级进程的详细分析,我们后面的文章会提到,这里我们还是聚焦于 0 号进程的调度逻辑。
0 号进程创建了两个顶级进程之后, 调用 cpu_startup_entry// kernel/sched/idle.c:348 void cpu_startup_entry(...) { while (1) do_idle(); } // kernel/sched/idle.c:224 static void do_idle(void) { ... schedule_idle(); // :286 ... } // kernel/sched/core.c:3545 void schedule_idle(void) { ... __schedule(false); // :3556 ... }
从上面的调用链可以看到,0 号进程会用一个 while 死循环,不断反复地做一件事情,这个事情就是调度。
0 号进程可以理解为系统中所有进程中优先级最低的进程,当没有进程可选中被调度,就选择 0 号进程,而 0 号进程所做的事情就是一个死循环逻辑,由此可见,这个进程确实闲得慌,所以也叫做 IDLE 进程,后面我们统称为 IDLE 进程。
2.5.2 其余 CPU 上的 IDLE 进程
除了第一颗 CPU 上有个 IDLE 进程不断在跑,其余 CPU 也都有 IDLE 进程不断在跑,这些个进程是第一颗 CPU 上的 IDLE 进程创建出来的,我们来分析一下这个过程。
在上面的 rest_init 方法中,第一颗 CPU 上的 IDLE 进程调用 kernel_thread 创建了 1 号进程,它的入口函数是 kernel_init,所以也叫 INIT 进程。
下面,我们来追一下这个调用链。 // init/main.c:1050 static int kernel_init(void *unused) { ... kernel_init_freeable(); // :1054 ... } // init/main.c:1103 static void kernel_init_freeable(void) { ... smp_init(); // :1129 ... } // kernel/smp.c:563 void smp_init(void) { ... // 创建出其他的 IDLE 进程 idle_threads_init(); pr_info("Bringing up secondary CPUs ... "); ... // 启动其他 CPU for_each_present_cpu(cpu) { ... cpu_up(cpu); } }
在 smp_init 方法中,先通过 idle_threads_init 方法复制出一堆 IDLE 进程,假设有 4 颗 CPU,除去当前进程,就复制出 3 个 IDLE 进程。// kernel/smpboot.c:66 void idle_threads_init(void) { unsigned int cpu, boot_cpu; boot_cpu = smp_processor_id(); for_each_possible_cpu(cpu) { if (cpu != boot_cpu) idle_init(cpu); } } // kernel/smpboot.c:50 static void idle_init(unsigned int cpu) { struct task_struct *tsk = per_cpu(idle_threads, cpu); if (!tsk) { // 复制进程 tsk = fork_idle(cpu); per_cpu(idle_threads, cpu) = tsk; } }
上面的逻辑即是,如果某个 CPU 上没有绑定 IDLE 进程,就调用 fork_idle 进行创建,通过 per_cpu 进行绑定。
这些IDLE 进程初始化完成之后,开始加载其余 CPU,入口函数是 secondary_start_kernel,我们还是拿 arm64 架构为例来分析。// arch/arm64/kernel/smp.c:187 void secondary_start_kernel(void) { ... cpu_startup_entry(CPUHP_AP_ONLINE_IDLE); // :252 } // kernel/sched/idle.c:348 void cpu_startup_entry(...) { while (1) do_idle(); }
至此,我们发现,其余 CPU 的 IDLE 进程也是和第一颗 CPU 的 IDLE 进程做着一样的事情,即不断死循环进行进程调度,最终目的都是为了当前 CPU 一直可以有机器指令在跑。
2.5.3 IDLE 进程调度小结内核的核心初始化流程是由第一颗 CPU 来做的,在这个流程中,第一个 IDLE 进程创建了 1 号进程和 2 号进程。 所有用户空间的祖先进程都是 1 号进程,也叫 INIT 进程,我们熟悉的 "僵尸进程" 最后都会被 INIT 进程给清理。 INIT 进程还给其余 CPU 创建了 IDLE 进程。 IDLE 进程带有一个死循环逻辑,持续不断尝试进程调度,为的就是 CPU 上一直可以有机器指令在执行。
2.6 进程调度时机小结系 统调用 yield、pause 会使得当前进程让出 CPU,随后进行一次进程调度。系统调用 futex(wait) 等待某个信号量,将进程设置为 TASK_INTERRUPTIBLE状态,然后进行一次进程调度。进程在退出的时候,会系统调用到 exit 方法,将当前进程设置为 TASK_DEAD 之后,进行一次进程调度。在创建新进程、唤醒进程、周期调度过程中,内核会给当前进程设置一个需要调度的标志,然后在下一次中断返回到用户空间时,进行一次调度。3 本文总结我们通常意识上的进程在 Linux 内核中的实 体是由 task_struct 来承载,这个数据结构有进程所有的信息。 0 号进程,即 IDLE 进程是在代码中静态定义的,是所有进程的祖先,它创造了 1 号进程,也就是 INIT 进程,这个进程是所有用户空间进程的祖先。 在一些系统调用过程中,会直接触发进程调度,在另一些系统调用中,会设置需要调度的标志,以便中断返回时进行一次进程调度。 内核也会周期性地进行调度,其中一个是周期性地给进程设置需要调度的标志,另一个就是 IDLE 进程不断尝试调度。
2019神舟电脑新品发布会轻薄持久,神舟精盾焕芯发布随着笔记本电脑市场的分类越来越精细化,消费者对于笔记本的选择早已不再停留于简单的游戏本和商务本二选一的时代了。而随着移动办公场景的丰富化,用户对于能够满足办公需求和轻度网游的轻薄本
GalaxyNote20换装纯平屏幕仅限基础版本本周早些时候有传言称,三星可能会放弃GalaxyNote20的曲面屏设计,不过,目前尚不清楚这种新的纯平屏幕设计是仅限基础机型采用,还是包括GalaxyNote20Note20Ul
国企数字化转型的基本认识与参考架构推进数字化转型协同创新平台建设,组织数字化转型相关交流研讨,切实推动国有企业数字化转型为进一步宣传推广国有企业数字化转型的政策布局先进理念方法路径和典型模式,营造国有企业数字化转型
云徙科技2021年第一期新员工培训精彩回放3月27日,云徙科技2021年第一期新员工培训大会在广州总部成功举行!微风拂煦,春暖花开,在这充满朝气与活力的时节,云徙科技迎来了2021年第一期新员工培训大会,来自北京上海广州深
国有企业全链路数字化转型白皮书震撼首发!发布会抢先看随着数字产业化产业数字化的快速发展,数字经济已经成为中国经济未来重要的发力点和增长点。数字经济背景的转型升级,不再是一道战略选择题,而是企业战略的必选题。其中,国有企业作为我国国民
定了!4月9日,美家行业CEO数字化创新峰会来啦近年来,企业数字化转型浪潮愈演愈烈,而后疫情时代的到来,转型和创新更为迫切。2021年,我国迈入到了十四五建设元年,十四五规划纲要将数字经济独立成篇,描绘出未来5年数字中国建设的崭
云徙发布国企数字化转型白皮书分享数字实战案例明者因时而变,知者随事而至,强者乘势而进。国有企业数字化转型已经进入重要时期随着云计算大数据5G网络的发展应用,数字技术正加速向各行各业渗透,数字经济的发展趋势越加明显。根据相关数
炽热的星AROMA单动铁耳塞星Star评测当得知AROMA即将发布单动铁耳塞星的时候,我并没有感到很惊讶,反而认为这一举动是情理和意料之中的事,毕竟单单元耳塞已趋于成熟,而星的发布无疑给AROMA的产品线注入了新鲜血液。产
关于借钱与不借钱浅谈我一个关系较好的老同学,什么都好,就是喜欢借钱不还,以前读书才毕业,他也曾帮助过我,后来就无休止的借钱,开始几百几百的借,我心想他帮助过我,还不还无关紧要,陆陆续续已经借了我几千都
PS5超高速SSD是焊接在主板上的,媒体忽悠很难写死外媒Noetbookcheck表示,玩家们不用担心PS5的SSD会写死,因为其TBW寿命会很高。例如1TB的西数黑盘SN750具备600TBW的写入寿命,假设PS5SSD有类似的T
小刚被淘汰感悟小刚周传雄被淘汰,这个问题说明,任何人,任何好的产品都要随时不断的打广告,不断的经营推销自己啊!有些人说康师傅方便面,可口可乐,耐克等这么高知名度了为什么还要到处打广告啊!如果不经