一文读懂Linux内核进程及调度时机原理

news2024/12/26 11:55:28

前言

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 进程是对物理世界的建模抽象

人们在面对一个问题束手无策的时候,经常会创造一个概念,然后基于这个概念来演化出一个系统来解决这个问题。进程的概念就是人类发明出来,为了解决物理世界想要同时做若干件事情的需求,最终演化出了进程子系统。关于进程的基本知识网上有很多,这里说下我的理解:

加载器将可执行程序文件(Linux 中是 ELF 格式)加载到操作系统,操作系统中就多了一个进程。

进程的核心由代码段和数据段组成,代码段就是进程在执行过程中按照正常流程一条条执行的指令,数据段就是指令需要的数据。

每颗 CPU 都有一个 PC(Program Counter)寄存器,这个寄存器指向了下一条要执行的指令地址,由于这个指令必然属于某个进程,所以,每个 CPU 每一时刻只能运行一个进程。

多线程在内核空间本质上也是多进程,多个进程在时间较大的尺度上给人一种可以同时执行的错觉,本质上是通过进程调度交叉执行,只不过这个时间太短,我们感觉不到而已。

1.2 进程的数据结构

由于历史原因,内核中表示进程的数据结构叫做 task_struct,这个数据结构里面的字段有几十个,我不太想一一列出来,然后占很大篇幅。我会列几个大家比较关心的,在后面的分析过程中,会逐渐展开 task_struct 的其他字段。本篇文档对应的 Linux 内核是 5.0。

// include/linux/sched.h:592
// Linnux 进程底层对应的数据结构
structtask_struct{//  进程的 ID
pid_tpid;//  进程的状态    
volatilelongstate;//  进程的父亲    
structtask_struct*parent;//  当前进程的子进程    
structlist_headchildren;};

从上面的几个关键的字段可以看出,每个进程都有唯一的 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 号进程是 无中生有 凭空产生的,是静态定义出来的,是所有进程的祖先。

内核资料直通车:

最新Linux内核源码资料文档+视频资料docs.qq.com/doc/DTmFTc29xUGdNSnZ2

学习直通车:

Linux内核源码/内存调优/文件系统/进程管理/设备驱动/网络协议栈ke.qq.com/course/4032547?flowToken=1040236

二,进程调度时机

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 函数,进入到调度的逻辑。

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
staticintfutex_wait(...){...futex_wait_queue_me(...);// :2713
...}// kernel/futex.c:2571 
staticvoidfutex_wait_queue_me(...){...//  这里可以看到,调用 futex 的进程将变为睡眠状态,与我们的认知一致
set_current_state(TASK_INTERRUPTIBLE);// :2580
...freezable_schedule();// :2598
...}// include/linux/freezer.h:169
staticinlinevoidfreezable_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
voiddo_exit(longcode){...do_task_dead();// :933
}// kernel/sched/core.c:3494
voiddo_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:...处理中断...//  回到用户空间
bret_to_user// :834
	
// arch/arm64/kernel/entry.S:895
ret_to_user:...ldr	x1,[tsk,#TSK_TI_FLAGS]// :890
and	x2,x1,#_TIF_WORK_MASKcbnzx2,work_pending

890 行代码想要表述的是,将 tsk(也就是被中断暂停的当前进程)数据结构中,偏移量为 TSK_TI_FLAGS 传递给 x1 寄存器,顺带说一下,arm64 中有 x0 ~ x31 寄存器。

TSK_TI_FLAGS 常量在 asm-offsets.c 文件中被定义。

// arch/arm64/kernel/asm-offsets.c:48
intmain(void){...DEFINE(TSK_TI_FLAGS,offsetof(structtask_struct,thread_info.flags))// :442
...}

本质上,就是 task_struct 结构中的 thread_info 结构中的 flags 字段的偏移量:

// include/linux/sched.h:592
structtask_struct{...structthread_infothread_info;// :598
...}// arch/arm64/include/asm/thread_info.h:39
structthread_info{...unsignedlongflags;// :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
structtask_struct{...//  sched_class 在进程的数据结构中
//  表示调度类型,我们后面的系列文章再详细分析 
conststructsched_class*sched_class;// :643
...}// kernel/sched/sched.h:1715
// Linux 中所有的调度类型
externconststructsched_classstop_sched_class;externconststructsched_classdl_sched_class;externconststructsched_classrt_sched_class;externconststructsched_classfair_sched_class;externconststructsched_classidle_sched_class;

可以看到,Linux 中一共有五种调度类型,fair_sched_class 是一般进程的调度类型,称为公平调度,我们后面的文章中再详细分析这五个调度类型,这里,我们还是聚焦重点。

我们跟随调用链,来到 fair_sched_class 的 check_preempt_check 方法。

// kernel/sched/fair.c:10506
conststructsched_classfair_sched_class={.check_preempt_curr=check_preempt_wakeup// :10513
}// kernel/sched/fair.c:6814
staticvoidcheck_preempt_wakeup(rq*rq,task_struct*p...){structtask_struct*curr=rq->curr;structsched_entity*se=&curr->se,*pse=&p->se;//  如果 pse 的虚拟时间小于当前进程的虚拟时间,就抢占
if(wakeup_preempt_entity(se,pse)==1){// :6867
gotopreempt;}preempt:// :6879
//  没有在这里直接调度,而是设置了一个标志,在异常处理返回的时候统一调度
resched_curr(rq);}

check_preempt_wakeup 方法中一处关键的地方,se 表示当前进程的调度实体,pse 表示 fork 出来的进程的调度实体。

调度实体这个对象也定义在进程的数据结构中。

// include/linux/sched.h:592
structtask_struct{...structsched_entityse;// :644
...}

调度实体是为了防止一个进程不断地 fork 多个子进程,从而无限霸占 cpu,内核可以将一组线程绑定到一起进行统一调度,这里我们不用关心太多细节,仍然聚焦核心。

下面我们来看下 check_preempt_wakeup 方法中 6867 行的 wakeup_preempt_entity 代码做了什么事情。

// kernel/sched/fair.c:6767
staticintwakeup_preempt_entity(structsched_entity*curr,structsched_entity*se){s64gran,vdiff=curr->vruntime-se->vruntime;if(vdiff<=0)return-1;//  gran 可以理解为进程运行的最小时间片
gran=wakeup_gran(se);if(vdiff>gran)return1;return0;}

公平调度类默认会通过进程的优先级和历史运行情况来计算出一个进程运行的虚拟时间,虚拟时间小的进程可以抢占虚拟时间大的进程。

当然,为了防止频繁抢占调度,要保证进程在 cpu 上的一个最小的运行时间,这个时间默认在 v5.0 内核中是 100 毫秒。

上面这段代码的逻辑,总结来说就是,如果当前进程的时间片已到,并且当前进程的虚拟时间小于 fork 出来的进程的虚拟时间片(显然是 0),则返回 1,然后进入到标记为 preempt 的代码,即 resched_curr。

// kernel/sched/core.c:465
voidresched_curr(structrq*rq){...set_tsk_need_resched(curr);// :483
...}// include/linux/sched.h:1676
staticinlinevoidset_tsk_need_resched(structtask_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,...){...returndo_futex(...op,...);// :3665
}

这种情况下,用户传递的 op 参数是 FUTEX_WAKE_OP,即用户需要进行唤醒操作,我们通过调用链往下追:

// kernel/futex.c:3573
longdo_futex(...intop,...){intcmd=op&FUTEX_CMD_MASK;switch(cmd){caseFUTEX_WAKE_OP:returnfutex_wake_op(...);// :3615
...}...}// kernel/futex.c:1683
staticintfutex_wake_op(...){...wake_up_q(...);// :1766
...}// kernel/sched/core.c:436
voidwake_up_q(...){wake_up_process(task);// :453
}// 后续调用链路有些长,我们中间的代码描述简化处理,最终会落到下面的代码
// kernel/sched/core.c:1667
staticvoidttwu_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.
 */voidscheduler_tick(void){...//  当前是哪个 cpu?
intcpu=smp_processor_id();//  拿到 cpu 上的进程队列
structrq*rq=cpu_rq(cpu);//  拿到 cpu 上当前运行的进程
structtask_struct*curr=rq->curr;...curr->sched_class->task_tick(rq,curr,0);// :3061
...}

scheduler_tick 调用当前进程的调度类的 task_tick 方法,我们还是分析常见的公平调度类的 task_tick 方法。

// kernel/sched/fair.c:10506 
conststructsched_classfair_sched_class={....task_tick=task_tick_fair,// :10530
...}// kernel/sched/fair.c:10030
staticvoidtask_tick_fair(structrq*rq,structtask_struct*curr,intqueued){structcfs_rq*cfs_rq;structsched_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
staticvoidentity_tick(structcfs_rq*cfs_rq,structsched_entity*curr,intqueued){//  更新当前进程的运行时间
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
staticvoidcheck_preempt_tick(structcfs_rq*cfs_rq,structsched_entity*curr){unsignedlongideal_runtime,delta_exec;structsched_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
voidarch_call_rest_init(void){rest_init();// :534
}// init/main.c:397
voidrest_init(void){intpid;...//  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
voidcpu_startup_entry(...){while(1)do_idle();}// kernel/sched/idle.c:224
staticvoiddo_idle(void){...schedule_idle();// :286
...}// kernel/sched/core.c:3545
voidschedule_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
staticintkernel_init(void*unused){...kernel_init_freeable();// :1054
...}// init/main.c:1103
staticvoidkernel_init_freeable(void){...smp_init();// :1129
...}// kernel/smp.c:563
voidsmp_init(void){...//  创建出其他的 IDLE 进程 
idle_threads_init();pr_info("Bringing up secondary CPUs ...\n");...//  启动其他 CPU
for_each_present_cpu(cpu){...cpu_up(cpu);}}

在 smp_init 方法中,先通过 idle_threads_init 方法复制出一堆 IDLE 进程,假设有 4 颗 CPU,除去当前进程,就复制出 3 个 IDLE 进程。

// kernel/smpboot.c:66
voididle_threads_init(void){unsignedintcpu,boot_cpu;boot_cpu=smp_processor_id();for_each_possible_cpu(cpu){if(cpu!=boot_cpu)idle_init(cpu);}}// kernel/smpboot.c:50
staticvoididle_init(unsignedintcpu){structtask_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 之后,进行一次进程调度。

在创建新进程、唤醒进程、周期调度过程中,内核会给当前进程设置一个需要调度的标志,然后在下一次中断返回到用户空间时,进行一次调度。

三,本文总结

我们通常意识上的进程在 Linux 内核中的实体是由 task_struct 来承载,这个数据结构有进程所有的信息。

0 号进程,即 IDLE 进程是在代码中静态定义的,是所有进程的祖先,它创造了 1 号进程,也就是 INIT 进程,这个进程是所有用户空间进程的祖先。

在一些系统调用过程中,会直接触发进程调度,在另一些系统调用中,会设置需要调度的标志,以便中断返回时进行一次进程调度。

内核也会周期性地进行调度,其中一个是周期性地给进程设置需要调度的标志,另一个就是 IDLE 进程不断尝试调度。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/146916.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Leetcode:257. 二叉树的所有路径(C++)

目录 问题描述&#xff1a; 实现代码与解析&#xff1a; 递归&#xff1a; 原理思路&#xff1a; 迭代&#xff08;前序&#xff09;&#xff1a; 思路原理&#xff1a; 问题描述&#xff1a; 给你一个二叉树的根节点 root &#xff0c;按 任意顺序 &#xff0c;返回所有…

计算机网络第一章

目录 1.概念 2.标准化工作及其相关组织 3.速率相关的性能指标 4.分层的基本原则&#xff1a; 5.参考模型 1.OSI七层参考模型 2.TCP/IP参考模型 3.五层参考模型 1.概念 计算机网络是网络中的一个分支&#xff0c;组成包括了计算机系统&#xff0c;通信设备&#xff0c;线路…

app逆向 || xx合伙人登陆参数

声明 本文仅供学习参考&#xff0c;如有侵权可私信本人删除&#xff0c;请勿用于其他途径&#xff0c;违者后果自负&#xff01; 如果觉得文章对你有所帮助&#xff0c;可以给博主点击关注和收藏哦&#xff01; 本文适用于对安卓开发和Java有了解的同学 前言 本人最近一直在…

学习笔记5:关于操作符与表达式的求值

目录​​​​​​​ 一.移位操作符 1.左移操作符 2.右移操作符 二.位操作符 1.位运算基本知识 2.位运算的巧妙运用 三.其他操作符 1.算术操作符 2.单目操作符 3.关于逻辑操作符 四.表达式求值 隐式类型转换 (1)整形提升(短整型家族数据的二进制序列补位转换) (2).算…

【最新】SpringBoot集成Dubbo3

最近在学习dubbo&#xff0c;构建一个简单的springboot集成dubbo&#xff0c;中间也是出了好多问题&#xff0c;在这记录下整体的过程。 1. 构建SpringBoot环境 一个简单的聚合工程 dubbo-consumer&#xff1a;是服务消费方dubbo-provider&#xff1a;是服务提供方dubbo-inte…

机器学习笔记之前馈神经网络(二)非线性问题

机器学习笔记之前馈神经网络——非线性问题引言回顾&#xff1a;关于非线性问题解决非线性问题的三种方式引言 上一节介绍了从机器学习到深度学习的过渡&#xff0c;并介绍了深度学习的发展过程。本节将主要介绍如何使用神经网络处理非线性问题 回顾&#xff1a;关于非线性问…

决策树生成、决策树可视化、决策树算法api、泰坦尼克号乘客生存预测案例代码

一、决策树算法api class sklearn.tree.DecisionTreeClassifier(criterion’gini’,max_depthNone,random_stateNone) criterion&#xff1a;特征选择标准&#xff0c;"gini"或者"entropy"&#xff0c;前者代表基尼系数&#xff0c;后者代表信息增益&…

来自 GitHub 2022 的趋势和见解

《Github 2022 发展趋势和见解》发布了这件事小伙伴们知道了吧&#xff1f;这是每个程序员不能错过的年度报告&#xff0c;因为里面详细介绍了语言的发展趋势和热门领域的介绍。那就让我们来看看吧 目录 编程语言 地理分布 贡献时间分配 技术发展趋势 最受欢迎的存储库 …

GoogLeNet详解

入门小菜鸟&#xff0c;希望像做笔记记录自己学的东西&#xff0c;也希望能帮助到同样入门的人&#xff0c;更希望大佬们帮忙纠错啦~侵权立删。 ✨完整代码在我的github上&#xff0c;有需要的朋友可以康康✨ https://github.com/tt-s-t/Deep-Learning.git 目录 一、GoogLeNet…

C++入门——auto、范围for、nullptr

下一篇就要类和对象了&#xff0c;剩了点零碎的知识点就浅浅水一篇把 一. auto关键字 在早期C/C中auto的含义是&#xff1a;使用auto修饰的变量&#xff0c;是具有自动存储器的局部变量&#xff0c;但遗憾的 是一直没有人去使用它&#xff0c;这是由于变量本身就具备生命周期…

算法及时间、空间复杂度

算法 算法是对问题求解过程的一种描述&#xff0c;是为解决一个或一类问题给出的一个确定的、有限长的操作序列。严格说来&#xff0c;一个算法必须满足以下5个重要特性&#xff1a; &#xff08;1&#xff09;有穷性&#xff1a;对于任意一组合法的输入值&#xff0c;在执行有…

【数据结构与算法——C语言版】5. 排序算法(2)——冒泡排序

前言 上篇文章【数据结构与算法——C语言版】4. 排序算法&#xff08;1&#xff09;——选择排序我们介绍了排序算法中的选择排序&#xff0c;其时间复杂度是O(n2)&#xff0c;本篇文章我们将介绍另一种同样时间复杂度是O(n2)的排序算法——冒牌排序&#xff0c;这两种算法思路…

ChatGPT背后的开源AI框架Ray,现在值10亿美元

Ray 被 OpenAI、亚马逊等科技公司用来开发大模型&#xff0c;是最近异军突起的框架。 最近一段时间&#xff0c;文本生成的人工智能在互联网上掀起了一阵风暴&#xff1a;ChatGPT 因为可以对人们能想到的几乎任何问题提供非常详细、近乎逼真的回答而受到追捧。大模型应用的出现…

Mapper代理开发案例及MyBatis核心

本片文章需要参考我的前一篇文章&#xff1a;MyBatis入门案例引入总结&#xff0c;使用mapper代理开发的好处就是可以解决开发中硬编码的问题和简化后期的SQL执行。使用这种方式可以不用写接口的实现类&#xff0c;免除了复杂的方法&#xff0c;使得代码更加清晰易懂按照以前的…

vue的过渡动画(有vue的动画库和ui库的介绍)

一、概念 Vue 在插入、更新或者移除 DOM 时&#xff0c;提供多种不同方式的应用过渡效果。 二、默认过渡 <template><div><button click"isShow!isShow">显示/隐藏</button><transition appear><h1 v-show"isShow" cl…

过滤器和拦截器的使用及管理

参考&#xff1a;(70条消息) Spring过滤器和拦截器的区别_yjc0403的博客-CSDN博客https://www.cnblogs.com/colin220/p/9606412.htm概述过滤器&#xff1a;是在javaweb中&#xff0c;你传入的request、response提前过滤掉一些信息&#xff0c;或者提前设置一些参数&#xff0c;…

Anaconda安装之后Spyder打不开解决办法--目前有用 jupyter notebook 无法正常运行2023.1.7

纯纯小白&#xff0c;探索一天&#xff0c;终于成功&#xff0c;需要我的经历没有白费&#xff0c;让大家少走弯路。 问题描述 从官网下载Anaconda之后&#xff0c;安装&#xff0c;一切正常。打开Anaconda navigator在弹出窗口选择了更新&#xff08;我怀疑这就根源&#xf…

Js逆向教程24-作用域和自执行函数

作者&#xff1a;虚坏叔叔 博客&#xff1a;https://xuhss.com 早餐店不会开到晚上&#xff0c;想吃的人早就来了&#xff01;&#x1f604; Js逆向教程24-作用域和自执行函数 一、变量作用域 1.1局部变量 function jb() {var a"我是局部变量"return a; }1.2全局变…

【Java寒假打卡】Java基础-异常

【Java寒假打卡】Java基础-异常异常概述throws声明异常throw抛出异常try-catch 抛出异常throwable的成员方法异常概述 Exception:称之为异常类&#xff0c;他表示程序本身可以处理的问题 RuntimeException及其子类&#xff1a;运行时异常。&#xff08;空指针异常&#xff0c;…

JUC总结系列篇 (二) : 对线程的理解和使用总结

文章内容&#xff1a; 一.为什么需要多线程 二.线程的创建 三.线程的方法sleep(),run(),wait(),yeid(),join(),interrupt()等方法归纳总结 四.线程的状态及其转换 五.线程的交替执行案例 六.多个线程依次执行案例 七.多线程并发带来的线程安全问题 一.为什么需要多线程&#x…