Linux内核设计与实现(3)第三章:进程管理

本文深入探讨了Linux系统中的进程管理机制,包括进程定义、进程状态、进程创建与终结、虚拟处理器与虚拟内存等核心概念。同时介绍了僵尸进程与孤儿进程的处理方式,以及Linux系统如何实现线程。

1 进程定义:

1.1 unix系统的两大抽象对象:进程,文件

进程是处于执行期的程序以及相关的资源的总称,linux通常也把进程叫做任务

1.2 进程是资源的封装单位

进程封装的资源包括:代码数据段,内存、文件、文件系统、信号、控制台,寄存器,堆栈等。
一个进程区别于另外一个进程的标记就是占有的资源完全不一样。

2. 进程提供两种虚拟机制:虚拟处理器和虚拟内存

这两种虚拟给进程造成一个假象——只有自己独享处理器以及独享整个内存资源。
有趣的是,同一进程中的线程可以共享虚拟内存,但每个都拥有各自的虚拟处理器。

2.1 虚拟处理器

一个物理CPU划分成多个虚拟CPU
虚拟处理器也被称为虚拟处理机,是指分配给虚拟机(VM)的物理的中央处理单元(CPU)。

CPU 虚拟化的目的就是能够同时运行多个进程(这不是唯一目的),而实质就是对进程的切换,也就是快速的切换执行多个进程。

2.2 虚拟内存

参考:关于虚拟地址空间的详细文章:虚拟地址空间
https://blog.csdn.net/lqy971966/article/details/119378416

3. 进程描述符 task_struct

内核把进程放在叫做任务队列的双向循环列表中,列表中每一项都是类型为 task_struct 的结构,称为进程描述符
它包含一个具体进程的所有信息(地址空间、内存、文件、文件系统、信号、控制台等)

3.1 分配进程描述符

Linux通过slab(一种内存分配机制)分配task_struct结构(task_struct分配在slab管理的缓存上),以此达到对象复用和缓存着色的目的。
具体而言就是在进程的内核栈的栈顶(对于向上增长的栈来说)创建一个新的结构struct thread_info,而该结构中有一个指向task_struct的指针,x86系统中通过current宏(当前正在运行进程的进程描述符)从thread_info的task域中提取并返回task_struct的地址。

参考:关于slab的详细文章:Linux内存管理之slab 1:slab原理
https://blog.csdn.net/lqy971966/article/details/112980005

3.1.1 创建进程快的原因

通过预先分配和重复使用task_struct,避免动态分配和释放带来的性能损耗,这也是为什么创建进程快的原因

3.2 缓存着色:

又称page coloring、cache coloring
缓存着色的目的:为了充分利用大cache(例如6M二级cache)而产生的纯软件技术,不需要任何硬件改动。
实现方法:在linux内核的VM管理部分,在为进程分配内存时调用

3.3. thread_info

现在由于 task_struct 由slab分配器动态生成,所以只需要在内核栈尾端分配一个 thread_info 结构,
结构中task域中存放的是指向该任务实际task_struct(由slab分配器动态生成)的指针

struct thread_info {
	struct task_struct  *task;      /* main task structure */
	__u32           flags;      /* low level flags */
	__u32           status;     /* thread synchronous flags */
	__u32           cpu;        /* current CPU */
};

为什么需要 thread_info ?
x86由于寄存器不富余,因此只能在内核栈的尾端创建 thread_info 结构,通过计算偏移间接地查找task_struct结构。
为了让寄存器少的硬件体系只通过栈指针就能算出位置,避免使用额外寄存器存储

暂时还未整明白,后续补充。

3.4. current 宏

在内核中,要访问任务(进程),首先要获得指向其 task_struct 的指针。
该指针通过 current 宏来访问,不同体系结构的宏实现不同。
x86由于寄存器不富余,因此只能在内核栈的尾端创建 thread_info 结构,通过计算偏移间接地查找task_struct结构。

current_thread_info()->task;

4. PID

内核通过一个唯一的进程标识值或PID来标识每个进程,PID是一个数,表示为pid_t隐含类型(就是int类型)
内核把每个进程的PID存放在它们各自的进程描述符中

PID 的上限可以通过修改/proc/sys/kernel/pid_max

[root@localhost kernel]# cat /proc/sys/kernel/pid_max
131072
[root@localhost kernel]#

5. 进程状态

进程描述符中的 state 域描述了进程的当前状态。一共有5中状态标志:

运行状态:
1.TASK_RUNNING(运行)
	进程是可执行的,它或者正在执行,或者在运行队列中等待执行。

等待状态:
2.TASK_INTERRUPTIBLE (可中断。进程被阻塞,等待被唤醒)

3.TASK_UNINTERRUPTIBLE (不可中断。收到信号不做任何响应)
	如:一个新进程创建过程中就是这个状态,防止进程还未准备好就执行,
		就受到中断信号的打扰出现混乱。
	
4.TASK_ZOMBIE	
	(僵死。进程已经结束了,但是父进程还没有调用wait4系统调用)
	
	
停止状态:
5.TASK_STOPPED	进程停止执行。
	通常发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候,

struct task_struct {
	……
	/* -1 unrunnable, 0 runnable, >0 stopped: */
	volatile long			state; //进程状态
	……
}

6. 进程上下文

当一个进程在执行时,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。
上下文简单说来就是一个环境,相对于进程而言,就是进程执行时的环境。

参考:Linux:上下文,进程上下文和中断上下文概念
https://blog.csdn.net/lqy971966/article/details/119103989

7. 进程家族树

Linux系统中进程之间存在明显的继承关系。所有的进程都是PID为1的init进程的后代。
内核在系统启动的最后阶段启动该进程,该进程读取系统的初始化脚本并执行其他的相关程序,最终完成系统启动的整个过程。

进程间的关系存放在进程描述符中,每个task_struct都包含一个指向其父进程task_struct的parent指针,以及一个名为children的子进程链表。

/* Real parent process: */
struct task_struct __rcu	*real_parent;

8. 进程创建:fork exec

Linux使用fork()和exec()两个函数去执行进程创建。

fork()通过拷贝当前进程创建一个子进程。
	子进程与父进程的区别仅仅在于PID,PPID和某些资源和统计量(例如,挂起的信号)。

exec()函数负责读取可执行文件并将其载入地址空间开始运行。

8.1 fork() 详解

Linux通过 clone() 系统调用实现 fork()
fork()、vfork()、__clone()都根据各自需要的参数标志去调用 clone(),它转而去调用 do_fork()。
clone()通过一系列的参数标志来指明父、子进程需要共享的资源

do_fork 完成了创建中的大部分工作,它的定义在 kernel/fork.c文件中。

8.1.1 copy_process() 的工作

该函数调用 copy_process() 函数,然后让进程开始运行。
copy_process() 函数完成的工作:

1.调用 dup_task_struct() 为新进程创建一个内核栈、thread_info结构和
	task_struct,这些值与当前进程的值相同。
	
2.检查并确保当前用户的进程数目没有超出资源限制。

3.进程描述符内的不继承父进程的成员被清0或者设为初始值,使自己和父进程区分开来

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()拷贝或共享打开的文件、文件系统信息、
	信号处理函数、进程地址空间和命名空间等。这是创建线程使用共享数据
	
8.copy_process()做扫尾工作并返回一个指向子进程的指针。

9. 再回到 do_fork()函数,
	如果 copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。

内核有意选择让子进程先运行,因为一般子进程会马上调用exec(),这样可以避免写时拷贝的额外开销。

8.1.2 vfork() - 基本弃用了

vfork() 除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同
如果Linux将来fork()有了写时拷贝页表项,那么vfork()就彻底没用了。
理想情况下最好不要调用vfork(),内核也不用实现它。 fork()和vfork()最终实际都是通过调用clone()来创建新进程。

8.1.3 fork调用过程:

fork->clone->do_fork->copy_process->dup_task_struct->回到 do_fork 起子进程
->copy_flags->
->alloc_pid

8.1.4 进程和线程调用 clone 区别

一个普通的fork()的实现是:
clone(SIGCHLD, 0);
线程创建:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

8.1.5 写时拷贝 COW

定义:
copy on write
虚拟地址空间独立(复制),物理地址空间共享,只有写入时才拷贝(推迟或者避免拷贝)

背景:
传统的fork()系统调用直接把所有的资源复制给新创建的进程。包括:虚拟地址空间和物理地址空间
缺点:
过于简单并且效率低下,因为它拷贝的数据也许并不共享
如果新进程打算立即执行一个新的映像(如调用exec函数),那么所有的拷贝都将浪费,未使用到。
解决:
cow 写时拷贝

参考:Linux:COW 写时拷贝技术
https://blog.csdn.net/lqy971966/article/details/118784913

9 进程终结 do_exit

当一个进程终结时,内核释放其资源并知其父进程。

9.1 do_exit 调用流程

不管进程怎么终结,该任务大部分要靠 do_exit(),其工作如下:

1.将 task_struct 中的标识成员设置为 PF_EXITING
2.调用 del_timer_sync()删除内核定时器, 确保没有定时器在排队和运行;
3.调用 exit_mm()释放进程占用的mm_struct;
4.调用 sem__exit(),使进程离开等待IPC信号的队列;
5.调用 exit_files() 和 exit_fs(),递减文件描述符、文件系统数据的引用计数。
6.把 task_struct 的 exit_code()设置为进程的返回值,供父进程随时检索;
7.调用 exit_notify()向父进程发送信号,并把自己的状态设为EXIT_ZOMBIE;
8.调用 schedule()切换到新进程继续执行。

因为进程的状态为 EXIT_ZOMBIE,所以不再被调度,do_exit()永不返回。
至此,该进程关联的资源被释放掉了,但是它本身占用的内存还没有释放,比如创建时分配的内核栈,thread_info结构和task_struct结构等。

此时进程存在的唯一目的是向它的父进程提供信息。
父进程检索到信息后,或者通知内核那是无关的信息后,该进程所持有的剩余内存被释放,归还给系统使用。

9.2 删除进程描述符

在调用了do_exit()后,尽管线程已经僵死不能再运行,但是系统还保留了它的进程描述符,这样使父进程有办法在子进程终结后仍然能够获得它的信息
在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程的task_struct才可被释放,即调用release_task()函数

10 僵尸进程 & 孤儿进程

一句话解释;
僵尸进程就是死了等父进程收尸;
孤儿进程就是父进程挂了,再找个爸爸

10.1 僵尸进程

任何一个子进程(init 除外)在 exit()/do_exit() 之后,都会称为僵尸进程(Zombie)。
进程关联的资源(内存,文件,信号等)被释放掉了,但是它本身占用的内存还没有释放,比如创建时分配的内核栈,进程号,thread_info结构和task_struct结构等
子进程退出后,父进程调用 wait() 或 waitpid() 获取子进程的状态信息,通知内核将其资源释放。

10.1.1 父进程处理僵尸进程 release_task

进程退出后,父进程调用 wait() 或 waitpid() 来检测子进程是否存在。
操作:挂起调用本进程,直到其中一个子进程退出,此时函数会返回子进程的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高速缓存。

至此,进程描述符和所有进程独享的资源就全部释放掉了。

10.2 孤儿进程

孤儿进程是没有父进程的进程

参考:关于僵尸/孤儿进程的详细文章:僵尸进程 & 孤儿进程
https://blog.csdn.net/lqy971966/article/details/119116896

10.2.1 孤儿进程处理流程 find_new_reaper

reaper 收割者;收割机

如果子进程的父进程已经退出了,那么子进程在退出时调用
exit_notify()->forget_original_parent()->find_new_reaper()来寻找新的父进程。
find_new_reaper()函数先在当前线程组中找一个线程作为父亲,如果找不到,就让init做父进程。
init进程会例行调用 wait()来检查其子进程,清除所有与其相关的僵死进程。

11. linux 中的线程

11.1 对内核来说,并没有线程这个概念

在Linux中,对内核来说,并没有线程这个概念(线程不过是一种特殊的进程)
它把所有的线程当做进程来实现,线程仅仅被视为一个与其他进程共享某些资源的进程。

每个线程都有唯一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程
(只是线程和其他一些进程共享某些资源,比如地址空间)。

对于linux来说,线程只是一种进程间共享资源的手段。
比如有一个包含4个线程的进程,linux仅仅创建4个进程并分配4个普通的task_struct结构,并指定它们共享某些资源而已。

11.2 创建线程

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

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

参数标志说明:
	CLONE_VM :父子进程共享地址空间
	CLONE_SIGHAND :父子进程共享信号处理函数
	CLONE_THREAD :父子进程放入相同线程组
	CLONE_FS :父子进程共享文件系统信息
	CLONE_FILES :共享打开的文件 

上面代码产生的结果跟fork()类似,只是父子俩共享地址空间、文件系统资源、文件描述符和信号处理程序。

一个普通的fork()的实现是:
clone(SIGCHLD, 0);

11.3 内核线程

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

和普通进程的区别:没有独立的地址空间,只能在内核空间运行
(Linux是一个单内核,整个内核都在一个大内核地址空间上运行)

创建只能由其他内核线程创建,函数为kernel_thread

从现有内核线程中创建一个新的内核线程的方法如下:

// 定义于<linux/kthread.h>中
struct task_struct *kthread_create(int (*threadfn)(void *date),
                void *data,
                const char namefmt[],
                ...)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值