进程管理
task_struct :
在32位机器上可以有1.7KB大小,存于任务队列的双向链表中(系统中存在多种调度机,所以这种任务队列存在多个)。
作用:内核管理进程所需要的一切信息,比如打开的文件,进程的地址空间(mm_struct),挂起的信号(作为链表,可见后续linux的数据结构),进程状态,上下文寄存器存储的信息等。
由slab构造,可由位于进程内核栈底的thread_info指向,也可由r2寄存器指向(实时性指向,为了寻址更迅速)。
current宏返回当前的task_struct的指针(要么通过计算thread_info偏移,要么直接返回r2寄存器(当然这个寄存器专门存放当前进程描述符的地址,只有某些CPU体系有))。
进程状态:
1.TASK_RUNNING;运行
2.TASK_INTERRUPTIBLE; 睡眠状态,可中断
3.TASK_UNINTERRUPTIBLE; 睡眠状态,不可中断
4._TASK_TRACED; 被跟踪
5._TASK_STOPPED; 停止(挂起)
通过set_task_state( task,state ); 设置当前进程状态(尽量避免直接修改内核结构体,系统给定的函数是会在必要的时候设置内存屏障等保证安全)
进程家族树:
内核在系统启动的最后阶段启动init进程作为始祖进程(PID为1),该进程读取系统初始化脚本以及执行其它相关程序,最终完成系统启动的整个过程。一个进程可以创建子进程(或者线程,两者的区别在于线程与父进程共享地址空间)。
每个进程可以通过进程描述符指向其父亲的task_struct,其所有的孩子的task_struct(通过子链),整个成为一个树状家族谱或者是全连通图,因此通过某个进程(线程)可以轻松地访问系统内的所有进程的进程描述符,但是不要这么做。
进程生产过程:
1.进程创建
首先,进程和线程(包括内核线程)的创建均通过系统调用(陷入内核态的方式,通过内中断软触发实现,有cpu体系的支持)触发的,会按序执行很多内核函数。
linux进程分为用户进程,用户线程,内核线程三种。
1.其中用户进程通过系统调用fork,vfork实现的(这个场景下父进程会休眠,等唤醒子进程后再唤醒父进程,目的:刚刚创建子进程的时候子进程与其父共享地址空间,如果父亲在调度会产生数据变化影响生成子进程后续的操作。)。
2.用户线程通过clone系统调用实现。
3.内核线程通过create_kthread来实现。
三种进程的创建过程均会调用
do_fork(unsigned long clone_flags, //创建子进程相关的参数,决定了父子进程之间共享的资源种类,以及执行do_fork功能的不同
unsigned long stack_start, //进程栈开始地址(用于计算偏移之后来分配具体的进程内核栈)
unsigned long stack_size, //进程栈空间大小(防止分配空间超出)
int __user *parent_tidptr, //父进程的pid
int __user *child_tidptr, //子进程的pid(只是当前的命名的,还需要转为namespace下的)
unsigned long tls //线程局部存储空间的地址(没有分配内核栈的暂存地)。
)
clone_flags参数
do_fork()执行过程:
1.根据clone_flags确定类型->2.调用 copy_process()函数(相当重要的初始化过程)->3.记录调度相关的东西->4.获取子进程id并将之转为namespace下的pid(namespace是虚拟化技术的重要部分)->5.初始化completion同步变量vfork ->6.唤醒刚创建的子进程 ->7.追踪子进程(初始化护航吧)->8.如果是vfork父进程休眠,则等待子进程将之唤醒 ->9.保存pid到namespace空间。
copy_process()执行过程:
1.检查clone_flags参数 ->2.调用dup_task_struct分配task_struct(使用slab)和分配内核栈(调用内核的伙伴系统分配,4K或8K(1页~2页左右,根据体系来)),创建完成之后父子进程描述符完全相同->3.task_struct简单配置初始化->4.初始化调度策略(决定使用的是CFS或者是RT),和优先级->5.根据clone_flags参数来决定重新分配或者是共享父进程的内容:打开的文件、文件系统信息、信号处理函数、进程地址空间、和命名空间等(调用一批函数)->6.初始化内核栈和thread_struct(保留一些PCB信息)结构->7.分配pid并加入到pid_hash中,之后返回task_struct。
写时拷贝:
fork()的实际开销就是复制父进程页表,和给子进程创建唯一的进程描述符。其余的尤其是地址空间里面大批量的数据是不需要复制的,因为之前花了很大功夫复制的之后可能会被很快换出去(因为自己不需要)。写时拷贝本质就是先共享用来读,写的时候因为各个进程(除了子线程)之间有区分,所以需要完成拷贝过程避免出错。在一些情况下:比如fork()完成之后,一般会直接使用execve系统调用完成地址空间中程序映射的替换,这样的话是无需在之前拷贝父进程的程序代码的。
2.进程执行(创建之后接着完成的):
进程刚刚创建完成时相当于是父进程的一个复制,两者执行相同的代码(两者同时指向一个mm_struct,所以拥有相同的代码区映射,所以执行相同的程序)。所以之后需要通过系统调用execve来完成子进程运行程序替换的过程,步骤如下:
1.打开关联的可执行文件->2.初始化用于加载文件内容所需要的linux_binprm结构体,在其中会初始化一份新的mm_struct(写时拷贝了)给进程使用->3.读取文件inode,并根据其中的权限等控制完成读写,收集参数及环境变量。
总之就是将关联的代码文件装载到进程上,并修改mm_struct。
总结
内核线程,用户进程,用户线程的创建均需要调用do_fork函数,不同之处在于传入的clone_flags不同。
进程有自己的独立地址空间(可以理解为有自己的页表和地址逻辑构成(mm_struct)),用户线程和父共享地址空间。用户进程和线程均有内核栈(陷入内核态要用),内核进程只有内核栈(线性映射,所以所有内核共享内核页表和栈空间)。