Linux内核设计与实现(3)第三章:进程管理
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[],
...)
本文深入探讨了Linux系统中的进程管理机制,包括进程定义、进程状态、进程创建与终结、虚拟处理器与虚拟内存等核心概念。同时介绍了僵尸进程与孤儿进程的处理方式,以及Linux系统如何实现线程。

被折叠的 条评论
为什么被折叠?



