进程的一生

与进程相关的基础知识,可以移步先去看看这篇文章——《进程基础整理》。

进程的状态

一个进程随着执行地推进改变着自己的状态(state)。进程的状态由进程在当前所执行的动作有关。每一个进程的状态都应是以下所列举的状态中的一种:

  • 新建(New):创建一个进程
  • 运行(Running):执行指令
  • 等待(Waiting):进程等待某件事的发生(例如:I/O的完成、信号的接收)
  • 就绪(Ready):进程在就绪队列中等着被CPU调度,以能够分派到处理器
  • 终止(Terminated):进程执行结束
这几个状态的名称可能在不同的操作系统中表述会有所不同,但是对应状态的信息一定能够在所有的系统中找到对应的。对于只有一个CPU的机器,得牢记的是,在每一个确定的时间点永远只有一个进程处于运行态而得以在CPU上运行(或者说进程虚拟的CPU与硬件CPU合一),认为有多个任务同时在运行的(处于运行态),那只是并发的“错觉”;处于就绪态和等待态的却同时可以有多个进程。用一个羊头图可以如下表示进程状态的动态变换:

对于进程的控制,Linux操作系统有提供相应的系统调用函数,现在我们就来理解与进程控制有关的几个常用函数。

1.fork函数

要创建一个进程,最基本的系统调用是 fork 。系统调用 fork 用于派生一个进程,函数原型如下:
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

fork系统调用的返回:若成功,父进程中返回子进程的PID,子进程中返回0;若出错则返回-1。fork出错可能有两种原因:一是当前的进程数已经达到了系统规定的上限,这是errno上的值被设置为EAGAIN;二是系统内存不足,这是errno的值被设置为ENOMEM)。

fork系统调用的作用是复制一个进程。当一个进程调用它,完成后就出现两个一模一样的进程,我们也由此得到了一个新进程。由fork创建的新进程被称为子进程(child process),而将原来的进程称为父进程(parent process)。子进程是父进程的一个拷贝,即子进程从父进程得到了数据段和堆栈段的拷贝,这些需要分配新的内存;而对于只读的代码段,通常使用共享内存的方式访问。

fork函数的主要用途是:

i)一个进程希望复制自身,从而父子进程能同时执行不同段的代码

ii)进程想执行另外一个程序

fork函数被调用一次,但返回两次(这样的函数在linux中只有少数几个)。两次返回的区别是子进程的返回值是0,而父进程的返回值是新的子进程的进程ID。将子进程的进程ID返回给父进程的理由是:因为一个进程的子进程可以多于一个,所以没有一个函数使一个进程可以获得其所有子进程的进程IDfork使子进程得到返回值0的理由是:一个进程只会有一个父进程,所以子进程总是可以调用getppid以获得父进程的进程ID(进程ID0总是由交换进程使用,所以一个子进程的进程ID不可能为0)。

fork返回后,子进程和父进程都从fork函数的下一条语句开始执行。

一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的。这取决于内核所使用的调度算法。如果要求父子进程之间相互同步,则要求某种形式的进程间通信。

linux中,创造一个新进程的方法只有这一个——fork系统调用。

对fork有深入理解,可以参考这篇文章——《一个fork的面试题》。

2.exec函数

linux使用exec函数族来执行新的程序,以新的子进程来完全替代原有的进程。

实际上,在linux中并不存在一个exec的函数形式,exec是一个函数族,指的是一组函数,共6个,分别是:

#include <unistd.h>
int execl(const char *pathname, const char *arg, …);
int execlp(const char *filename, const char *arg, …);
int execle(const char *pathname, const char *arg, …, char *const envp[]);
int execv(const char *pathname, const char arg[]);
int execlp(const char *filename, const char arg[]);
int execle(const char *pathname, const char arg[], …, char *const envp[]);

六个函数返回:若成功则无返回值,若出错则返回-1。

事实上,其中只有execve才是真正意义上的系统调用,其他都是在此基础上经过包装的库函数。exec函数族的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件。这里的可执行文件既可以是二进制文件,也可以是任何linux下可执行的脚本文件。

与一般情况不同,exec函数族的函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈段都已经被新的内容所取代,只是进程ID等一些表面上的信息仍保持原样,颇有点神似三十六计中的金蝉脱壳。看上去还是旧的躯壳,却已经注入了新的灵魂。只有调用失败了,它们才会返回一个-1,从原程序的调用点接着往下执行。

现在我们就能知道 linux是如何执行新程序的?(fork函数调用之后,子进程是父进程的一个 完全拷贝,但是fork之后一般都会调用exec函数族......

i)每当有进程认为自己不能为系统和拥护做出任何贡献了,它就可以发挥最后一点余热,调用任何一个exec,让自己以新的面貌重生

ii)如果一个进程想执行另一个程序,它就可以fork出一个新进程,然后调用任何一个exec,这样看起来就好像通过执行应用程序而产生了一个新进程一样。

事实上第二种情况被应用的更普遍,以至于linux专门为其做了优化,我们已经知道,fork会将调用进程的所有内容原封不动地拷贝到新产生的子进程中去,这些拷贝的动作很消耗时间,而如果fork完之后马上就调用了exec,这些辛辛苦苦拷贝来的东西又会被立刻抹掉,这看起来非常不划算,于是人们设计了一种写时复制(Copy-On-WriteCOW)技术,使得fork结束后并不立刻复制父进程的内容,而是到了真正实用的时候才复制,这样如果下一条语句是exec,它就不会白白做无用功了也就提高了效率。

3.exit函数

从exit的名字即能看出,这个系统调用是用来终止一个进程的。无论在程序的什么位置,只要是执行到了exit系统调用,进程就会停止剩下的所有操作,清除包括PCB在内的各种数据结构,并终止本进程的执行。exit在Linux函数库中得原型是:
#include <stdlib.h>
void exit(int status);
还有另一相似的系统调用_exit(),对于这两函数的差别可以参考—— exit()和_exit()的差别
这里需要注意的是:在一个进程调用了exit之后,该进程并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构。
僵尸进程(Zombie):它已经放弃了几乎所有内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留了一个位置,记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间。
处理僵尸进程的方法之一就是下面将要介绍的进程等待的系统调用wait和waitpid。

4.wait和waitpid函数

wait和waited的函数原型是:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
两个函数的返回:若成功则返回进程ID,若出错则返回-1。
进程一旦调用了wait,就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样的一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。
参数status用来保存被收集进程退出时的一些状态,它是一个执行int类型的指针。但如果我们对这个子进程是如何死掉的毫不在意,而只想把这个僵尸进程消灭掉,这时就可以设定status为NULL。
pid = wait(NULL);
如果成功,wait会返回被收集的子进程的进程ID,如果调用进程没有子进程,调用就会失败,此时wait返回-1,同时errno被置为ECHILD。
从本质上来讲,waited和wait的作用是完全相同的,但wait多提供了参数pid和options。对于wait和waitpid的详细讲解可以参考 这篇文章

5.一个简单的示例程序

#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>

int main(){
    pid_t pid;
    pid = fork();

    if (pid < 0){/*error occurred*/
        fprintf(stderr,"Fork Failed!");
        exit(-1);
    }
    else if (pid == 0){/*Child Process*/
         execlp("/bin/ls","ls",NULL);
    }
    else{/*Parent Process*/
    	/*parent will wait for the child to complete*/
    	wait(NULL);
    	printf("Child Complete!");
    	exit(0);
    }
}

6.进程的一生

用一个形象的比喻来对进程短暂的一生做一个小小的总结:

首先,随着fork的成功执行,一个新的子进程诞生,但此时的它还只是父进程的一个克隆,从父进程哪里得到了数据段和堆栈段的拷贝。然后随着exec,新进程脱胎换骨,离家独立,开始独立执行一个全新的程序,并完全替代了原有拷贝的父进程的数据段和堆栈段数据。人有生老病死,进程也是一样,它可以是自然死亡,即运行到main函数的最后一个“}”,从容地离我们而去;也可以是自杀,自杀有两种方式,一种是调用exit函数,一种是在main函数中使用return,无论是哪一种方式,它都可以留下遗书,放在返回值里保留下来;它甚至还可以被谋杀,被其他的进程通过另外一些方式来结束它的生命。

进程死掉之后,会留下一具僵尸,称为“僵尸进程”,waitpid充当殓尸工,把僵尸推过去火化,使其最终归于无形。

这就是进程完整的一生。


参考资料:《Linux C 编程 从入门到精通》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值