【读书笔记】Linux内核设计与实现-进程管理

1.进程

进程就是处于执行期的程序(目标码存放在某种存储介质上)。

ps:进程并不仅仅局限于一段可执行代码(也叫代码段,text section)。通常进程还包含其他资源,如:打开的文件,挂起的信号灯。

执行线程,简称线程(thread),是在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。

内核调度的对象是线程,而不是进程。

ps:Linux系统的线程实现非常特别:它对线程和进程并不特别区分。即线程只不过是一种特殊的进程。

2.进程描述符以及任务结构

内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中。
链表中的每一项都是类型为task_struct ,称为进程描述符(process descriptor)的结构,定义在<linux/sched.h>中。
进程描述符中包含一个具体进程的所有信息。

进程描述符中包含的数据能完整的描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态等。
在这里插入图片描述

2.1 分配进程描述符

在这里插入图片描述

2.2 进程描述符的存放–PID

内核通过一个唯一的进程标识值(process identification value)或PID来标识每个进程。
PID是一个数,表示为pid_t隐含类型,实际上就是一个int类型。
PID实际上就是系统中允许同时存在的进程的最大数目。
PID的最大默认值为32768(受<linux/thread.h>中所定义PID最大值的限制),可通过/proc/sys/kernel/pid_max来查看。

2.3 进程状态

进程描述符中的state域描述了进程的当前状态。如下五种

  1. TASK_RUNNING(运行 R )-- 进程是可执行的:它或者正在执行,或者在运行队列中等待执行。
  2. TASK_INTERRUPTIBLE (可中断 S ) – 进程正在睡眠(被阻塞),等待某些条件的达成。
  3. TASK_UNINTERRUPTIBLE(不可中断 D ) – 除了就算是接收到信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。
  4. __TASK_TRACED ( Z ) – 被其他进程跟踪的进程,如通过ptrace对调式程序进程跟踪。
  5. __TASK_STOPPED(停止 T ) – 进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候,此外,在调试期间接收到任何信号,都会使进程进入这种状态。

在这里插入图片描述

2.4 设置当前进程状态–set_task_state

set_task_state(task,state);  /*等价于*/
task->state = state;

ps:

set_current_state(state); /* 等价于,参考<linux/sched.h> */
set_task_state(current,state);

2.5 进程上下文

可执行程序代码是进程的重要组成部分。
这些代码从一个可执行文件载入到进程的地址空间执行。
一般程序在用户空间执行。当一个程序调用执行了系统调用或者触发了某个异常,它就陷入了内核空间
此时,我们称内核“代表进程执行”并处于进程上下文中。

2.6 进程家族树–所有的进程都是PID为1的init进程的后代

进程间的关系存放在进程描述符中。
每个task_struct都包含一个指向其父进程tast_struct、j叫做parent的指针,还包含一个称为children的子进程链表。

Q:如何获取父进程的进程描述符?
A:struct task_struct *my_parent = current->parent;

Q:如何访问子进程?
A:

struct task_struct *task;
struct list_head *list;

list_for_each(list,&current->children)
{
	task = list_entry(list,struct task_struct,sibling);
}

init进程的进程描述符是作为init_task静态分配的。
如:

struct task_struct *task;
for(task = current;task != &init_task; task = task->parent);
/*task 现在指向init*/

ps:
for_each_process(task)宏提供了依次访问整个任务队列(双向循环链表)的能力,但在一个拥有大量进程的系统中通过重复来遍历所有进程的代价是很大的。
因此,如果没有充足的理由(或者别无他法),别这样做。
eg:

struct task_struct *task;
for_each_process(task){
	/*打印出每一个任务(进程)的名称和PID*/
	printk("%s[%d]\n",task->comm,task->pid);
}

3.进程创建–fork和exec族函数

fork()通过拷贝当前进程创建一个子进程。
exec族函数复制读取可执行文件并将其载入地址空间开始运行。

3.1 写时拷贝–Linux拥有进程快速执行能力原因之一

COW技术指的是资源的复制只有在需要写入的时候才进行,在此之前,只是以只读的方式共享(即父子进程共享同一个拷贝)。

3.2 fork()

Linux通过clone()系统调用实现fork()。
如下是大致调用流程:

fork()->clone()->do_fork()->copy_process()

ps:do_fork()定义在kernel/fork.c文件中。(可能随着kernel版本不同存在的路径不同)
do_fork函数调用copy_process函数,然后让进城开始运行。
copy_process函数工作流程大致如下:

  1. 调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct,这些值与当前进城的值相同。此时,子进程和父进程的描述符是完全相同的。
  2. 检查并确保新创建这个子进程后,当前用户所拥有的进程数目没有超出给它分配的资源的限制。
  3. 子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清0或设为初始值。那些不是继承而来的进城描述符成员,主要是统计信息。task_struct中的大多数数据都依然未被修改。
  4. 子进程的状态被设置为TASK_UNINTERRUPTIBLE,以保证它不会投入运行。
  5. copy_process()调用copy_flags()以更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0.表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置。
  6. 调用alloc_pid()为新进程分配一个有效的PID。
  7. 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享,否则,这些资源对每个进程是不同的,因此被拷贝到这里(COW)。
  8. 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。

再回到do_fork()函数,如果是copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。
内核有意选择子进程首先执行(并非总能如此)。因为一般子进程都会马上调用exec族函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。

3.3 vfork()

除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同。
子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。
子进程不能向地址空间写入。

ps:理想情况下,系统最好不要调用vfork(),内核也不用实现它。

4.线程在Linux中的实现

线程机制提供了在同一程序内共享内存地址空间运行的一组线程。
Linux把所有的线程都当做进程来实现。
线程仅仅被视为一个与其他进程共享某些资源的进程。(每个线程都拥有唯一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程,只是线程和其他一些进程共享某些资源,如地址空间)

4.1 创建线程

线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源:

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND,0);  /*结果和调用fork()差不多,只是父子俩共享地址空间,文件系统资源,文件描述符和信号处理程序*/

传递给clone函数的参数标志决定了新创建进程的行为方式和父子进程之间共享资源种类。
如下表所示:(<linux/sched.h>)

参数标志 含义
CLONE_FILES 父子进程共享打开的文件
CLONE_FS 父子进程共享文件系统信息
CLONE_IDLETASK 将PID设置为0(只供idle进程使用)
CLONE_NEWNS 为子进程创建新的命名空间
CLONE_PARENT 指定子进程与父进程拥有同一个父进程
CLONE_PTRACE 继续调试子进程
CLONE_SETTID 将TID回写至用户空间
CLONE_SETTLS 为子进程创建新的TLS
CLONE_SIGHAND 父子进程共享信号处理函数及被阻断的信号
CLONE_SYSVSEM 父子进程共享System V SEM_UNDO语义
CLONE_THREAD 父子进程放入相同的线程组
CLONE_VFORK 调用vfork(),所以父进程准备睡眠等待子进程将其唤醒
CLONE_UNTRACED 防止跟踪进程在子进程强制执行CLONE_PTRACE
CLONE_STOP 以TASK_STOPPED状态开始进程
CLONE_SETTLS 为子进程创建新的TLS(thread-local storage)
CLONE_CHILD_CLEARTID 清除子进程的TID
CLONE_CHILD_SETTID 设置子进程的TID
CLONE_PARENT_SETTID 设置父进程的TID
CLONE_VM 父子进程共享地址空间

ps:
idle进程的概念:
简单的说idle是一个进程,其pid号为 0。其前身是系统创建的第一个进程,也是唯一一个没有通过fork()产生的进程。在smp系统中,每个处理器单元有独立的一个运行队列,而每个运行队列上又有一个idle进程,即有多少处理器单元,就有多少idle进程。系统的空闲时间,其实就是指idle进程的"运行时间"。idle进程pid==o,也就是init_task.

4.2 内核线程–独立运行在内核空间的标准进程

内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为NULL)。
它们只在内核空间运行,从来不切换到用户空间取。
内核进程和普通进程移远,可以被调用和被抢占。

5.进程终结–do_exit()

do_exit()被系统调用exit()调用,定义于kernel/exit.c中,做了如下工作:

  1. 将task_struct(书中写成了tast_struct)中的标志成员设置为PF_EXITING。
  2. 调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。
  3. 如果BSD的进程记账功能是开启的,do_exit()调用acct_update_integrals()来输出记账信息。
  4. 然后调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们(也就是说,这个地址空间没有被共享),就彻底释放它们。
  5. 接下来调用sem__exit()函数。如果进程排队等候IPC信号,它则离开队列。
  6. 调用exit_files()和exit_fs(),以分别递减文件描述符、文件系统数据的引用计数。如果其中某个引用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放。
  7. 接着把存放在task_struct的exit_code成员中的任务退出代码置为由exit()提供的退出代码,或者去完成任何其他由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。
  8. 调用exit_notify()向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者为init进程,并把进程状态(存放在task_struct结构的exit_state中)设成EXIT_ZOMBIE
  9. do_exit()调用schedule()切换到新的进程。因为处于EXIT_ZOMBIE状态的进程不会再被调度,所以这是进程所执行的最后一段代码,do_exit()永不返回。

ps:若该进程是这些资源的唯一使用者,当进程不可运行(实际上也没有地址空间让它运行) 并处于EXIT_ZOMBIE退出状态。它占用的所有内存就是内核栈、thread_info结构和task_struct结构。
此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到信息后,或者通知内核那是无关的信息后,由进程所持有的剩余内存被释放,归还给系统使用。

5.1 删除进程描述符–wait族函数

进程终结时所需的清理工作和进程描述符的删除被分开执行
wait()这一族的函数都是通过唯一的一个系统调用wait4()来实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。

当最终需要释放进程描述符时,release_task()会被调用,其流程如下:

  1. 它调用__exit_signal(),该函数调用__unhash_process(),后者又调用detach_pid()从pidhash上删除该进程,同时也要从任务列表中删除该进程。
  2. __exit_signal()释放目前僵死进程中所使用的的所有剩余资源,并进程最终统计和记录。
  3. 如果这个进程是线程组中的最后一个进程,并且领头进程已经死掉,那么release_task()就要通知僵死的领头进程的父进程。
  4. release_task()调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放task_struct所占的slab高速缓存。

5.2 孤儿进程造成的进退维谷

如果父进程在子进程之前退出,必须有机制来保证子进程能够找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白的浪费资源。
这一机制就是给子进程在当前线程组内找一个线程作为父亲,如果行不通,则让init进程做它们的父进程
ps:init进程会例行调用wait()来检查其子进程,清除所有与其相关的僵死进程。

发布了91 篇原创文章 · 获赞 17 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/qq_23327993/article/details/105065705