本章是UNIX系统中进程控制原语,包括进程创建、执行新程序、进程终止,另外还会对进程的属性加以说明,包括进程ID、实际/有效用户ID。
进程标识每个进程某一时刻在系统中都是独一无二的,它们之间是用一个非负数的唯一ID来表示和区分,虽然是唯一的,但成立条件是某一时刻,进程ID可以在不同时刻复用,当一个进程终止后,其ID就称为复用的候选者,UNIX系统通常会有一个延迟的复用算法,使得新创建的进程ID不同于最近一段时间内终止的进程ID,以避免将新进程误认为是之前已终止的那个进程。
进程ID为1的通常是init进程,它在系统自举结束后由内核创建,该进程的作用正如其名,是用来初始化系统的,它通常会读取系统初始化的一些配置文件,将系统状态引导至初始化状态,init进程是不可能终止的。它是一个root用户进程,而不是内核进程,因此不是内核的一部分。
除了进程ID,每个进程还有一些其他属性,下来函数返回相应的属性,函数及其声明头文件如下:
#include <unistd.h> pid_t getpid(void); // 返回值:调用进程的进程ID pid_t getppid(void); // 返回值:调用进程的父进程ID pid_t getuid(void); // 返回值:调用进程的实际用户ID pid_t geteuid(void); // 返回值:调用进程的有效用户ID pid_t getgid(void); // 返回值:调用进程的实际组ID pid_t getegid(void); // 返回值:调用进程的有效组ID
上述函数全部没有出错返回。
函数fork一个已经存在的进程可以通过调用fork( )函数来向操作系统请求提供服务,通过该服务来产生一个子进程。其头文件及函数原型如下:
#include <unistd.h> pid_t fork(void); // 返回值:子进程返回0,父进程返回子进程ID
该函数会有两个返回值,出错时返回-1,成功时父进程返回新创建子进程的ID,新建子进程则返回0。
之所以要在父进程中返回子进程,是因为除此之外,父进程没有其他方法获得子进程的ID,只能通过这种方法返回,这里所说的没有其他方法指的是操作系统没有提供一个API函数来供父进程获取子进程ID,除非我们人为编程实现。
为什么fork( )函数会返回两次呢?这是因为fork( )函数的工作过程是完全复制一份父进程到新的子进程的虚拟内存空间中,如下图所示:
如上图所示,左边是父进程,父进程占用虚拟内存空间4G(这里以32位系统为例,且不考虑细节),父进程使用fork( )函数请求系统为它创建一个子进程,此时系统完全复制一份父进程的所有虚拟空间的数据到新的子进程的虚拟内存空间,所以就有两个进程需要返回,因此返回两次。上图调用fork那里产生子进程之后两个进程各自返回。
需要说明的是,此时复制是完全复制(一些需要改变的小小细节可以忽略不计),包括数据变量、堆、栈的分配和使用情况,这样复制会出现什么情况呢?下面看一个代码示例:
#include <iostream> #include <unistd.h> using namespace std; void err_sys(const std::string &s) { std::cout << s << std::endl; exit(-1); } int main(int argc, char const *argv[]) { int i = 5; pid_t pid; if ((pid = fork()) < 0 ) { err_sys("fork error."); } else if (pid == 0) { int j = 6; cout << "================================" << endl; cout << "child i address: " << &i << endl; cout << "child j address: " << &j << endl; } else { int j = 7; cout << "================================" << endl; cout << "parent i address: " << &i << endl; cout << "parent j address: " << &j << endl; } return 0; }
上述代码编译后执行结果如下:
从图中可以看到,父子进程的变量i的内存地址完全相同,而变量j的内存地址不同,之所以i是相同的,是因为父子进程都是在操作系统抽象管理的虚拟内存空间中运行的,也即操作系统给每个进程分配一个4G的虚拟内存,都是从0x000000到0xFFFFFF,由于变量i在fork( )函数之前已经分配了内存,而fork( )函数完全复制了父进程,因此变量i也是同样的内存地址,之后变量j是每个进程各自分配的,因此其内存地址根据自己进程当前环境来安排,所以不再相同。
fork( )完全复制父进程,还包括复制缓冲区使用的情况,实际上缓冲区其实是当前进程调用标准IO函数时,标准IO函数在进程的虚拟内存空间中开辟了一块内存用于缓冲。因此fork( )也会复制缓冲区使用情况,如果缓冲区没有被刷新,则缓冲区中的数据也一并被复制过去了。
由于每个进程都是运行在虚拟的内存之中的,所有的虚拟内存都要经过映射,转换到实际的内存,实际上,父进程在使用fork( )函数产生子进程之后,为了效率父子进程的代码区都是映射到同一块内存区域的。
fork产生新的子进程之后,此时就有了2个进程,一父一子,父子进程哪个先运行是不确定的,取决于操作系统的调度算法,如果要求父子进程相互同步,则要进行父子进程间的某种形式的通信。
fork之后父进程和子进程之间打开文件的共享如下:
这里的文件描述符的复制,就像C++语言中的浅复制一样,仅仅复制了一个指针而已,指针所指的对象完全相同。因此父子进程共享文件偏移量,所以如果父子进程都写同一个文件,那么结果会交错,而不会覆盖。
fork( )的作用是复制产生一个新子进程,除了进程数据本身被复制,操作系统还会管理每个进程,操作系统管理每个进程的方式是在内核中为每个进程建立一个对应的PCB(进程控制块),该PCB管理相应的进程,其中包含了进程的一些属性信息,包括但不限于进程ID、进程特征信息、进程状态、优先级、资源分配等等。子进程PCB中的部分信息通常也会从父进程的PCB那里复制一些,比如实际用户ID、会话ID等。
fork产生子进程之后,子进程可以使用exec来执行新的程序,exec之后新的进程仍然和父进程共享同一个文件描述符,因为从上图中最左侧可以看出内核是为进程维护的,也就是说按进程PID来标识,因此即使exec加载了新的程序执行,但因为PID不变,所以和父进程共享文件描述符。
默认操作是在exec后仍然保持描述符打开状态,除非显式用fcntl设置,或者在open打开文件时显式指定。
函数exit进程能正常执行结束退出,也可能未执行完毕异常终止。
第一种情况下exit函数(exit/_exit/_Exit)会获得一个进程main函数的return返回值作为“退出状态”,然后内核将“退出状态”转换为“终止状态”;
第二种情况发生时,由内核为其产生一个“终止状态”。
这两种情况产生的“终止状态”中含有该进程相关的一些信息,比如进程ID、进程终止状态、进程CPU使用情况、有无core dump等信息,其实就是进程的PCB资源仍然存在,需要使用wait函数来对其处理。
孤儿进程和僵尸进程孤儿进程是父进程产生子进程后,父子进程各自做各自的工作,父进程的工作做完后退出执行,而子进程继续在做自己的工作,仍正常执行,此时该子进程就成了无父进程的孤儿进程,但随后该孤儿进程就会被init进程收养,孤儿进程成为init的子进程,init成为孤儿进程的父进程。
僵尸进程是父进程产生子进程后,父子进程各自做各自的工作,子进程做完自己的工作后退出执行或者半途异常终止,这时子进程就会从正常的活跃进程变成一个“僵尸进程”,也就是内核为正常进程结束产生或者为异常终止进程而产生的那个“终止状态”,需要父进程使用wait函数来对其处理。
为什么要产生僵尸进程呢?注意,这里是为什么要产生,而不是为什么会产生。要产生的原因很简单,父进程可能需要了解子进程的退出状态信息,因此系统必须提供这样的服务或者功能。这里也需要注意的是,父进程只是可能需要了解,而不是一定要了解,因此产生僵尸进程的行为应该是允许开发者进行控制的。如果开发者需要进行相应配置,则需借助sigaction( )函数,该函数将在第10章信号中说明。
当一个进程终止时,内核会检查所有的活动进程,也会检查所有的僵尸(书上没有说明这一点)。
对于活跃的进程,如果是正在终止进程的子进程,那么该子进程将被init收养;
对于僵尸进程,如果是正在终止进程的僵尸进程(僵死的子进程),init进程将会对其进行回收。这一点也是在UNIX系统上处理大量僵尸进程的原理。
函数wait和waitpid当子进程终止时,内核会向它的父进程发送一个SIGCHLD信号,该信号是一个异步事件,它可以在任意时刻出现,父进程可以忽略该信号,或者加以捕捉处理。我们可以使用wait或者waitpid函数来处理子进程终止事件。其头文件及函数原型如下:
#include <sys/wait.h> pid_t wait(int *); pid_t waitpid(pid_t, int *, int);
两个函数在成功时返回处理进程的PID,出错则返回-1。waitpid( )函数出错返回值还有可能是0。此种情况是:当设置了WNOHANG选项(不阻塞),并且所要处理的子进程存在,但尚未有子进程需要处理,则返回0,如果没有子进程符合,或者没有子进程,则返回-1。
除了wait和waitpid函数之外,还有waitid( )、wait3( )、wait4( )等函数。
竞争条件竞争条件在第三章的原子操作中有过相关介绍,多个进程对同一个文件读写时,就可能出现读写顺序竞争的问题,对数据的读写取决于进程的访问顺序,这就构成了竞争条件。为了避免竞争,就需要进行进程同步,而进程同步通常是某种形式上的通信。
函数execfork( )函数调用之后,会生成一个新的子进程,子进程完全复制父进程之后,父子进程可以并发协作来提供工作效率。但是如果要执行全新的程序,必须使用exec( )函数来加载。exec( )函数加载的新程序会从main( )函数开始重新执行,并且清空之前进程复制的代码段、数据段、堆、栈,但进程的ID不变。UNIX系统提供了7种不同的exec函数,它们统称为exec函数,其头文件及函数原型如下:
#include <unistd.h> int execl (const char* path, const char* arg, ...); int execv (const char* path, char* const argv[]); int execle (const char* path, const char* arg, ...); int execve (const char* path, char* const argv[], char* const envp[]); int execlp (const char* file, const char* arg, ...); int execvp (const char* file, char* const argv[]); int fexecve (int fd, char* const argv[], char* const envp[]);
上述函数失败时返回-1,成功则不返回,因为进程执行的代码已经改变,包括所有的堆栈信息全部已经替换,是一个新的程序,不会返回。
另外,GNU还扩展了一个exec函数,其头文件及函数原型如下:
#include <unistd.h> int execvpe (const char* file, char* const argv[], char* const envp[]);
它是线程安全的,且使用指定环境变量。
以上exec系列函数中,带l的表示该函数加载的新程序的每个参数都是独立传递的,带v的表示所有参数放在一个数组中,然后将该数组指针传递进去,而带p的则会去系统的path环境变量中搜索,结尾带e的表示会使用指定的环境变量表。对于fexecve( )函数,它使用文件描述符来加载新程序。
exec新程序时,当前进程打开的或者继承的文件描述符将如何处理取决于每个描述符的close_on_exec标志。
解释器文件解释器文件是一种文本文件,该文件的第一行是一个固定的形式: #! pathname [optional-argument],其中的pathname是绝对路径,后跟一个可选的参数。当执行解释器文件时,首先执行pathname这个程序,然后将可选的参数作为第一个参数,解释器文件作为其后的参数。
函数system通过函数system( )可以能够快速使用shell来执行命令,相当于用C/C++程序来调用shell。其头文件及函数原型如下:
#include <stdlib.h> int system (const char* command);
函数失败返回值较多,具体需参考说明手册。
进程调度UNIX系统提供了一个API接口,可以用来粗略调整进程运行优先级。其头文件及函数原型如下:
#include <unistd.h> int nice (int inc);
该函数成功时返回(nice - NZERO),失败则返回-1。由于(nice - NZERO)可能为负值,因此对于-1的返回值,需要判断errno。如果nice参数太大,进程优先级会调整到上限;如果nice参数太小,进程优先级会调整到下限;两者都不会给出任何提示,都是静默行为。
进程时间UNIX中进程可以使用times( )函数来获得自己的日历时间、用户CPU和系统CPU时间,其头文件及函数原型如下:
#include <sys/times.h> clock_t times (struct tms *buffer);
函数成功时返回日历时间,失败则返回-1。需要注意的时,times( )函数返回值类型是clock_t,该类型不是以秒为单位,而是以滴答tick为单位,因此需要做一次转换,要用times( )函数的返回值除以sysconf(_SC_CLK_TCK),此运算得到的值才是秒数,需要注意的是要防止times( )函数返回值过大造成溢出。
习题8.1 在图8-3中,如果用exit调用代替_exit调用,那么可能会使标准输出关闭,使printf返回-1。修改该程序以验证在你所使用的系统上是否产生此种结果。如果并且如此,你怎样处理才能得到类似结果呢?
在我的操作系统(Ubuntu 16.04.02 LTS x86)上,如果用exit调用代替_exit调用不会使标准输出关闭。如果需要使标准输出在子进程退出时关闭,则要显式的关闭stdout,如果关闭了标准输出,则父进程无法直接用printf打印,直接printf函数打印会使得其返回-1以表明出错,可以将printf函数的返回值保存之后写入到文件中以查看。代码如下:
#include <unistd.h> #include <sys/types.h> pid_t vfork(); #include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <sys/stat.h> void err_sys(const char *p) { printf("%s\n", p); exit(-1); } int globvar = 6; /* external variable in initialized data */ int main(void) { int var; /* automatic variable on the stack */ pid_t pid; var = 88; printf("before vfork\n"); /* we don't flush stdio */ if ((pid = vfork()) < 0) { err_sys("vfork error"); } else if (pid == 0) { /* child */ globvar++; /* modify parent's variables */ var++; fclose(stdout); exit(0); /* child terminates */ } /* parent continues here */ int i = printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar, var); int fd = open("return", O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR); char buf[8] = {0}; sprintf(buf, "%d", i); buf[7] = '\n'; write(fd, buf, 8); close(fd); exit(0); }
上述代码中使用sprintf( )函数来转换int到string类型,然后将转换得到的string字符串写入到文件,之后使用命令行来查看文件。之所以需要转换,是因为在UNIX系统中,read和write函数是不区分文本或者二进制的,read和write函数仅对内存二进制字节进行读写,因此我们需要最终将内存字节转换到文本模式再写入到文件。另外,在代码最开始显式为vfork( )函数提供了一个声明以屏蔽gcc/clang编译器发出的莫名其妙的warning,代码编译后执行结果如下:
8.2 回忆图7-6中典型的存储空间布局。由于对应于每个函数调用的栈帧通常存储在栈中,并且由于调用vfork后,子进程运行在父进程的地址空间中,如果不是在main函数中而是在另一个函数中调用vfork,此后子进程又从该函数返回时,将会发生什么?请编写一段测试程序对此进行验证,并且画图说明发生了什么。
vfork( )是个已废弃的函数,不同平台实现可能存在差异,不应关注或者使用它。该接口可能会导致栈帧的覆盖,从而内存错误。
8.3 重写图8-6中的程序,把wait换成waitid。不调用pr_exit,而从siginfo结构中确定等价的信息。