[APUE]进程控制(上)
一、进程标识
进程ID 0是调度进程,常常被称为交换进程(swapper)。该进程并不执行任何磁盘上的程序--它是内核的一部分,因此也被称为系统进程。进程ID 1是init进程,在自举(bootstrapping)过程结束时由内核调用。该进程的程序文件在UNIX的早期版本中是/etc/init,在较新版本中是/sbin/init。此进程负责在内核自举后启动一个UNIX系统。init通常读与系统有关的初始化(/etc/rc*文件),并将系统引导到一个状态(例如多用户)。init进程决不会终止。它是一个普通的用户进程(与交换进程不同,它不是内核中的系统进程),但是它以超级用户特权运行。
在某些UNIX的虚存实现中,进程ID 2是页精灵进程(pagedaemon)。此进程负责支持虚存系统的请页操作。与交换进程一样,页精灵进程也是内核进程。
除了进程ID,每个进程还有其他标识符。下列函数可以返回这些标识符:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
返回: 调用进程的进程ID
pid_t getppid(void);
返回: 调用进程的父进程ID
uid_t getuid(void);
返回: 调用进程的实际用户ID
uid_t geteuid(void);
返回: 调用进程的有效用户ID
gid_t getgid(void);
返回: 调用进程的实际组ID
gid_t getegid(void);
返回: 调用进程的有效阻ID
这些函数都没有出错返回
二、fork函数
一个进程调用fork函数是UNIX内核创建一个新进程的唯一方法(除了交换进程、init进程和页精灵进程)
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
返回: 子进程中为0,父进程中为子进程的进程ID,出错为-1.
子进程和父进程继续执行fork之后的指令。子进程是父进程的复制品。例如,进程获得父进程数据空间、堆和栈的复制品。但是这些都是进程拥有的拷贝不是与父进程共享。如果正文段是只读的,则父、子进程共享正文段。
现在很多实现并不做一个父进程数据段和堆的完全拷贝,因为在fork之后经常跟随着exec。作为替代,使用了在**写时复制(Copy On Write, COW)**的技术。这些区域由父、子进程共享,而且内核将它们的存取权限改成只读的。如果有进程试图修改这些区域,则内核为有关部分(典型的是虚存系统中的"页"),做一个拷贝。
#include <sys/types.h> #include <stdio.h> #inlcude <unistd.h> int glob = 6; char buf[] = "a write to stdout\n"; int main(void) { int var; pid_t pid; var = 88; if (write(STDOUT_FILENO, buf, sizeof(buf) - 1) != sizeof(buf) - 1) { fprintf(stderr, "write error"); } printf("before fork \n"); if ((pid = fork()) < 0) { fprintf(stderr, "fork error"); } else if (pid == 0) { glob++; var++; } else { sleep(2); } printf("pid=%d,glob=%d,var=%d\n", gitpid(), glob, var); return 0; }
编译运行上面的进程:
可以看到当我们将输出重定向到temp.out文件后多出个before fork的输出。write函数是不带缓存的。因为在fork之前调用write,所以其数据写到标准输出一次。但是标准IO是带缓存的。如果标准输出连到终端设备,则它是行缓存,否则它是全缓存。当以交互方式运行该程序时,只得到printf输出的行一次,其原因是标准输出缓存由新行符刷新。当我们将printf("before fork \n");后的换行符去掉之后即printf("before fork");来验证这一点,修改之后输出结果是:
可以看到before fork打印了两次,这说明因为我们去掉了换行符所以标准输出流的行缓存不会被flush。
但是当将标注输出重新定向到一个文件时,却得到printf输出行两次。其原因是,将标准输出重新定向到一个文件时标准输出流就不是行缓存而是全缓存了,在fork之前调用了printf一次,但当调用fork时,该行数据仍在缓存中,然后在父进程数据空间复制到子进程的过程中时,该缓存数据也被复制到了子进程中。于是那时父、子进程各自有了带该行内容的缓存。在exit之前的第二个printf将其数据添加到现存的缓冲中。当每个进程终止时,缓存中的内容将被写到相应文件中。
文件共享 对于上面的程序需要注意:在重定向父进程的标准输出时也重定向了子进程的标准输出。fork的一个特性是所有由父进程打开的文件描述符都被复制到子进程中。父、子进程每个相同的打开文件描述符共享一个文件表项。
这种共享文件的方式使父子进程对同一文件使用了一个文件位移量。对于以下情况:一个进程fork了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父、子进程都向标准输出执行写操作。如果父进程使其标准输出重定向(很可能是由shell实现的),那么子进程写到该标准输出时,他将更新与父进程共享的该文件的位移量。在我们所考虑的例子中,当父进程等待子进程时,子进程写到标准输出;而在子进程终止后,父进程也写入到标准输出上,并且知道其输出会添加在子进程所写数据之后。如果父、子进程不共享同一文件位移量,这种形式的交互就很难实现。[为了理解这一点可参看APUE3.10节]
如果父、子进程写到同一文件描述符文件,但又没有任何形式的同步(例如使父进程等待子进程),那么它们的输出就会相互混合(假定所用的文件描述符是在fork之前打开的)。
在fork之后处理文件描述符有两种常见的情况:
(1) 父进程等待子进程完成。这种情况下,父进程无需对其描述符做任何处理。
(2) 父、子进程各自执行不同的程序段。在这种情况下,在fork之后,父、子进程各自它们不需使用的文件描述符,并且不干扰对方使用的文件描述符。
除了打开文件之外,很多父进程的其他性质也会由子进程继承:
- 实际用户ID、实际组ID、有效用户ID、有效组ID。
- 添加组ID。
- 进程组ID。
- 对话期ID。
- 控制终端。
- 设置-用户-ID标志和设置-组-ID标志。
- 当前工作目录。
- 根目录。
- 文件方式创建屏蔽字。(umask)
- 信号屏蔽和排列。
- 对任一打开文件描述符的在执行时关闭标志。
- 环境。
- 连接的共享存储段。
父、子进程之间的区别是:
- fork的返回值。
- 进程ID
- 不同的父进程iD。
- 子进程的tms_utime,tms_stime,tms_cutime以及tms_ustime设置为0。
- 父进程设置的锁,子进程不继承。
- 子进程的未决告警被清除。
- 子进程的未决信号集设置被清除。
三、vfork函数
vfork函数的调用序列和返回值与fork相同,但两个函数的语义不同。
vfork用于创建一个新进程,而该进程的目的是exec一个新程序。vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit),所以就不会用到此地址空间。
vfork和fork之间的另一个区别是:vfork保证子进程先运行,在它调用exec/exit之后父进程才可能被调度运行。(如果在调用exec/exit之前子进程依赖于父进程的进一步动作,则会导致死锁。) 对于以下示例:
#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>
int glob = 6;
int main(void)
{
int var;
pid_t pid;
var = 88;
printf("before vfork\n");
if ((pid = vfork()) < 0) {
fprintf(stderr, "vfork error\n");
} else if (pid == 0) {
glob++;
var++;
_exit(0);
}
printf("pid=%d,glob=%d,var=%d\n", getpid(), glob, var);
return 0;
}
编译运行该程序:
需要注意在上面的程序我们调用了_exit而不是exit。_exit并不执行IO缓存的刷新操作。如果是调用exit而不是_exit,则该程序的输出是:
(上图是APUE原书, 下图是我在centos上实验结果
之所以结果不同是因为在linux中子进程关闭的是自己的, 虽然他们共享标准输入、标准输出、标准出错等 “打开的文件”, 子进程exit时,也不过是递减一个引用计数,不可能关闭父进程的,所以父进程还是有输出的。
)
可见父进程的printf输出消失了。其原因是子进程调用了exit,它刷新开关闭了所有标准IO流,这包括标准输出。虽然这是由子进程执行的,但却是在父进程的地址空间中进行的,所以所有受到影响的标准IO FILE对象都是在父进程中。当父进程调用prinf时,标准输出已经被关闭了,于是printf返回-1。
四、exit函数
进程有三种正常终止法和两种异常终止法。
(1) 正常终止:
(a) 在main函数内执行return语句,这相当于调用exit。
(b) 调用exit函数。
(c) 调用_exit函数。
(2) 异常终止:
(a) 调用abort。它产生SIGABRT信号,所以是下一种异常终止的特例。
(b) 当进程接收到某个信号时。进程本身(例如调用abort函数)、其他进程和内核都能产生传送到某一进程的信号。例如:进程越出其地址空间访问存储单元,或者除以0,内核都会为该进程产生相应的信号。
对上述任意一种终止情形,我们都希望终止进程能够通知其父进程它是如何终止的。对于e x i t和_ e x i t,这是依靠传递给它们的退出状态( exit status)参数来实现的。在异常终止情况,内核(不是进程本身)产生一个指示其异常终止原因的终止状态( termination status) 。在任意一种情况下,该终止进程的父进程都能用 w a i t或w a i t p i d函数(在下一节说明)取得其终止状态。(退出状态是传给exit/_exit的参数,或main返回值。在最后调用_exit时内核将其退出状态转为终止状态,如果子进程正常终止那父进程可以获取子进程的退出状态)。
一定是一个父进程生成一个子进程。上面又说明了子进程将其终止状态返回给父进程。但是如果父进程在子进程之前终止,则将如何呢 ?其回答是对于其父进程已经终止的所有进程,它们的父进程都改变为init进程。我们称这些进程由init进程领养。其操作过程大致是:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止的进程的子进程,如果是,则该进程的父进程 I D就更改为1 ( i n i t进程的I D )。这种处理方法保证了每个进程有一个父进程。
另一个我们关心的情况是如果子进程在父进程之前终止,那么父进程又如何能在做相应检查时得到子进程的终止状态呢?对此问题的回答是内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用 w a i t或waitpid 时,可以得到有关信息。这种信息至少包括进程I D、该进程的终止状态、以反该进程使用的 C P U时间总量。内核可以释放终止进程所使用的所有存储器,关闭其所有打开文件。在 U N I X术语中,一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程被称为僵死进程。
最后一个要考虑的问题是:一个由i n i t进程领养的进程终止时会发生什么 ?它会不会变成一个僵死进程?对此问题的回答是“否” ,因为i n i t被编写成只要有一个子进程终止, i n i t就会调用一个w a i t函数取得其终止状态。这样也就防止了在系统中有很多僵死进程。当提及“一个 i n i t的子进程”时,这指的是 i n i t直接产生的进程(例如,将在9 . 2节说明的g e t t y进程),或者是其父进程已终止,由init 领养的进程。