1.进程控制
1)fork函数:
fork
函数的特点概括起来就是“调用一次,返回两次”,在父进程中调用一次,在父进程和子进程中各返回一次。从上图可以看出,一开始是一个控制流程,调用fork
之后发生了分叉,变成两个控制流程,这也就是“fork”(分叉)这个名字的由来了。子进程中fork
的返回值是0,而父进程中fork
的返回值则是子进程的id(从根本上说fork
是从内核返回的,内核自有办法让父进程和子进程返回不同的值),这样当fork
函数返回后,程序员可以根据返回值的不同让父进程和子进程执行不同的代码。
fork
的返回值这样规定是有道理的。fork
在子进程中返回0,子进程仍可以调用getpid
函数得到自己的进程id,也可以调用getppid
函数得到父进程的id。在父进程中用getpid
可以得到自己的进程id,然而要想得到子进程的id,只有将fork
的返回值记录下来,别无它法。
fork
的另一个特性是所有由父进程打开的描述符都被复制到子进程中。父、子进程中相同编号的文件描述符在内核中指向同一个file
结构体,也就是说,file
结构体的引用计数要增加。
2)exec函数:
用
fork
创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec
函数以执行另一个程序。当进程调用一种exec
函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec
并不创建新进程,所以调用exec
前后该进程的id并未改变。由于exec
函数只有错误返回值,只要返回了一定是出错了,所以不需要判断它的返回值,直接在后面调用perror
即可。
3)wait 和wait_pid函数:
一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用
wait
或waitpid
获取这些信息,然后彻底清除掉这个进程。如果一个进程已经终止,但是它的父进程尚未调用wait
或waitpid
对它进行清理,这时的进程状态称为僵尸(Zombie)进程。僵尸进程是不能用kill
命令清除掉的,因为kill
命令只是用来终止进程的,而僵尸进程已经终止了。
如果一个父进程终止,而它的子进程还存在(这些子进程或者仍在运行,或者已经是僵尸进程了),则这些子进程的父进程改为
init
进程。init
是系统中的一个特殊进程,通常程序文件是/sbin/init
,进程id是1,在系统启动时负责启动各种系统服务,之后就负责清理子进程,只要有子进程终止,init
就会调用wait
函数清理它。
wait
和waitpid
函数的原型是:
#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
或waitpid
时可能会:
阻塞(如果它的所有子进程都还在运行)。
带子进程的终止信息立即返回(如果一个子进程已终止,正等待父进程读取其终止信息)。
出错立即返回(如果它没有任何子进程)。
这两个函数的区别是:
如果父进程的所有子进程都还在运行,调用
wait
将使父进程阻塞,而调用waitpid
时如果在options
参数中指定WNOHANG
可以使父进程不阻塞而立即返回0。
wait
等待第一个终止的子进程,而waitpid
可以通过pid
参数指定等待哪一个子进程。可见,调用
wait
和waitpid
不仅可以获得子进程的终止信息,还可以使父进程阻塞等待子进程终止,起到进程间同步的作用。如果参数status
不是空指针,则子进程的终止信息通过这个参数传出,如果只是为了同步而不关心子进程的终止信息,可以将status
参数指定为NULL
。
2.进程间通信
每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)。如下图所示。
图 30.6. 进程间通信
管道是一种最基本的IPC机制,由pipe
函数创建:
#include <unistd.h>
int pipe(int filedes[2]);
调用
pipe
函数时在内核中开辟一块缓冲区(称为管道)用于通信,它有一个读端一个写端,然后通过filedes
参数传出给用户程序两个文件描述符,filedes[0]
指向管道的读端,filedes[1]
指向管道的写端。所以管道在用户程序看起来就像一个打开的文件,通过read(filedes[0]);
或者write(filedes[1]);
向这个文件读写数据其实是在读写内核缓冲区。pipe
函数调用成功返回0,调用失败返回-1。
开辟了管道之后如何实现两个进程间的通信呢?比如可以按下面的步骤通信(下图出自[APUE2e])。
图 30.7. 管道
父进程调用
pipe
开辟管道,得到两个文件描述符指向管道的两端。父进程调用
fork
创建子进程,那么子进程也有两个文件描述符指向同一管道。父进程关闭管道读端,子进程关闭管道写端。父进程可以往管道里写,子进程可以从管道里读,管道是用环形队列实现的,数据从写端流入从读端流出,这样就实现了进程间通信。
例 30.7. 管道
#include <stdlib.h>
#include <unistd.h>
#define MAXLINE 80
int main(void)
{
int n;
int fd[2];
pid_t pid;
char line[MAXLINE];
if (pipe(fd) < 0) {
perror("pipe");
exit(1);
}
if ((pid = fork()) < 0) {
perror("fork");
exit(1);
}
if (pid > 0) { /* parent */
close(fd[0]);
write(fd[1], "hello world\n", 12);
wait(NULL);
} else { /* child */
close(fd[1]);
n = read(fd[0], line, MAXLINE);
write(STDOUT_FILENO, line, n);
}
return 0;
}
使用管道有一些限制:
两个进程通过一个管道只能实现单向通信,比如上面的例子,父进程写子进程读,如果有时候也需要子进程写父进程读,就必须另开一个管道。请读者思考,如果只开一个管道,但是父进程不关闭读端,子进程也不关闭写端,双方都有读端和写端,为什么不能实现双向通信?
管道的读写端通过打开的文件描述符来传递,因此要通信的两个进程必须从它们的公共祖先那里继承管道文件描述符。上面的例子是父进程把文件描述符传给子进程之后父子进程之间通信,也可以父进程
fork
两次,把文件描述符传给两个子进程,然后两个子进程之间通信,总之需要通过fork
传递文件描述符使两个进程都能访问同一管道,它们才能通信。
使用管道需要注意以下4种特殊情况(假设都是阻塞I/O操作,没有设置O_NONBLOCK
标志):
如果所有指向管道写端的文件描述符都关闭了(管道写端的引用计数等于0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次
read
会返回0,就像读到文件末尾一样。如果有指向管道写端的文件描述符没关闭(管道写端的引用计数大于0),而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次
read
会阻塞,直到管道中有数据可读了才读取数据并返回。如果所有指向管道读端的文件描述符都关闭了(管道读端的引用计数等于0),这时有进程向管道的写端
write
,那么该进程会收到信号SIGPIPE
,通常会导致进程异常终止。在第 33 章 信号会讲到怎样使SIGPIPE
信号不终止进程。如果有指向管道读端的文件描述符没关闭(管道读端的引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再次
write
会阻塞,直到管道中有空位置了才写入数据并返回。
管道的这四种特殊情况具有普遍意义。在第 37 章 socket编程要讲的TCP socket也具有管道的这些特性。
进程间通信必须通过内核提供的通道,而且必须有一种办法在进程中标识内核提供的某个通道,上一节讲的管道是用打开的文件描述符来标识的。如果要互相通信的几个进程没有从公共祖先那里继承文件描述符,它们怎么通信呢?内核提供一条通道不成问题,问题是如何标识这条通道才能使各进程都可以访问它?文件系统中的路径名是全局的,各进程都可以访问,因此可以用文件系统中的路径名来标识一个IPC通道。
FIFO和Unix Domain Socket这两种IPC机制都是利用文件系统中的特殊文件来标识的。可以用mkfifo
命令创建一个FIFO文件:
$ mkfifo hello
$ ls -l hello
prw-r--r-- 1 djkings djkings 0 2008-10-30 10:44 hello
FIFO文件在磁盘上没有数据块,仅用来标识内核中的一条通道,各进程可以打开这个文件进行
read
/write
,实际上是在读写内核通道(根本原因在于这个file
结构体所指向的read
、write
函数和常规文件不一样),这样就实现了进程间通信。Unix Domain Socket和FIFO的原理类似,也需要一个特殊的socket文件来标识内核中的通道,例如/var/run
目录下有很多系统服务的socket文件:
$ ls -l /var/run/
total 52
srw-rw-rw- 1 root root 0 2008-10-30 00:24 acpid.socket
......
srw-rw-rw- 1 root root 0 2008-10-30 00:25 gdm_socket
......
srw-rw-rw- 1 root root 0 2008-10-30 00:24 sdp
......
srwxr-xr-x 1 root root 0 2008-10-30 00:42 synaptic.socket
文件类型s表示socket,这些文件在磁盘上也没有数据块。UNIX Domain Socket是目前最广泛使用的IPC机制,到后面讲socket编程时再详细介绍。
现在把进程之间传递信息的各种途径(包括各种IPC机制)总结如下:
父进程通过
fork
可以将打开文件的描述符传递给子进程子进程结束时,父进程调用
wait
可以得到子进程的终止信息几个进程可以在文件系统中读写某个共享文件,也可以通过给文件加锁来实现进程间同步
进程之间互发信号,一般使用
SIGUSR1
和SIGUSR2
实现用户自定义功能管道
FIFO
mmap函数,几个进程可以映射同一内存区
SYS V IPC,以前的SYS V UNIX系统实现的IPC机制,包括消息队列、信号量和共享内存,现在已经基本废弃
UNIX Domain Socket,目前最广泛使用的IPC机制