进程
进程
指在系统中能独立运行并作为资源(一段可执行程序代码、打开的文件、挂起的信号、内核内部数据、处理器状态、一个或多个具有内存映射的内存地址、一个或者多个执行线程、存放全局变量的数据段…)分配的基本单位。
它是由一组机器指令、数据和堆栈组成的,是一个能独立运行的活动实体。
进程变迁图(略)
创建->就绪->执行->等待->退出
线程
是进程中的一个实体,作为系统调度和分派的基本单位。
对Linux而言,线程只不过是一种特殊的进程罢了。
进程的虚拟机制
虚拟处理器
虚拟内存
让进程以为自己在独享处理器
线程之间可以共享虚拟内存,但每个都拥有各自的虚拟处理器
进程描述符
进程描述符(process descriptor)的结构
内核把进程的列表存放在叫做任务队列的双向循环链表中。
链表中每一项都是类型都task_struct、称为进程描述符(process descriptor)的结构
分配进程描述符
Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色(cache coloring)的目的。
slab分配器
什么是slab分配器?
首先我们来看看task_struct结构中,slab分配器是怎么使用的
我们看内核源代码kernel/fork.c:
首先138行,用一个全局变量存放指向 task_struct 高速缓存的指针
|
|
在内核初始化的时候,430行的 fork_init(void) 会创建高速缓存:
|
|
这样就创建了一个task_struct的高速缓存,其中存放的就是struct task_struct 对象。
该对象被创建后存放在slab中偏移量为 ARCH_MIN_TASKALIGN(预定义的值与体系结构有关,通常定义为L1_CACHE_BYTES也就是L1高速缓存的字节大小) 的地方。
每当进程调用fork()时,一定会创建一个新的进程秒速符,这是在458行dup_task_struct()中完成的,而该函数被1913行do_fork()(the main fork-routine)调用:
|
|
进程执行完成后,如果没有子进程等待的话,它的进程描述符就会被释放,并返回给task_struct_cachep slab高速缓存,这是在145行的free_task_struct()中实现的:
|
|
slab层负责内存紧缺情况下所有底层的对齐、着色、分配、释放和回收等,可以说是一种很好地创建内存池的方式,挖个坑,不久后我也会写一篇博客来介绍jemalloc现在比较流行的表现出色的内存池实现。
这也告诉我们,当我们要频繁地创建很多相同类型的对象的时候,应该考虑使用slab告诉缓存,也就是说,不需要我们自己去实现空闲链表。
struct thrad_info结构
好的,现在我们回过头来继续看内存描述符的分配
首先看 x86体系上的struct thread_info结构,定义在linux/arch/arm/include/asm/thread_info.h中:
|
|
进程描述符的存放
内核通过一个唯一的进程标识符即PID(process identification value)来标识每个进程。
PID最大值的默认设置为 short int的最大值也就是著名的32768,然而这个值在 /proc/sys/kernel_pid_max中进行修改的。
进程状态
也就是我们之前提到的进程状态图啦,上过OS的同学应该都知道,所以就略过了
就是下面五个状态之间的转换了
创建->就绪->执行->等待->退出
内核经常调整某个进程的状态 用到set_task_state(task, state) 或者 set_current_state(state) 函数
进程上下文
要理解上下文这个东西,首先我们得明白两个概念。
一个是内核态,另一个是用户态。
现代的CPU都具有不同的操作模式,代表不同的级别,不同的级别具有不同的功能,在较低的级别中将禁止某些操作。
Linux操作系统在设计的时候,使用了两个级别,一个是最高级别的内核态,这个级别可以进行所有操作。
而另一个是用户态,处理器控制着对硬件的直接访问以及对内存的非授权访问。
内核态和用户态有自己的内存映射,即自己的地址空间。
正是有了不同运行状态的划分,才有了上下文的概念。用户空间的应用程序,如果想要请求系统服务,比如操作一个物理设备,或者映射一段设备空间的地址到用户空间,就必须通过系统调用来(操作系统提供给用户空间的接口函数)实现。
通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的地址映射,通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。
所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
提供系统调用服务的内核代码代表发起系统调用的应用程序运行在进程上下文。
进程创建
写时拷贝 copy-on-write
fork()只有我们需要写入的时候,数据才会被复制。
fork()
fork()和vfork()
通过系统调用 0x80中断来陷入内核,由系统提供的相应系统调用来完成进程的创建
fork.c
正如我们之前看slab的时候提到的,fork创建进程,都是通过do_for()来实现的,vfork亦是如此。
do_fork()
do_fork()步骤:
调用 copy_process 为子进程复制出一份进程信息
如果是 vfork 初始化完成处理信息
调用 wake_up_new_task 将子进程加入调度器,为之分配 CPU
如果是 vfork,父进程等待子进程完成 exec 替换自己的地址空间
do_fork()代码:
do_fork()的主要代码如下:
|
|
copy_process
copy_process步骤:
调用 dup_task_struct 复制当前的 task_struct
检查进程数是否超过限制
初始化自旋锁、挂起信号、CPU 定时器等
调用 sched_fork 初始化进程数据结构,并把进程状态设置为 TASK_RUNNING
复制所有进程信息,包括文件系统、信号处理函数、信号、内存管理等
调用 copy_thread 初始化子进程内核栈
为新进程分配并设置新的 pid
copy_process代码
|
|
dup_task_struct
dup_task_struct步骤:
调用alloc_task_struct_node分配一个 task_struct 节点
调用alloc_thread_info_node分配一个 thread_info 节点,thread_info的结构我们在前面也介绍过了,其实是分配了一个thread_union联合体,将栈底返回给 ti
|
|
- 最后将栈底的值 ti 赋值给新节点的栈
dup_task_struct代码:
|
|
最终执行完dup_task_struct之后,子进程除了tsk->stack指针不同之外,没有任何变化
sched_fork
sched_fork步骤
将子进程状态设置为 TASK_RUNNING
为其分配 CPU
sched_fork代码
|
|
copy_thread
copy_thread代码
|
|
copy_thread 这段代码为我们解释了两个相当重要的问题!
一是,为什么 fork 在子进程中返回0,原因是childregs->ax = 0;这段代码将子进程的 eax 赋值为0
二是,p->thread.ip = (unsigned long) ret_from_fork;将子进程的 ip 设置为 ret_form_fork 的首地址,因此子进程是从 ret_from_fork 开始执行的
vfork()
除了不拷贝父进程的页表项外,基本等同于fork。
fork()总结
总结下来,fork的主要流程如下
新进程的创建过程:
dup_task_struct中为其分配了新的堆栈
调用了sched_fork,将其置为TASK_RUNNING
copy_thread中将父进程的寄存器上下文复制给子进程,保证了父子进程的堆栈信息是一致的
将ret_from_fork的地址设置为eip寄存器的值
最终子进程从ret_from_fork开始执行
线程创建
线程的创建和普通的进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源:
|
|
上面的代码和调用fork()的结果差不多,只是父子俩共享地址空间,文件系统资源,文件描述符和信号处理程序。
对比一个普通的fork()就是:
|
|
而vfork()的实现是:
|
|
线程安全
如果多线程的程序的运行结果是可预期的,并且是与单线程的程序运行结果是一样的,那么我们就认为他是线程安全的。
用户级线程和内核级线程
用户级线程和内核级线程的区别
首先最主要的区别是:相比用户级线程,内核级线程没有独立的地址空间,同时也意味着内核并不能看到用户线程
内核支持线程是OS内核可感知的,而用户级线程是OS内核不可感知的。
用户级线程的创建、撤消和调度不需要OS内核的支持,是在语言(如Java)这一级处理的;而内核支持线程的创建、撤消和调度都需OS内核提供支持,而且与进程的创建、撤消和调度大体是相同的。
用户级线程执行系统调用指令时将导致其所属进程被中断,而内核支持线程执行系统调用指令时,只导致该线程被中断。
在只有用户级线程的系统内,CPU调度还是以进程为单位,处于运行状态的进程中的多个线程,由用户程序控制线程的轮换运行;在有内核支持线程的系统内,CPU调度则以线程为单位,由OS的线程调度程序负责线程的调度。
用户级线程的程序实体是运行在用户态下的程序,而内核支持线程的程序实体则是可以运行在任何状态下的程序。
用户级进程
优点:
线程的调度不需要内核直接参与,控制简单。
可以在不支持线程的操作系统中实现。
创建和销毁线程、线程切换代价等线程管理的代价比内核线程少得多。
允许每个进程定制自己的调度算法,线程管理比较灵活。
线程能够利用的表空间和堆栈空间比内核级线程多。
同一进程中只能同时有一个线程在运行,如果有一个线程使用了系统调用而阻塞,那么整个进程都会被挂起。另外,页面失效也会产生同样的问题。
缺点:
- 资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用
内核级线程
优点:
- 当有多个处理机时,一个进程的多个线程可以同时执行。
缺点:
- 由内核进行调度。
进程终结
do_exit()
删除进程描述符
①.父进程收集其后代的信息,通过wait()系统调用族
②.release_task()
僵尸进程
在linux中,当一个进程退出(如调用exit等)后,并不是马上完全消失掉了,它还会留下一些踪迹,成为一个僵尸进程(Zombie)。作为一个僵尸进程来说,它已经放弃了几乎所有内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间。在僵尸进程记录了这个进程是怎么死亡的(是正常退出呢,还是出现了错误,还是被其它进程强迫退出的?),以及它占用的总系统CPU时间和总用户CPU时间分别是多少?还有发生页错误的数目和收到信号的数目等。
wait 和 waitpid
而wait和waitpid这两个系统调用就是用来收集这些信息,并使得这个僵尸进程永远消失的。
这两个系统调用的原型是这样子的:
|
|
对于wait来说,它是等待所有的子进程的退出, 而对比来看,waitpid增加了两个参数pid和option。
pid
对于pid来说,有四种情况:
pid == -1 等待任一个子进程(与wait等效);
pid > 0 则等待其进程ID与pid相等的子进程。
pid == 0 等待其组ID等于调用进程组ID的任一个子进程。
pid < -1 等待其组ID等于pid绝对值的任一子进程。
status macro
另外,如果参数status的值不是NULL,wait就会把子进程退出时的状态取出并存入其中,这是一个整数值,指出了子进程是正常退出还是被非正常结束的,以及正常结束时的返回值,或被哪一个信号结束的等信息。有一套专门的宏(macro)来对其进行操作:
WIFEXITED(status) 若子进程是正常退出的,则为真,此时可以调用WEXITSTATUS(status)获得退出值
WEXITSTATUS(status) 若子进程是被异常终止的,则为真,此时可以调用WTERMSIG(status)获得使其终止的信号编号
WIFSTOPPED(status) 若子进程是暂停状态,则为真,此时可以调用WTERMSIG(status)获得使其暂停的信号编号
option
还有一个参数叫option,它提供了一些额外的选项来控制waitpid,目前在Linux中只支持WNOHANG和WUNTRACED两个选项:
WNOHANG waitpid在调用时发现没有已退出的子进程可收集,则返回0
WUNTRACED 在所有符合条件的pid中,如果其中有已经stopped的进程,则立即返回(而对于traced的进程,即使没有该选项,如果其stopped了,也会立即返回)
孤儿进程
孤儿进程的产生
如果父进程在子进程之前推出,那么这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。
孤儿进程的解决
解决孤儿进程的一般方法是,给子进程在当前线程组重新找一个线程作为父亲,如果不行,就让init做他们的父进程。