以前,进程是最小的运行单位;有了线程之后,线程成为最小的运行单位,而进程则是线程的容器。程序本身只是指令、数据及其组织形式的描述,进程才是程序(指令和数据)的真正运行实例。多个进程可与同一个程序相关联,而每个进程则是以同步或异步的方式独立运行的。
进程结构一般由3部分组成:代码段、数据段和堆栈段。代码段是用于存放程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用同一个代码段。而数据段则存放程序的全局变量、常量和静态变量。堆栈段中的栈用于函数调用,它存放着函数的参数、函数内部定义的局部变量。堆栈段还包括了进程控制块(Process Control Block,PCB)。PCB处于进程核心堆栈的底部,不需要额外分配空间。PCB是进程存在的唯一标识,系统通过PCB的存在而感知进程的存在。系统通过PCB对进程进行管理和调度。PCB包括创建进程、执行程序、退出进程以及改变进程的优先级等。
一般情况下Linux下C++程序的生成可分为4个阶段:预编译、编译、汇编、链接。编译器g++经过预编译、编译、汇编3个步骤将源程序文件转换为目标文件。如果程序有多个目标文件或者程序使用了库函数,编译器还要将所有的目标文件或所需要的库链接起来,最后形成可执行程序。当程序执行时,操作系统将可执行程序复制到内存中。一般程序转换为进程分以下几个步骤:
①内核将程序读入内存,为程序分配内存空间;
②内核为该进程分配进程标识符(PID)和其他所需资源;
③内核为进程保存PID及相应的状态信息,把进程放到运行队列中等待执行,程序转化为进程后就可以被操作系统的调度程序调度执行了。
进程的创建有两种方式:一种是由操作系统创建,一种是由父进程创建。
系统允许一个进程创建新进程(即为子进程),子进程还可以创建新的子进程,形成进程树结构。整个Linux系统的所有进程也是一个树形结构。树根是系统自动构造的,即在内核态下执行的0号进程,它是所有进程的祖先。由0号进程创建1号进程(内核态),1号负责执行内核的部分初始化工作及进行系统配置,并创建若干个用于高速缓存和虚拟贮存管理的内核线程。随后,1号进程调用execve()运行可执行程序init,并演变成用户态1号进程,即init进程。
它按照配置文件/etc/initab的要求,完成系统启动工作,创建编号为1号、2号……的若干终端注册进程getty。每个getty进程设置其进程组标识号,并检测配置到系统终端的接口线路。当检测到来自终端的连接信号时,getty进程将通过函数execve()执行注册程序login,此时用户就可输入注册名和密码进入登录过程,如果成功,由login程序再通过函数execv()执行shell,该shell进程接收getty进程的pid,取代原来的getty进程。再由shell直接或间接地产生其他进程。
上述过程可描述为:0号进程—>1号内核进程—>1号内核线程—>1号用户进程(init进程)—>getty进程—>shell进程
1.fork函数
Linux系统下使用fork()函数创建一个子进程,其函数原型如下:
#include <unistd.h>
pid_t fork(void);
除了0号进程(该进程是系统自举时由系统创建的)以外,Linux系统中的任何一个进程都是由其他进程创建的。
fork()函数不需要参数,返回值是一个进程标识符(PID)。对于返回值,有以下3种情况。
(1)对于父进程,fork()函数返回新创建的子进程的ID。
(2)对于子进程,fork()函数返回0。
(3)如果创建出错,则fork()函数返回-1。
事实上,子进程完全复制了父进程的地址空间的内容,包括堆栈段和数据段的内容。但是,子进程并没有复制代码段,而是和父进程共用代码段。这样做是合理的,因为子进程可能执行不同的流程来改变数据段和堆栈段,因此需要分开存储父子进程各自的数据段和堆栈段。但是代码段是只读的,不存在被修改的问题,因此代码段可以让父子进程共享,以节省存储空间。
父子进程共用代码段
现在的Linux内核在实现fork()函数时往往在创建子进程时并不立即复制父进程的数据段和堆栈段,而是当子进程修改这些数据内容时复制操作才会发生,内核才会给子进程分配进程空间,将父进程的内容复制过来,然后继续后面的操作。这样的实现更加合理,对于一些只是为了复制自身完成一些工作的进程来说,这样做的效率会更高。这也是现代操作系统中一个重要的概念——“写时复制”的一个重要体现。
循环创建n个子进程
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
printf("father process exec begin...");
pid_t pid;
int i;
for(i = 0; i < 5; i++)
{
pid_t pid = fork();
if(pid == -1)
{
perror("fork error");
exit(1);
}
else if(pid == 0)
{
//不让子进程现创建孙子进程
break;
}
}
if(i<5)
{
sleep(i);
printf("I'm %dth child, pid = %u, ppid = %u \n", i+1, getpid(), getppid());
}
else
{
sleep(i);
printf("I'm father");
}
return 0;
}
2.僵尸进程和孤儿进程
子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。于是就产生了孤儿进程和僵尸进程。
孤儿进程,是指一个父进程退出后,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
僵尸进程,是指一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,这种进程称为僵尸进程。当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。
(1)wait函数
函数原型为:
pid_t wait(int * status);
wait()会暂时停止目前进程的执行,直到有信号来到或子进程结束。如果在调用wait()时子进程已经结束,则wait()会立即返回子进程结束状态值。子进程的结束状态值会由参数status返回,而子进程的进程识别码也会一快返回。如果不需要结束状态值,则参数status可以设成NULL。
如果执行成功则返回子进程识别码(PID);如果有错误发生则返回-1,并将失败原因存于errno中。
如果参数status的值不是NULL,wait就会把子进程退出时的状态取出并存入其中,这是一个整数值(int),指出了子进程是正常退出还是被非正常结束的,以及正常结束时的返回值或被哪一个信号结束的等信息。由于这些信息被存放在一个整数的不同二进制位中,所以用常规的方法读取会非常麻烦,人们就设计了一套专门的宏(macro)来完成这项工作,下面介绍其中最常用的两个:
①WIFEXITED(status),这个宏用来指出子进程是否为正常退出的,如果是,它会返回一个非零值,注意,虽然名字一样,这里的参数status并不同于wait函数中的status参数,而是那个指针所指向的整数,切记不要搞混了;
②WEXITSTATUS(status),当WIFEXITED返回非零值时,可以用这个宏来提取子进程的返回值,如果子进程调用exit(5)退出,WEXITSTATUS(status)就会返回5,如果子进程调用exit(7),WEXITSTATUS(status)就会返回7。请注意,如果进程不是正常退出的,也就是说,WIFEXITED返回0,这个值就毫无意义。
利用WEXITSTATUS获得子进程的返回码。
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(){
/*fork一个子进程*/
pid_t pid = fork();
if(pid<0){
perror("fork error\n");
return 0;
}else if(pid > 0){/*父进程*/
printf("Parent process\n");
int status=-1;
pid_t pr=wait(&status);
if(WIFEXITED(status)){
printf("the child process %d exit normally.\n",pr);
printf("the return code is %d.\n",WEXITSTATUS(status));
}else{
printf("the child process %d exit abnormally.\n",pr);
}
}else if(pid == 0){
printf("Sub-process, PID: %u, PPID: %u\n", getpid(), getppid());
exit(3);
}
return 0;
}
(2)waitpid函数、
从本质上讲,waitpid是wait的封装,waitpid只是多出了两个可由用户控制的参数pid和options,为编程提供了灵活性。
函数原型:
pid_t waitpid(pid_t pid,int * status,int options);
waitpid()会暂时停止目前进程的执行,直到有信号来到或子进程结束。如果在调用waitpid()时子进程已经结束,则waitpid()会立即返回子进程结束状态值。子进程的结束状态值会由参数status返回,而子进程的进程识别码也会一起返回。如果不在意结束状态值,则参数status可以设成NULL。
a.参数pid为欲等待的子进程识别码,其他数值意义如下所述。
1)pid<-1:等待进程组识别码为pid绝对值的任何子进程。
2)pid=-1:等待任何子进程,相当于wait。
3)pid=0:等待进程组识别码与目前进程相同的任何子进程。
4)pid>0:等待任何子进程识别码为pid的子进程。
b.参数options的值有以下几种类型。
1)options=WNOHANG,即使没有子进程退出,它也会立即返回,不会像wait那样永远等下去。
2)options=WUNTRACED,则子进程进入暂停则马上返回,但结束状态不予以理会。
Linux中只支持WNOHANG和WUNTRACED两个选项,这是两个常数,可以用“|”运算符把它们连接起来使用,比如:
ret=waitpid(-1,NULL,WNOHANG | WUNTRACED);
如果不想使用它们,也可以把options设为0,如:
ret=waitpid(-1,NULL,0);
c.waitpid的返回值如下所述。
1)当正常返回的时候waitpid返回收集到的子进程的进程ID。
2)如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
3)如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。
4)当pid所指示的子进程不存在,或此进程存在,但不是调用进程的子进程,waitpid就会出错返回,这时errno被设置为ECHILD。
使用waitpid函数收集子进程信息。
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(){
pid_t pid, pr;
pid=fork();
if(pid<0) /* 如果fork出错 */
printf("Error occured on forking.\n");
else if(pid==0){ /* 如果是子进程 */
printf("Sub process will sleep for 10 seconds.\n")
sleep(10); /* 睡眠10秒 */
exit(0);
}else if(pid>0){
/* 如果是父进程 */
do{
pr=waitpid(pid, NULL, WNOHANG);
/* 使用了WNOHANG参数,waitpid不会在这里等待 */
if(pr==0){ /* 如果没有收集到子进程 */
printf("No child exited\n");
sleep(1);
}
}while(pr==0); /* 没有收集到子进程,就回去继续尝试 */
if(pr==pid)
printf("successfully get child %d\n", pr);
else
printf("some error occured\n");
}
return 0;
}
3.守护进程
守护进程是脱离于终端并且在后台运行的进程。守护进程脱离于终端是为了避免进程在执行过程中的信息在任何终端上显示并且进程也不会被任何终端所产生的终端信息所打断。
守护进程常常在系统引导装入时启动,在系统关闭时终止。Linux系统有很多守护进程,大多数服务都是通过守护进程实现的,同时,守护进程还能完成许多系统任务,例如作业规划进程crond、打印进程lqd等(这里的结尾字母d就是Daemon的意思)。
创建一个简单的守护进程的步骤如下所述。
(1)创建子进程,父进程退出。
这是编写守护进程的第一步。由于守护进程是脱离控制终端的,因此完成第一步后就会在Shell终端里造成一程序已经运行完毕的假象。之后的所有工作都在子进程中完成,而用户在Shell终端里则可以执行其他命令,从而在形式上做到了与控制终端的脱离。
在Linux中如果父进程先于子进程退出会造成子进程成为孤儿进程,而每当系统发现一个孤儿进程时,就会自动由1号进程(init)收养它,这样,原先的子进程就会变成init进程的子进程。
(2)在子进程中创建新会话。
这个步骤是创建守护进程中最重要的一步,虽然它的实现非常简单,但它的意义却非常重大。在这里使用的是系统函数setsid,在具体介绍setsid之前,首先要了解两个概念:进程组和会话期。
1)进程组:是一个或多个进程的集合。进程组由进程组ID来唯一标识,除了进程号(PID)之外,进程组ID也是一个进程的必备属性。每个进程组都有一个组长进程,其组长进程的进程号等于进程组ID,且该进程组ID不会因组长进程的退出而受到影响。
2)会话周期:会话期是一个或多个进程组的集合。通常,一个会话开始与用户登录,终止于用户退出,在此期间该用户运行的所有进程都属于这个会话期。
setsid函数用于创建一个新的会话,并担任该会话组的组长。调用setsid有3个作用:
①让进程摆脱原会话的控制;
②让进程摆脱原进程组的控制;
③让进程摆脱原控制终端的控制。
那么,在创建守护进程时为什么要调用setsid函数呢?由于创建守护进程的第一步调用了fork函数来创建子进程,再将父进程退出。由于在调用了fork函数时,子进程全盘拷贝了父进程的会话期、进程组、控制终端等,虽然父进程退出了,但会话期、进程组、控制终端等并没有改变,因此,还不是真正意义上的独立。而setsid函数能够使进程完全独立出来,从而摆脱其他进程的控制。
(3)改变当前目录为根目录。
这一步也是必要的步骤。使用fork创建的子进程继承了父进程的当前工作目录。由于在进程运行中,当前目录所在的文件系统(如/mnt/usb)是不能卸载的,这对以后的使用会造成诸多的麻烦(比如系统由于某种原因要进入用户模式,但无法实现)。因此,通常的做法是让"/"作为守护进程的当前工作目录,这样就可以避免上述的问题。当然,如有特殊需要,也可以把当前工作目录换成其他的路径。
(4)重设文件权限掩码。
文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有个文件权限掩码是050,它就屏蔽了文件组拥有者的可读与可执行权限。由于使用fork函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为0,可以大大增强该守护进程的灵活性。设置文件权限掩码的函数是umask,通常的使用方法为umask(0)。
(5)关闭文件描述符。
同文件权限码一样,用fork函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法结束。
在上面的第二步之后,守护进程已经与所属的控制终端失去了联系。因此从终端输入的字符不可能达到守护进程,守护进程中用常规方法输出的字符(如printf)也不可能在终端上显示出来。所以,文件描述符为0、1和2的3个文件(常说的输入、输出和报错)已经失去了存在的价值,也应被关闭。通常按如下方式关闭文件描述符:
for(i=0;i<MAXFILE;i++)
close(i);
这样,一个简单的守护进程就建立起来了。
实现一个守护进程的完整实例(每隔10s在/tmp/dameon.log中写入一句话)。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<fcntl.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<sys/stat.h>
#define MAXFILE 65535
int main(){
pid_t pc;
int i,fd,len;
char *buf="this is a Dameon\n";
len = strlen(buf);
pc = fork(); /*第一步*/
if(pc<0){
printf("error fork\n");
exit(1);
}else if(pc>0){
exit(0);
}
setsid(); /*第二步*/
chdir("/"); /*第三步*/
umask(0); /*第四步*/
for(i=0;i<MAXFILE;i++) /*第五步*/
close(i);
while(1){
if((fd=open("/tmp/dameon.log",O_CREAT|O_WRONLY|O_APPEND,0600))<0){
perror("open");
exit(1);
}
write(fd,buf,len+1);
close(fd);
sleep(10);
}
return 0;
}
执行编译后的目标文件,发现它好像执行一下就退出了。这时,执行ps -ef | grep dameon.out命令,发现它还在运行,只是躲到后台去运行了。再去查看/tmp/dameon.log文件,还在每隔10s就打印一行“this is a Dameon”