UNIX-like 操作系统,有一个强劲的功能:可以同时运行多个进程,并且让进程们共享 CPU,内存,和其他的资源.我们所开发的程序,往往会从一个进程演变为多个进程.的确,许多时候线程是不错的选择,不过多进程和多线程使用有许多是相通的:如何启动和停止进程,如何在进程间通信,如何同步进程.
什么是 UNIX 进程?
谈论进程前,需要正确的理解"什么是进程":
进程是一个正在运行的程序实例,执行给定的代码,有自己的"执行栈内存",有自己的"内存页集合",有自己的"文件描述符号码表",有一个唯一的进程号码.
从这个定义可以知道,一个进程并不代表一个程序,几个进程可能是同一时刻同一个程序的多个实例,他们的用户可能是相同的,也可能是不同的.
使用系统调用 fork() 创建进程
系统调用 pid_t fork(void)
用来创建新的进程.这是一个非常奇特的调用,返回两次!听起来很奇怪.首先,在一个正在运行的进程中调用 fork()
,会创建一个新进程,新进程是调用进程的子进程.当 fork()
时,调用进程的所有正在使用的"内存页"会被复制,调用进程正在使用的"文件描述符号码表"也会被复制,这些复制被放入新进程中使用.因此,新进程和调用进程的这些内容变成相互独立的.
注意:
"写时复制",许多现代系统采用了这些技术.当
fork()
时,系统并不立刻复制"内存页"和"文件描述符号码表",只是在新进程中做一个映射,这样会节省很多复制开销.当新进程中有代码修改"内存页"和"文件描述符号码表"时,系统会立刻复制一个副本,用于修改.
还有一个需要注意的:
"文件描述符号码表":操作系统每打开一个文件,会在内核缓存中保存文件资源,并为每一个文件建立一些散列表,记录文件信息,同时为开发者提供一个非负整数的号码(文件表的索引),用于引用内存中的文件资源.因此,多个进程的"文件描述符号码表"虽然是独立的,但是号码指向的文件却可能是同一个文件,对同一个文件的修改是可以影响到多个进程.由此,出现了另一项技术:"同步",防止多进程同时修改文件资源.
fork()
返回两次,返回值有 3 种情况:
- 如果返回的是 0 ,那么返回值位于新进程中
- 如果返回的是 >0,那么返回值位于调用进程中,返回值是新进程的进程号码.返回值
pid_t
定义在sys/types.h
头文件中,通常是一个整数 - 如果返回的是 -1,那么返回值位于调用进程中,创建新进程失败
例子:
#include
#include
#define MAX_COUNT 200
void doChild(void);
void doParent(void);
void main(void) {
pid_t pid = fork();
switch (pid) {
case -1:
perror("Counld not fork");
exit(1);
case 0:
doChild();
exit(0);
default:
doParent();
}
}
void doChild(void) {
int i;
for (i = 1; i <= MAX_COUNT; i++)
printf("This line is from child, value = %d\n", i);
printf("*** Child process is done ***\n");
}
void doParent(void) {
int i;
for (i = 1; i <= MAX_COUNT; i++)
printf("This line is from parent, value = %d\n", i);
printf("*** Parent is done ***\n");
}
对于这个例子,我准备了一个 Nim 语言版本的:
import posix, os
const maxCount = 200
proc doChild()
proc doParent()
proc main() =
var pid = fork()
case pid
of -1:
raise newException(OsError, osLastError().osErrorMsg())
of 0:
doChild()
quit(QuitSuccess)
else:
doParent()
proc doChild() =
for i in 1..maxCount:
echo "This line is from child, value = ", i
echo "*** Child process is done ***"
proc doParent() =
for i in 1..maxCount:
echo "This line is from parent, value = ", i
echo "*** Parent process is done ***"
main()
子进程结束
一旦创建了一个新进程,其就成为调用进程的子进程.有两种可能存在的情况:父进程先于子进程退出,子进程先于父进程退出:
当一个子进程退出时,并不立刻清空进程表,而是向父进程发送一个信号.父进程需要对此应答,然后系统会完全清除子进程.假设父进程没有应答,或者应答之前子进程退出,子进程会被系统设置为"僵尸"状态.
当一个父进程退出时,如果有几个子进程仍在运行,这些子进程会变成"孤儿进程"."孤儿进程"会立刻被 "init" 超级进程接管,作为其父进程."init" 进程能够确保这些子进程在退出时不会变为"僵尸进程",因为 "init" 进程总是应答子进程的退出.("init" 进程是随操作系统启动的第一个进程,进程号码是 1)
使用系统调用 wait() 应答子进程
应答子进程的简单方法,是使用系统调用 pid_t wait(int * status)
.当调用这个函数时,有以下可能:
- 进程中没有子进程,立刻返回
- 进程中的子进程已经退出,并且是"僵尸"状态,立刻应答并返回,取得子进程状态(通过参数)
- 进程中的子进程都在运行,立刻阻塞,直到有一个子进程退出或者收到一个 "SIGCHILD" 信号时返回,取得子进程状态(通过参数)
例子:
#include
#include
#define MAX_COUNT 200
void doChild(void);
void doParent(void);
void main(void) {
pid_t pid = fork();
switch (pid) {
case -1:
perror("Counld not fork");
exit(1);
case 0:
doChild();
exit(0);
default:
doParent();
}
int status;
pid = wait(&status);
printf("*** Parent detects process %d is done ***\n", pid);
printf("*** Parent exits ***\n");
exit(0);
}
void doChild(void) {
int i;
for (i = 1; i <= MAX_COUNT; i++)
printf("This line is from child, value = %d\n", i);
printf("*** Child process is done ***\n");
}
void doParent(void) {
int i;
for (i = 1; i <= MAX_COUNT; i++)
printf("This line is from parent, value = %d\n", i);
printf("*** Parent is done ***\n");
}
注:当一个进程正常或者异常结束时,内核就向其父进程发送 "SIGCHILD" 信号.子进程结束是一个异步事件,(可以在父进程运行的任何时候发生),所以这种信号由内核向父进程发送异步通知.父进程可以选择忽略该信号,也可以提供一个信号处理函数:
#include
#include
#include
#define MAX_COUNT 200
void catchChild(int sigNumber);
void doChild(void);
void doParent(void);
void main(void) {
signal(SIGCHLD, catchChild);
pid_t pid = fork();
switch (pid) {
case -1:
perror("Counld not fork");
exit(1);
case 0:
doChild();
exit(0);
default:
doParent();
}
sleep(1);
printf("*** Parent exits ***\n");
exit(0);
}
void catchChild(int sigNumber) {
int childStatus;
pid_t pid = wait(&childStatus);
printf("*** Parent detects process %d is done ***\n", pid);
}
void doChild(void) {
printf("*** Child process is done ***\n");
}
void doParent(void) {
printf("*** Parent is done ***\n");
}
也可以:
#include
#include
#include
#define MAX_COUNT 200
void catchChild(int sigNumber);
void doChild(void);
void doParent(void);
void main(void) {
struct sigaction action;
struct sigaction oaction;
action.sa_flags = 0;
action.sa_handler = catchChild;
sigaction(SIGCHLD, &action, &oaction);
pid_t pid = fork();
switch (pid) {
case -1:
perror("Counld not fork");
exit(1);
case 0:
doChild();
exit(0);
default:
doParent();
}
sleep(1);
printf("*** Parent exits ***\n");
exit(0);
}
void catchChild(int sigNumber) {
int childStatus;
pid_t pid = wait(&childStatus);
printf("*** Parent detects process %d is done ***\n", pid);
}
void doChild(void) {
printf("*** Child process is done ***\n");
}
void doParent(void) {
printf("*** Parent is done ***\n");
}
使用系统调用 exec() 执行程序
有时候,创建一个子进程,但是并不想运行和父进程同样的程序.这时候可以使用 exec
家族执行另一个二进制文件.exec
家族函数包括: execl()
,execlp()
,execle()
,execv()
,execvp()
,execve()
.这里介绍一下 int execvp(const char *file, char *const argv[])
.
- 第一个参数是一个字符串,表示可执行文件的路径
- 第二个参数是传递给可执行程序的参数,同
int main(int argc, char **argv)
中的argv
.
当 execvp()
调用时,给出的二进制程序文件会被加载进内存,该子进程 fork()
出来的所有"内存页"和"文件描述符号码表"都会清空.子进程的进程号码不变.然后子进程运行新的二进制程序.
如果 execvp()
执行程序成功,该函数不会返回,其下面的代码不会继续运行,因为已经转入新的程序中.如果执行程序失败,返回 -1.
#include
#include
#include
#define MAX_COUNT 200
void doChild(char **argv);
void doParent(void);
void main(int argc, char **argv) {
pid_t pid = fork();
switch (pid) {
case -1:
perror("Counld not fork");
exit(1);
case 0:
doChild(argv);
exit(0);
default:
doParent();
}
int status;
pid = wait(&status);
printf("*** Parent detects process %d is done ***\n", pid);
printf("*** Parent exits ***\n");
exit(0);
}
void doChild(char **argv) {
execvp(argv[1], NULL);
printf("*** Child process is done ***\n");
}
void doParent(void) {
printf("*** Parent is done ***\n");
}
使用系统调用 pipe() 创建管道,在进程间通信
一旦创建了多个进程后,我们突然意识到没有办法在进程之间通信.在多进程的程序中,我们常常需要进程之间共同完成一些任务.在父-子进程这样的关系进程,可以使用管道(匿名管道)进行通信.
系统调用 int pipe(int fds[2])
,创建一对管道,第一个用于读,第二个用于写,返回两个对应的文件描述符号码.
管道非常受限:
- 每一个都是单向的,要么只能读,要么只能写
- 管道只能在有父-子关系的进程中使用,不相关的进程之间无法使用.
管道也有一些优点:.
- 管道中传输的是字节流,写入的顺序和读取的顺序是一致的
- 管道中不会丢失数据,除非读取端提前关闭读取管道
#include
#include
#define BUFFSIZE 6
void doChild(int pipefds[2]);
void doParent(int pipefds[2]);
void main(void) {
int pipefds[2];
pipe(pipefds);
pid_t pid = fork();
switch (pid) {
case -1:
perror("Counld not fork");
exit(1);
case 0:
doChild(pipefds);
exit(0);
default:
doParent(pipefds);
}
int status;
pid = wait(&status);
printf("*** Parent detects process %d is done ***\n", pid);
printf("*** Parent exits ***\n");
exit(0);
}
void doChild(int pipefds[2]) {
close(pipefds[1]);
char buff[BUFFSIZE];
read(pipefds[0], buff, sizeof(buff));
printf("Recv %s.\n", buff);
printf("*** Child process is done ***\n");
}
void doParent(int pipefds[2]) {
close(pipefds[0]);
char buff[BUFFSIZE] = "Hello";
write(pipefds[1], buff, sizeof(buff));
printf("*** Parent is done ***\n");
}
如果 pipe()
调用成功,将会创建一对管道.pipefds[0] 用于读取,pipefds[1] 用于写入.我们首先使用 pipe()
创建了管道,然后调用 fork()
创建一个子进程,fork()
会把 pipe()
创建的管道描述符做一份复制给新进程.因此,在新进程(子进程)也存在同样的"文件描述符号码表",他们都指向操作系统内核缓冲中存放的管道文件资源.另外,我们在父子进程分别关闭了不需要的"文件描述符号码表",虽然不是必须的,但是"打开的文件"是有上限的,尽可能的关闭不使用的总是好的.
close()
只会减少内核缓冲中保存的"文件资源"的引用计数,而不是真的关闭"文件资源".当引用计数变为 0 的时候,才会真正关闭"文件资源".同样的,每次 open()
或者以其他方式打开一个"文件资源"时,也会把引用计数加 1.
从上面的代码也可以看出,管道实际上是属于文件性质,对管道的操作跟对文件的操作是相同的.这意味着你也可以把管道设置为非阻塞的(请关注我的后续文字: POSIX 程序设计 __ IO),可以使用文件操作的常用函数.
在更复杂的程序中,你可能需要一个双向通信的管道,那么你必须建立两对管道:每一个进程使用两个端进行读和写.这时候你必须要小心,因为这样使用管道很容易"死锁".我们没有用锁,怎么会出现"死锁"?事实上,这种情况只是跟"死锁"相同的表现.
进程 A 进程 B
pipefds_a[1] ----------> pipefds_a[0]
pipefds_b[0] <---------- pipefds_b[1]
比如在上图中,进程 A 和进程 B 分别通过一对管道进行读写.
假设进程 A 使用 pipefds_b[0,阻塞在读取时,进程 B 也使用 pipefds_b[1] 阻塞在读取.这时候,两边都没有字节流发送,进程 A 和进程 B 就会一直阻塞在那里,什么都不做了.
假设进程 A 使用 pipefds_a[1] 写入.基于字节流的读写往往都是基于内核缓冲区,管道也不例外.当创建管道的时候,内核同时为管道分配了一块内存缓冲,管道写入的时候会写入到这块内核缓冲区上,管道读取的时候也会从这块内核缓冲区读走数据.如果写入的数据很大,内核缓冲区已经填满了,那么进程 A 就会阻塞在那,等待内核缓冲区清空,继续写入.然而此时,进程 B 并没有读取数据,也在使用 pipefds_b[1] 写入管道的内核缓冲区,也阻塞在那里.这样,两个进程也同时阻塞,什么都不做了.
建议:可能的话,把管道读写设置为非阻塞的.
使用系统调用 mkfifo() 创建命名管道,在进程间通信
管道 pipe()
有一个限制:只能在父-子进程之间使用(祖父-孙子也可以).在不相干的进程之间通信,可以使用命名管道 FIFO.int mkfifo(const char * filename, mode_t mode)
打开一个文件,然后进行写入数据,写入完毕关闭,另外一个进程就可以打开同一个文件进行读取数据.
说白了,管道是使用内存交换数据,命名管道使用一个实际的文件交换数据.操作一个命名管道和操作一个普通文件非常相似.另外,命名管道不能够同时读和写,要么读,要么写,每一个操作只能等待另一个的完成.因为是对文件的操作,需要有文件的读写权限.
从一个命名管道读取时,如果没有数据,会阻塞,而不是立刻返回 EOF(这与普通文件的读不同).当命名管道写入时,如果内核缓冲区塞满了,但是没有消费者(读取),进程会阻塞在那,直到内核缓冲区的数据被读取清空.
建议:可能的话,把命名管道读写设置为非阻塞的.
#include
#include
#include
#include
#define BUFFSIZE 6
void doChild(void);
void doParent(void);
void main(void) {
pid_t pid = fork();
switch (pid) {
case -1:
perror("Counld not fork");
exit(1);
case 0:
doChild();
exit(0);
default:
doParent();
}
int status;
pid = wait(&status);
printf("*** Parent detects process %d is done ***\n", pid);
printf("*** Parent exits ***\n");
exit(0);
}
void doChild(void) {
mkfifo("./fifo_file", S_IFIFO | 0666);
char buff[BUFFSIZE];
int fd = open("./fifo_file", O_RDONLY);
read(fd, buff, sizeof(buff));
printf("Recv %s.\n", buff);
printf("*** Child process is done ***\n");
}
void doParent(void) {
mkfifo("./fifo_file", S_IFIFO | 0666);
char buff[BUFFSIZE] = "Hello";
int fd = open("./fifo_file", O_WRONLY);
write(fd, buff, sizeof(buff));
printf("Write %s.\n", buff);
printf("*** Parent is done ***\n");
}
使用系统调用 socket() 创建套接字,在进程间通信
这是一个广博的话题,应该占用单独一个章节.
这里我们单独介绍一下 UNIX Domain Socket,这是一个非常好用的通信方式,跟管道是非常相似,甚至工作方式接近相同,但是拥有非常棒的优点:进程之间可以是不相关的,通信方式是双向的(管道是单向的).
我们可以使用普通的 int socket(int domain, int type, int protocol)
系统调用,并设置 domain 为 AF_UNIX
,采用本机通信来建立本机多进程之间的通信.不过,UNIX 系统为这种场景提供了一个更简便可行的方案:int socketpair(int domain, int type, int protocol, int sockfd[2])
.
使用 socketpair()
,会创建一对管道,每一端都可以读写,是双向的,跨进程的.除此之外,跟管道是相同的工作方式.
建议:对于进程间通信,这是一个非常效率稳定的方式,你也可以将其设置为非阻塞的.
#include
#include
#include
#include
#define BUFFSIZE 6
void doChild(int pipefds[2]);
void doParent(int pipefds[2]);
void main(void) {
int pipefds[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, pipefds);
pid_t pid = fork();
switch (pid) {
case -1:
perror("Counld not fork");
exit(1);
case 0:
doChild(pipefds);
exit(0);
default:
doParent(pipefds);
}
int status;
pid = wait(&status);
printf("*** Parent detects process %d is done ***\n", pid);
printf("*** Parent exits ***\n");
exit(0);
}
void doChild(int pipefds[2]) {
close(pipefds[1]);
char buff[BUFFSIZE];
read(pipefds[0], buff, sizeof(buff));
printf("Child recv %s.\n", buff);
char buff2[BUFFSIZE] = "OK";
write(pipefds[0], buff2, sizeof(buff2));
printf("Child send %s.\n", buff2);
printf("*** Child process is done ***\n");
}
void doParent(int pipefds[2]) {
close(pipefds[0]);
char buff[BUFFSIZE] = "Hello";
write(pipefds[1], buff, sizeof(buff));
printf("Parent send %s.\n", buff);
char buff2[BUFFSIZE];
read(pipefds[1], buff2, sizeof(buff2));
printf("Parent recv %s.\n", buff2);
printf("*** Parent is done ***\n");
}
使用系统调用 mmap() 创建内存映射,在进程间通信
系统调用 void *mmap(void *addr, size_t length, int port, int flags, int fd, off_t offset)
在虚拟内存空间创建一个内存映射,映射的内存可以与其他进程共享:
- 映射同一个文件同一个区域,共享相同的"物理内存页"
- 通过
fork()
创建的子进程,会复制父进程的"内存页",可以在这些内存页中映射共享内存
#include
#include
#include
#include
#include
#define BUFFSIZE 6
void doChild(void);
void doParent(void);
struct Data {
pthread_mutex_t mutex;
int state;
};
static struct Data *data;
void main(void) {
int fd;
pthread_mutexattr_t mattr;
fd = open("/dev/zero", O_RDWR, 0);
data = (struct Data *)mmap(0, sizeof(struct Data),
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
pthread_mutexattr_init(&mattr);
pthread_mutexattr_setpshared(&mattr, PTHREAD_PROCESS_SHARED);
pthread_mutex_init(&data->mutex, NULL);
data->state = 0;
pid_t pid = fork();
switch (pid) {
case -1:
perror("Counld not fork");
exit(1);
case 0:
doChild();
exit(0);
default:
doParent();
}
int status;
pid = wait(&status);
printf("Parent state %d\n", data->state);
printf("*** Parent detects process %d is done ***\n", pid);
printf("*** Parent exits ***\n");
exit(0);
}
void doChild(void) {
pthread_mutex_lock(&data->mutex);
data->state = 1;
pthread_mutex_unlock(&data->mutex);
printf("*** Child process is done ***\n");
}
void doParent(void) {
printf("*** Parent is done ***\n");
}
在这个程序中,我们首先定义了一个全局共享数据 data
,在后面的代码中使用 mmap()
对其申请内存,并映射内存页.open()
打开了一个 /dev/zero
的设备,当从这个设备读取数据时,获得的是字符 \0
,可以用来初始化一个文件(也可以向这个字符设备写入,字符设备会丢弃数据,什么也不做).然后我们设置共享数据的 state
为 0,初始化线程锁,通过 pthread_mutexattr_setpshared
系统调用设置锁的共享属性是进程共享 PTHREAD_PROCESS_SHARED
.然后调用 fork()
创建子进程,在子进程中修改共享数据 data
的 state
字段为 1.最后在父进程中 wait
等待并应答子进程,打印最后的共享数据 data
的 state
值.你会发现,state
已经变为 1,这表示父进程和子进程确实共享了 data
.
这种数据通信,是基于内存映射的,所以操作非常快.
更多的进程通信
还有一些进程通信方式,包括但不限于:
- 消息队列
- 信号量
- 共享内存(类似于 mmap,但是可移植较差)
友情提示:这些方式无法使用 select()
或者 poll()
,进程等待消息只能通过消息队列,不能通过其他类似通知的方式知道消息到来.除非你没有更好的法子的时候,选择这些通信方式.
常用编程 API
pid_t fork(void)
// 创建一个子进程
pid_t wait(int *status)
// 等待并应答一个随机子进程
pid_t waitpid(pid_t pid, int * status, int options)
// 等待并应答一个特定子进程
int execve(const char * filename, char const *argv, char const *envp)
int execl( const char *path, const char *arg, ...)
int execlp(const char *file, const char *arg, ...)
int execle(const char *path, const char *arg, ..., char * const envp[])
int execv( const char *path, char *const argv[])
int execvp(const char *file, char *const argv[])
// 在一个子进程中,执行一个二进制可执行文件,运行新程序
void exit(int status)
// 退出进程
pid_t getpid(void) // 获取当前进程号码
pid_t getppid(void) // 获取当前进程的父进程号码
pid_t getpgrp(void) // 获取当前进程的进程组号码
pid_t getpgid(pid_t pid) // 获取特定进程的进程组号码
pid_t getsid(pid_t pid) // 获取特定进程的进程组首进程号码
int setpgid(pid_t pid, pid_t pgid)
// 设置特定进程的进程组号码
pid_t setsid(void)
// 使当前进程创建一个新会话,并成为新进程组首进程
uid_t getuid(void) // 获取当前进程的用户号码
uid_t geteuid(void) // 获取当前进程的有效用户号码
gid_t getgid(void) // 获取当前进程的用户组号码
gid_t getegid(void) // 获取当前进程的有效用户组号码
int setuid(uid_t uid) // 设置当前进程的用户号码
int seteuid(uid_t uid) // 设置当前进程的有效用户号码
int setgid(gid_t gid) // 设置当前进程的用户组号码
int setegid(gid_t gid) // 设置当前进程的有效用户组号码
int pipe(int fds[2])
// 创建一对匿名管道
int mkfifo(const char * filename, mode_t mode)
// 创建一对命名管道
int socketpair(int domain, int type, int protocol, int sockfd[2])
// 创建一对本机套接字管道
void *mmap(void *addr, size_t length, int port, int flags, int fd, off_t offset)
// 创建一个内存映射
友情提示:出于演示简洁,本文字中的函数调用,大部分没有检查创建的正确性,实际项目中,应该有严格的检查.