正如上一篇咱们提到过,进程是Linux系统中仅次于文件的基本抽象概念。正在运行的进程不只仅是二进制代码,而是数据、资源、状态和虚拟的计算机组成。咱们今天主要介绍进程的概念,组成,运行状态和生命周期等。html
进程就是处于执行器的程序(目标代码放在某种存储介质上)。算法
但进程并不只仅局限于一个可执行程序代码,一般还要包含其余资源,好比:centos
打开的文件,挂起的信号,内核内部数据,处理器状态,一个或多个具备内存映射的内存地址空间,一个或多个可执行线程,存放全局变量的数据段等。数组
内核须要有效而又透明地管理全部细节。缓存
执行线程(thread of execution),简称线程(thread),是进程中活动的对象。每一个线程都拥有本身的虚拟存储器,包括程序计数器,栈,一组进程寄存器。编辑器
一个进程只有一个虚拟内存实例,因此,进程下的全部线程共享相同的内存地址空间。函数
进程的另外一个名字是任务(task),Linux内核一般把进程叫作任务。性能
内核把进程列表存放在任务队列(task list)的双向循环链表中。链表中的每个节点都是类型为task_struct称为进程描述符(process descriptor)的结构。测试
每一个进程描述符都包含了一个具体进程的全部信息。在32位机器上,大约有1.7KB。因此它包含了前面进程的定义中提到的“打开的文件,挂起的信号......”诸多信息。spa
Linux内核经过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色的目的。在Linux2.6后面的内核中,每一个任务的新的thread_info结构在内核栈的尾端分配,该结构中task指针存放的是指向该任务实际task_struct的指针。
而事实上,Linux内核栈是比较小的,在x86上,32位机的内核栈8KB,64位机是16KB。固然能够配置,每一个处理器也都有本身的栈。可是也说明了内核栈不大,而一个进程描述符占了1.7KB,已是相对较大了。
内核经过一个惟一的进程标识值PID来标识每一个进程。PID是一个数,实际上为int类型。PID的最大默认值为32768。固然能够配置。
这个值表明了系统中能够同时存在的进程的最大数目,值越小,转一圈就越快。可是值一大,若是要切换进程,又成了一问题。该PID存在task_struct结构中,实际上最终目的是获得进程描述符。因此经过current宏找到当前正在运行的进程的速度显得尤其重要,硬件体系结构不同,处理不同。有的经过专门的寄存器存放当前进程描述符指针。而x86寄存器有限,只能在内核栈尾端建立thread_info结构,经过计算偏移间接地查找task_struct结构。
进程的状态以下图所示,分为五种,系统中的每一个进程必定处在其中的一种。进程的当前状态存储在进程描述符的state域。
该进程状态图很是准确而简要的描述了进程状态的切换,说明了进程从建立到运行到销毁的过程,也说明了进程被抢占或者被中断的转换过程。固然可能须要结合书或者这连续几章的介绍会了解得更加深入。
下面咱们先分开描述五个状态的含义。
TASK_RUNNING(运行):进程是可执行的,就绪或者正在运行。就绪表示已经加入到运行队列中等待执行。同时,该状态也是进程在用户空间中惟一可能的状态,因此只有该状态在用户空间和内核空间都能表示。
TASK_INTERRUPTIBLE(可中断):进程正在睡眠(即被阻塞),等待某些条件的达成便可被唤醒。
TASK_UNINTERRUPTIBLE(不可中断):该进程即便在等待时也不受干扰,不接收信号,使用较少。
注,ps aux查看进程stat字段为D状态,不可中断又不能杀死的进程。它可能正在执行一个重要的任务或者持有一个信号。进程启动之初也是处于这个状态。
__TASK_TRACED:被其余进程跟踪的进程,例如经过ptrace对调试进程进行跟踪。
__TASK_STTOPED(中止):进程中止执行;进程没有投入运行也不能投入运行。
细心的你可能会发现,这五个状态和图上的五个状态明显不同。是的,但我认为都没毛病。上图是总体的状态切换,而这里应该是做者站在内核的角度进行列举。
TASK_RUNNING中咱们提到了用户空间和内核空间,咱们在这里在从进程状态来看看进程上下文的定义。
进程上下文在上一篇基本概念中提到过。
可执行程序代码是进程的重要组成部分,这些代码从一个可执行文件载入到进程的地址空间执行。
通常程序在用户空间执行,当一个程序执行了系统调用或者触发了某个异常,它就陷入内核空间。此时,咱们称内核“表明进程执行”并处于进程上下文中。
注1:系统调用和异常处理程序是内核与外界的接口的统称,即内核的全部访问都必须经过这些接口。
注2:中断上下文在基础概念中亦提过,在中断上下文中,系统不表明进程执行,而是执行了一个中断处理程序,而每每和驱动相关。因此不会有进程干扰它,此时也不会存在进程上下文。也能够理解为内核要么处于进程上下文中,要么处于中断上下文中,固然也能够休息用户空间工做便可。
每一个进程都有惟一的ID进行标识,即进程ID,简称PID。在Linux系统中,进程之间存在一个明显的进程关系,全部的进程都是PID为1的init(centos 7为systemd)进程的的后代。
而PID为0表示空闲进程,即当没有其余进程在运行时,内核运行该空闲进程。
因此,每个进程都有一个父进程,每个进程也能够拥有0个或者多个子进程,这样组成了一颗进程树。
Unix的进程建立很特别。许多其余的操做系统都提供了产生(spawn)进程的机制,首先在新的地址空间里建立进程,读入可执行文件,最后开始执行。
Unix与之不一样,将上述的步骤分解到两个单独的函数中去执行:fork()和exec()。exec()表明了execve()等一系列函数。
首先建立一个新的进程,而后,经过exec系统调用把新的二进制程序加载到该进程中。
Linux经过clone()系统调用实现fork()。
fork()、vfork()和__clone()库函数都根据各自须要的参数标志去调用clone(),而后clone()去调用do_work()。
传统的fork()系统调用直接把全部的资源复制给新建立的进程。现代的Linux操做系统使用写时复制(copy-on-write COW)页来实现。
写时复制是一种能够推迟甚至免除复制数据的技术(惰性算法),内核此时并不复制整个进程地址空间,父子进程共享同一份拷贝。
只有在须要写入时,数据才会进行复制,并且虚拟内存是分页来处理,某一页被修改了会产生缺页中断,该页才须要复制,因此使各个进程拥有各自的拷贝。因此加快了进程的建立。
再回到fork(),内核有意选择子进程首先执行。由于通常子进程立刻调用exec()函数,这样避免写时复制的额外开销。由于若是父进程首先执行的话,有可能开始向地址空间写入。
经过fork()系统调用,能够建立一个和当前进程同样的进程。新进程称为原进程的“子进程”,原进程称为“父进程”。在子进程中,成功的fork()返回0;在父进程中,fork()会返回子进程的pid。
除了一些本质性区别,父子进程在其余各方面都是相同的:
它是在COW出现以前的一种进程建立方式,如今由于有了COW,因此该方式已经基本不使用了。
除了不能拷贝父进程的页表项外,它和fork()的功能相同。子进程做为父进程的一个单独线程在它的地址空间运行,父进程被阻塞,直到子进程退出或者执行exec()。
Microsoft Windows或Sun Solaris等操做系统在内核中提供了专门支持线程的机制。而Linux操做系统中,线程看起来就像是一个普通的进程。
在Linux内核中,并没有线程的概念。因此它的建立和普通进程相似,只是须要选择不一样的参数。
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
这些参数表示父子俩共享地址空间、文件系统资源、文件描述符和信号处理函数。
对比一下普通进程fork()的实现是:
clone(SIGCHILD, 0);
因此,传递给clone()的参数标志决定了新建立进程的行为方式和父子进程之间共享的资源种类。
内核常常须要在后台执行一些操做。这种任务能够经过内核线程(kernel thread)完成——独立运行在内核空间的标准进程。
内核线程和普通进程的区别在于内核线程没有独立的地址空间!由于它们只在内核空间运行,不须要去用户空间。
内核线程和普通进程同样,能够被调度,能够被抢占。
咱们在Linux系统中运行ps -ef命令,能够看到不少内核线程,好比flush和ksoftirqd等等。
内核是经过从kthreadd内核进程中衍生出来全部新的内核线程。
前面提到过exec()不是一具体函数,而是表明了6个函数,以下。
execl() execlp() execle()
execv() execvp() execve()
第一行三个函数表示参数是可变的,第二行表示参数是固定的,便可变的变成了数组。l表示list,v表示vector。
第二列的p表示会在用户的绝对路径下查找可执行文件,即参数指定的文件名必须在用户路径下。p表明path。
第三列的e表示会为新进程提供新的环境变量。
六个函数只有execve()是惟一的系统调用,其余是在其基础上封装的C库函数。
一个exec()系统调用会把二进制程序加载到内存中,替换地址空间原来的内容,并开始执行,这个过程称为“执行(executing)”一个新的程序。
execl("/bin/vi", "vi", NULL);
第一个参数表示二进制程序的路径,第二个参数通常表示程序名称,该程序是“vi”编辑器。NULL表示最后的参数,前面提到过l表明了可变参数。
execl("/bin/vi", "vi", "/home/test/123.txt", NULL);
上面的示例加了一个参数,该参数表示vi编辑的对象。
成功的execl调用不只改变了地址空间和进程映像,还改变了进程的其余一些属性:
可是进程的某些属性仍是没有改变,如pid,ppid,优先级,所属的用户和组。
还有文件描述符也被继承了下来,因此实际操做中通常会在调用exec前关闭打开的文件,固然也能够经过fcntl(),让内核去自动完成关闭操做。
通常来讲,进程的析构是自身引发的。它发生在进程调用exit()系统调用时,便可以显示调用,也能够隐式地从某个程序的主函数返回(如C语言编译器会自动在main()函数的返回点加上exit())。固然也可能被动的终结,好比信号通知或异常处理等。无论进程如何终结,该任务大部分经过do_exit()来完成。
在终止进程以前,C库会按顺序执行如下关闭进程的步骤。
这些步骤完成了在用户空间须要作的全部工做,最后exit()会调用_exit(),内核能够处理终止进程的剩余工做。
内核清理进程建立的、再也不使用的资源,包括但不局限于:分配内存、打开文件和System V的信号量。清理完成后,内核会摧毁进程并告知父进程其子进程已近终止。
注:atexit()是POSIX标准函数,而on_exit()是SunOS 4定义的,新版本的Solaris也再也不支持了。atexit()主要用来指定的注册函数做为终止函数。
按上述方式将进程终结后,系统还保留了它的进程描述符,在父进程得到已经终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程的task_struct结构才会被释放。
若是父进程在子进程以前退出,必须有机制来保证子进程能找到一个新的父亲,不然这些成为孤儿的进程就会在退出时永远处于僵尸(EXIT_ZOMBIE)状态,白白地耗费内存。
Linux内核提供了一些接口,能够获取已终止子进程的信息。
wait(), waitpid(), waitid(), wait3(), wait4()
前面三个是标准的POSIX标准定义的,后两个不是。常使用的是waitpid()函数,等待某个特定的子进程,固然也能够是一组进程,主要依据是第一个参数的值。
init进程会周期性地调用wait()来检查子进程,清除全部与其相关的僵尸进程。
ps:事实上,在实际项目中,碰到过几回僵尸进程,状况好的重启系统恢复正常,状况差的出现太重启失败,只能按电源键。所幸的是这种状况极少,出现僵尸进程通常是性能撑不住的状况下。但个人疑问是,为啥systemd进程没有检查到该僵尸进程呢??若是能检查到并清理的话,那么咱们则只需再启动应用程序便可。
简单测试
#include <stdio.h> #include <sys/wait.h> #include <sys/types.h> #include <unistd.h>
int main(){ int ret; pid_t pid; pid_t pidSelf = getpid(); printf("hello, my pid is %d~\n", getpid()); pid = fork(); if (pid == -1) return -1; else if (pid == 0){ printf("hello, child process, my pid is %d\n", getpid()); //exit(-1);
sleep(5); } else if (pid > 0){ printf("hello, father process, my pid is %d!\n", getpid()); } printf("hello world, my pid is %d!\n", getpid()); waitpid(-1, NULL, 0); printf("end, my pid is %d.\n", getpid()); return ret; }
输出结果:
hello, my pid is 2816~
hello, father process, my pid is 2816!
hello world, my pid is 2816!
hello, child process, my pid is 2817 //然后暂停5s.
hello world, my pid is 2817!
end, my pid is 2817.
end, my pid is 2816.
参考资料:
《Linux内核设计与实现》