Linux内核深度解析之进程管理丨内含赠书福利(二)

网友投稿 895 2022-05-30

3.唤醒新进程

函数wake_up_new_task负责唤醒刚刚创建的新进程,其代码如下:

第7行代码,把新进程的状态从TASK_NEW切换到TASK_RUNNING。

第9行代码,在SMP系统上,创建新进程是执行负载均衡的绝佳时机,为新进程选择一个负载最轻的处理器。

第11行代码,锁住运行队列。

第12行代码,更新运行队列的时钟。

第13行代码,根据公平运行队列的平均负载统计值,推算新进程的平均负载统计值。

第15行代码,把新进程插入运行队列。

第18行代码,检查新进程是否可以抢占当前进程。

第22行代码,在SMP系统上,调用调度类的task_woken方法。

第26行代码,释放运行队列的锁。

4.新进程第一次运行

新进程第一次运行,是从函数ret_from_fork开始执行。函数ret_from_fork是由各种处理器架构自定义的函数,ARM64架构定义的ret_from_fork函数如下:

在介绍函数copy_thread时,我们已经说过:如果新进程是内核线程,寄存器x19存放线程函数的地址,寄存器x20存放线程函数的参数;如果新进程是用户进程,寄存器x19的值是0。

函数ret_from_fork的执行过程如下。

第4行代码,调用函数schedule_tail,为上一个进程执行清理操作。

第8行和第9行代码,如果寄存器x19的值是0,说明当前进程是用户进程,那么使用寄存器x28存放当前进程的thread_info结构体的地址,然后跳转到标号ret_to_user返回用户模式。

第6行和第7行代码,如果寄存器x19的值不是0,说明当前进程是内核线程,那么调用线程函数。

函数schedule_tail负责为上一个进程执行清理操作,是新进程第一次运行时必须最先做的事情,其代码如下:

函数schedule_tail的执行过程如下。

第6行代码,调用函数finish_task_switch(),为上一个进程执行清理操作,参考2.8.6节。

第7行代码,执行运行队列的所有负载均衡回调函数。

第8行代码,开启内核抢占。

第10行和第11行代码,如果pthread库在调用clone()创建线程时设置了标志位CLONE_CHILD_SETTID,那么新进程把自己的进程标识符写到指定位置。

2.5.2 装载程序

当调度器调度新进程时,新进程从函数ret_from_fork开始执行,然后从系统调用fork返回用户空间,返回值是0。接着新进程使用系统调用execve装载程序。

linux内核提供了两个装载程序的系统调用:

两个系统调用的主要区别是:如果路径名是相对的,那么execve解释为相对调用进程的当前工作目录,而execveat解释为相对文件描述符dirfd指向的目录。如果路径名是绝对的,那么execveat忽略参数dirfd。

参数argv是传给新程序的参数指针数组,数组的每个元素存放一个参数字符串的地址,argv[0]应该指向要装载的程序的名称。

参数envp是传给新程序的环境指针数组,数组的每个元素存放一个环境字符串的地址,环境字符串的形式是“键=值”。

argv和envp都必须在数组的末尾包含一个空指针。

如果程序的main函数被定义为下面的形式,参数指针数组和环境指针数组可以被程序的main函数访问:

可是,POSIX.1标准没有规定main函数的第3个参数。根据POSIX.1标准,应该借助外部变量environ访问环境指针数组。

两个系统调用最终都调用函数do_execveat_common,其执行流程如图2.11所示。

图2.11 装载程序的执行流程

(1)调用函数do_open_execat打开可执行文件。

(2)调用函数sched_exec。装载程序是一次很好的实现处理器负载均衡的机会,因为此时进程在内存和缓存中的数据是最少的。选择负载最轻的处理器,然后唤醒当前处理器上的迁移线程,当前进程睡眠等待迁移线程把自己迁移到目标处理器。

(3)调用函数bprm_mm_init创建新的内存描述符,分配临时的用户栈。

如图2.12所示,临时用户栈的长度是一页,虚拟地址范围是[STACK_TOP_MAX−页长度,STACK_TOP_MAX),bprm->p指向在栈底保留一个字长(指针长度)后的位置。

(4)调用函数prepare_binprm设置进程证书,然后读文件的前面128字节到缓冲区。

(5)依次把文件名称、环境字符串和参数字符串压到用户栈,如图2.13所示。

图2.12 临时用户栈

图2.13 把文件名称、环境和参数压到用户栈

(6)调用函数exec_binprm。函数exec_binprm调用函数search_binary_handler,尝试注册过的每种二进制格式的处理程序,直到某个处理程序识别正在装载的程序为止。

1.二进制格式

在Linux内核中,每种二进制格式都表示为下面的数据结构的一个实例:

每种二进制格式必须提供下面3个函数。

(1)load_binary用来加载普通程序。

(2)load_shlib用来加载共享库。

(3)core_dump用来在进程异常退出时生成核心转储文件。程序员使用调试器(例如GDB)分析核心转储文件以找出原因。min_coredump指定核心转储文件的最小长度。

每种二进制格式必须使用函数register_binfmt向内核注册。

下面介绍常用的二进制格式:ELF格式和脚本格式。

2.装载ELF程序

(1)ELF文件:ELF(Executable and Linkable Format)是可执行与可链接格式,主要有以下4种类型。

目标文件(object file),也称为可重定位文件(relocatable file),扩展名是“.o”,多个目标文件可以链接生成可执行文件或者共享库。

可执行文件(executable file)。

共享库(shared object file),扩展名是“.so”。

核心转储文件(core dump file)。

如图2.14所示,ELF文件分成4个部分:ELF首部、程序首部表(program header table)、节(section)和节首部表(section header table)。实际上,一个文件不一定包含全部内容,而且它们的位置也不一定像图2.14中这样安排,只有ELF首部的位置是固定的,其余各部分的位置和大小由ELF首部的成员决定。

图2.14 ELF文件的格式

程序首部表就是我们所说的段表(segment table),段(segment)是从运行的角度描述,节(section)是从链接的角度描述,一个段包含一个或多个节。在不会混淆的情况下,我们通常把节称为段,例如代码段(text section),不称为代码节。

32位ELF文件和64位ELF文件的差别很小,本书只介绍64位ELF文件的格式。

ELF首部的成员及说明如表2.4所示。

表2.4 ELF首部的成员及说明

程序首部表中每条表项的成员及说明如表2.5所示。

表2.5 程序首部表中每条表项的成员及说明

节首部表中每条表项的成员及说明如表2.6所示。

表2.6 节首部表中每条表项的成员及说明

重要的节及说明如表2.7所示。

表2.7 重要的节及说明

可以使用程序“readelf”查看ELF文件的信息。

1)查看ELF首部:readelf -h

2)查看程序首部表:readelf -l

3)查看节首部表:readelf -S

(2)代码实现:内核中负责解析ELF程序的源文件,如表2.8所示。

表2.8 解析ELF程序的源文件

如图2.15所示,源文件“fs/binfmt_elf.c”定义的函数load_elf_binary负责装载ELF程序,主要步骤如下。

图2.15 装载ELF程序

1)检查ELF首部。检查前4字节是不是ELF魔幻数,检查是不是可执行文件或者共享库,检查处理器架构。

2)读取程序首部表。

3)在程序首部表中查找解释器段,如果程序需要链接动态库,那么存在解释器段,从解释器段读取解释器的文件名称,打开文件,然后读取ELF首部。

4)检查解释器的ELF首部,读取解释器的程序首部表。

5)调用函数flush_old_exec终止线程组中的所有其他线程,释放旧的用户虚拟地址空间,关闭那些设置了“执行execve时关闭”标志的文件。

6)调用函数setup_new_exec。函数setup_new_exec调用函数arch_pick_mmap_layout以设置内存映射的布局,在堆和栈之间有一个内存映射区域,传统方案是内存映射区域向栈的方向扩展,另一种方案是内存映射区域向堆的方向扩展,从两种方案中选择一种。然后把进程的名称设置为目标程序的名称,设置用户虚拟地址空间的大小。

7)以前调用函数bprm_mm_init创建了临时的用户栈,现在调用函数set_arg_pages把用户栈定下来,更新用户栈的标志位和访问权限,把用户栈移动到最终的位置,并且扩大用户栈。

8)把所有可加载段映射到进程的虚拟地址空间。

9)调用函数setbrk把未初始化数据段映射到进程的用户虚拟地址空间,并且设置堆的起始虚拟地址,然后调用函数padzero用零填充未初始化数据段。

10)得到程序的入口。如果程序有解释器段,那么把解释器程序中的所有可加载段映射到进程的用户虚拟地址空间,程序入口是解释器程序的入口,否则就是目标程序自身的入口。

11)调用函数create_elf_tables依次把传递ELF解释器信息的辅助向量、环境指针数组envp、参数指针数组argv和参数个数argc压到进程的用户栈。

12)调用函数start_thread设置结构体pt_regs中的程序计数器和栈指针寄存器。当进程从用户模式切换到内核模式时,内核把用户模式的各种寄存器保存在内核栈底部的结构体pt_regs中。因为不同处理器架构的寄存器不同,所以各种处理器架构必须自定义结构体pt_regs和函数start_thread,ARM64架构定义的函数start_thread如下:

3.装载脚本程序

脚本程序的主要特征是:前两字节是“#!”,后面是解释程序的名称和参数。解释程序用来解释执行脚本程序。

如图2.16所示,源文件“fs/binfmt_script.c”定义的函数load_script负责装载脚本程序,主要步骤如下。

图2.16 装载脚本程序

(1)检查前两字节是不是脚本程序的标识符。

(2)解析出解释程序的名称和参数。

(3)从用户栈删除第一个参数,然后依次把脚本程序的文件名称、传给解释程序的参数和解释程序的名称压到用户栈。

(4)调用函数open_exec打开解释程序文件。

(5)调用函数prepare_binprm设置进程证书,然后读取解释程序文件的前128字节到缓冲区。

(6)调用函数search_binary_handler,尝试注册过的每种二进制格式的处理程序,直到某个处理程序识别解释程序为止。

2.6 进程退出

进程退出分两种情况:进程主动退出和终止进程。

Linux内核提供了以下两个使进程主动退出的系统调用。

(1)exit用来使一个线程退出。

(2)Linux私有的系统调用exit_group用来使一个线程组的所有线程退出。

glibc库封装了库函数exit、_exit和_Exit用来使一个进程退出,这些库函数调用系统调用exit_group。库函数exit和_exit的区别是exit会执行由进程使用atexit和on_exit注册的函数。

注意:我们编写用户程序时调用的函数exit,是glibc库的函数exit,不是系统调用exit。

终止进程是通过给进程发送信号实现的,Linux内核提供了发送信号的系统调用。

(1)kill用来发送信号给进程或者进程组。

(2)tkill用来发送信号给线程,参数tid是线程标识符。

(3)tgkill用来发送信号给线程,参数tgid是线程组标识符,参数tid是线程标识符。

tkill和tgkill是Linux私有的系统调用,tkill已经废弃,被tgkill取代。

当进程退出的时候,根据父进程是否关注子进程退出事件,处理存在如下差异。

(1)如果父进程关注子进程退出事件,那么进程退出时释放各种资源,只留下一个空的进程描述符,变成僵尸进程,发送信号SIGCHLD(CHLD是child的缩写)通知父进程,父进程在查询进程终止的原因以后回收子进程的进程描述符。

(2)如果父进程不关注子进程退出事件,那么进程退出时释放各种资源,释放进程描述符,自动消失。

进程默认关注子进程退出事件,如果不想关注,可以使用系统调用sigaction针对信号SIGCHLD设置标志SA_NOCLDWAIT(CLD是child的缩写),以指示子进程退出时不要变成僵尸进程,或者设置忽略信号SIGCHLD。

怎么查询子进程终止的原因?Linux内核提供了3个系统调用来等待子进程的状态改变,状态改变包括:子进程终止,信号SIGSTOP使子进程停止执行,或者信号SIGCONT使子进程继续执行。这3个系统调用如下。

注意:wait4已经废弃,新的程序应该使用waitpid和waitid。

子进程退出以后需要父进程回收进程描述符,如果父进程先退出,子进程成为“孤儿”,谁来为子进程回收进程描述符呢?父进程退出时需要给子进程寻找一个“领养者”,按照下面的顺序选择领养“孤儿”的进程。

(1)如果进程属于一个线程组,且该线程组还有其他线程,那么选择任意一个线程。

(2)选择最亲近的充当“替补领养者”的祖先进程。进程可以使用系统调用prctl(PR_SET_CHILD_SUBREAPER)把自己设置为“替补领养者”(subreaper)。

(3)选择进程所属的进程号命名空间中的1号进程。

2.6.1 线程组退出

系统调用exit_group实现线程组退出,执行流程如图2.17所示,把主要工作委托给函数do_group_exit,执行流程如下。

图2.17 线程组退出的执行流程

(1)如果线程组正在退出,那么从信号结构体的成员group_exit_code取出退出码。

(2)如果线程组未处于正在退出的状态,并且线程组至少有两个线程,那么处理如下。

1)关中断并申请锁。

2)如果线程组正在退出,那么从信号结构体的成员group_exit_code取出退出码。

3)如果线程组未处于正在退出的状态,那么处理如下。

把退出码保存在信号结构体的成员group_exit_code中,传递给其他线程。

给线程组设置正在退出的标志。

向线程组的其他线程发送杀死信号,然后唤醒线程,让线程处理杀死信号。

4)释放锁并开中断。

(3)当前线程调用函数do_exit以退出。

假设一个线程组有两个线程,称为线程1和线程2,线程1调用exit_group使线程组退出,线程1的执行过程如下。

(1)把退出码保存在信号结构体的成员group_exit_code中,传递给线程2。

(2)给线程组设置正在退出的标志。

(3)向线程2发送杀死信号,然后唤醒线程2,让线程2处理杀死信号。

Linux内核深度解析之进程管理丨内含赠书福利(二)

(4)线程1调用函数do_exit以退出。

线程2退出的执行流程如图2.18所示,线程2准备返回用户模式的时候,发现收到了杀死信号,于是处理杀死信号,调用函数do_group_exit,函数do_group_exit的执行过程如下。

图2.18 线程2退出的执行流程

(1)因为线程组处于正在退出的状态,所以线程2从信号结构体的成员group_exit_code取出退出码。

(2)线程2调用函数do_exit以退出。

线程2可能在以下3种情况下准备返回用户模式。

(1)执行完系统调用。

(2)被中断抢占,中断处理程序执行完。

(3)执行指令时生成异常,异常处理程序执行完。

函数do_exit的执行过程如下。

(1)释放各种资源,把资源对应的数据结构的引用计数减一,如果引用计数变成0,那么释放数据结构。

(2)调用函数exit_notify,先为成为“孤儿”的子进程选择“领养者”,然后把自己的死讯通知父进程。

(3)把进程状态设置为死亡(TASK_DEAD)。

(4)最后一次调用函数__schedule以调度进程。

死亡进程最后一次调用函数__schedule调度进程时,进程调度器做了如下特殊处理。

第8行和第9行代码,执行调度类的task_dead方法。

第11行代码,如果结构体thread_info放在进程描述符里面,而不是放在内核栈的顶部,那么释放进程的内核栈。

第12行代码,把进程描述符的引用计数减1,如果引用计数变为0,那么释放进程描述符。

2.6.2 终止进程

系统调用kill(源文件“kernel/signal.c”)负责向线程组或者进程组发送信号,执行流程如图2.19所示。

(1)如果参数pid大于0,那么调用函数kill_pid_info来向线程pid所属的线程组发送信号。

(2)如果参数pid等于0,那么向当前进程组发送信号。

(3)如果参数pid小于−1,那么向组长标识符为-pid的进程组发送信号。

(4)如果参数pid等于−1,那么向除了1号进程和当前线程组以外的所有线程组发送信号。

函数kill_pid_info负责向线程组发送信号,执行流程如图2.20所示,函数check_kill_permission检查当前进程是否有权限发送信号,函数__send_signal负责发送信号。

图2.19 系统调用kill的执行流程

图2.20 向线程组发送信号的执行流程

函数__send_signal的主要代码如下:

第11~13行代码,如果目标线程忽略信号,那么没必要发送信号。

第15行代码,确定把信号添加到哪个信号队列和集合。线程组有一个共享的信号队列和集合,每个线程有一个私有的信号队列和集合。如果向线程组发送信号,那么应该把信号添加到线程组共享的信号队列和集合中;如果向线程发送信号,那么应该把信号添加到线程私有的信号队列和集合中。

第18行代码,如果是传统信号,并且信号集合已经包含同一个信号,那么没必要重复发送信号。

第22~25行代码,判断分配信号队列节点时是否可以忽略信号队列长度的限制:对于传统信号,如果是特殊的信号信息,或者信号的编码大于0,那么允许忽略;如果是实时信号,那么不允许忽略。

第27行和第28行代码,分配一个信号队列节点。

第29行和第30行代码,如果分配信号队列节点成功,那么把它添加到信号队列中。

第37行代码,如果某个进程正在通过信号文件描述符(signalfd)监听信号,那么通知进程。signalfd是进程创建用来接收信号的文件描述符,进程可以使用select或poll监听信号文件描述符。

第38行代码,把信号添加到信号集合中。

第39行代码,调用函数complete_signal:如果向线程组发送信号,那么需要在线程组中查找一个没有屏蔽信号的线程,唤醒它,让它处理信号。

上一节已经介绍过,当线程准备从内核模式返回用户模式时,检查是否收到信号,如果收到信号,那么处理信号。

2.6.3 查询子进程终止原因

系统调用waitid的原型如下:

参数idtype指定标识符类型,支持以下取值。

(1)P_ALL:表示等待任意子进程,忽略参数id。

(2)P_PID:表示等待进程号为id的子进程。

(3)P_PGID:表示等待进程组标识符是id的任意子进程。

参数options是选项,取值是0或者以下标志的组合。

(1)WEXITED:等待退出的子进程。

(2)WSTOPPED:等待收到信号SIGSTOP并停止执行的子进程。

(3)WCONTINUED:等待收到信号SIGCONT并继续执行的子进程。

(4)WNOHANG:如果没有子进程退出,立即返回。

(5)WNOWAIT:让子进程处于僵尸状态,以后可以再次查询状态信息。

系统调用waitpid的原型是:

系统调用wait4的原型是:

参数pid的取值如下。

(1)大于0,表示等待进程号为pid的子进程。

(2)等于0,表示等待和调用进程属于同一个进程组的任意子进程。

(3)等于-1,表示等待任意子进程。

(4)小于-1,表示等待进程组标识符是pid的绝对值的任意子进程。

参数options是选项,取值是0或者以下标志的组合。

(1)WNOHANG:如果没有子进程退出,立即返回。

(2)WUNTRACED:如果子进程停止执行,但是不被ptrace跟踪,那么立即返回。

(3)WCONTINUED:等待收到信号SIGCONT并继续执行的子进程。

以下选项是Linux私有的,和使用clone创建子进程一起使用。

(1)__WCLONE:只等待克隆的子进程。

(2)__WALL:等待所有子进程。

(3)__WNOTHREAD:不等待相同线程组中其他线程的子进程。

系统调用waitpid、waitid和wait4把主要工作委托给函数do_wait,函数do_wait的执行流程如图2.21所示,遍历当前线程组的每个线程,针对每个线程遍历它的每个子进程,如果是僵尸进程,调用函数eligible_child来判断是不是符合等待条件的子进程,如果符合等待条件,调用函数wait_task_zombie进行处理。

图2.21 函数do_wait的执行流程

函数wait_task_zombie的执行流程如下。

(1)如果调用者没有传入标志WEXITED,说明调用者不想等待退出的子进程,那么直接返回。

(2)如果调用者传入标志WNOWAIT,表示调用者想让子进程处于僵尸状态,以后可以再次查询子进程的状态信息,那么只读取进程的状态信息,从线程的成员exit_code读取退出码。

(3)如果调用者没有传入标志WNOWAIT,处理如下。

1)读取进程的状态信息。如果线程组处于正在退出的状态,从线程组的信号结构体的成员group_exit_code读取退出码;如果只是一个线程退出,那么从线程的成员exit_code读取退出码。

2)把状态切换到死亡,释放进程描述符。

{-:-} 福利

本文转载自异步社区

Linux 任务调度

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:详细教程:应用侧开发Java Demo
下一篇:尚远科技CTO周云峰先生应邀在“华为HC大会”进行物联网天线主旨演讲
相关文章