POSIX 程序设计 __ 多进程和进程通信

UNIX-like 操作系统,有一个强劲的功能:可以同时运行多个进程,并且让进程们共享 CPU,内存,和其他的资源.我们所开发的程序,往往会从一个进程演变为多个进程.的确,许多时候线程是不错的选择,不过多进程和多线程使用有许多是相通的:如何启动和停止进程,如何在进程间通信,如何同步进程.

什么是 UNIX 进程?

谈论进程前,需要正确的理解"什么是进程":

进程是一个正在运行的程序实例,执行给定的代码,有自己的"执行栈内存",有自己的"内存页集合",有自己的"文件描述符号码表",有一个唯一的进程号码.

从这个定义可以知道,一个进程并不代表一个程序,几个进程可能是同一时刻同一个程序的多个实例,他们的用户可能是相同的,也可能是不同的.

使用系统调用 fork() 创建进程

系统调用 pid_t fork(void) 用来创建新的进程.这是一个非常奇特的调用,返回两次!听起来很奇怪.首先,在一个正在运行的进程中调用 fork(),会创建一个新进程,新进程是调用进程的子进程.当 fork() 时,调用进程的所有正在使用的"内存页"会被复制,调用进程正在使用的"文件描述符号码表"也会被复制,这些复制被放入新进程中使用.因此,新进程和调用进程的这些内容变成相互独立的.

fork

注意:

"写时复制",许多现代系统采用了这些技术.当 fork() 时,系统并不立刻复制"内存页"和"文件描述符号码表",只是在新进程中做一个映射,这样会节省很多复制开销.当新进程中有代码修改"内存页"和"文件描述符号码表"时,系统会立刻复制一个副本,用于修改.

还有一个需要注意的:

"文件描述符号码表":操作系统每打开一个文件,会在内核缓存中保存文件资源,并为每一个文件建立一些散列表,记录文件信息,同时为开发者提供一个非负整数的号码(文件表的索引),用于引用内存中的文件资源.因此,多个进程的"文件描述符号码表"虽然是独立的,但是号码指向的文件却可能是同一个文件,对同一个文件的修改是可以影响到多个进程.由此,出现了另一项技术:"同步",防止多进程同时修改文件资源.

fork() 返回两次,返回值有 3 种情况:

  1. 如果返回的是 0 ,那么返回值位于新进程中
  2. 如果返回的是 >0,那么返回值位于调用进程中,返回值是新进程的进程号码.返回值 pid_t 定义在 sys/types.h 头文件中,通常是一个整数
  3. 如果返回的是 -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).当调用这个函数时,有以下可能:

  1. 进程中没有子进程,立刻返回
  2. 进程中的子进程已经退出,并且是"僵尸"状态,立刻应答并返回,取得子进程状态(通过参数)
  3. 进程中的子进程都在运行,立刻阻塞,直到有一个子进程退出或者收到一个 "SIGCHILD" 信号时返回,取得子进程状态(通过参数)
wait

例子:

#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.

POSIX 程序设计 __ 多进程和进程通信_第1张图片
exec
#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]),创建一对管道,第一个用于读,第二个用于写,返回两个对应的文件描述符号码.

管道非常受限:

  1. 每一个都是单向的,要么只能读,要么只能写
  2. 管道只能在有父-子关系的进程中使用,不相关的进程之间无法使用.

管道也有一些优点:.

  1. 管道中传输的是字节流,写入的顺序和读取的顺序是一致的
  2. 管道中不会丢失数据,除非读取端提前关闭读取管道
PIPE
#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 分别通过一对管道进行读写.

  1. 假设进程 A 使用 pipefds_b[0,阻塞在读取时,进程 B 也使用 pipefds_b[1] 阻塞在读取.这时候,两边都没有字节流发送,进程 A 和进程 B 就会一直阻塞在那里,什么都不做了.

  2. 假设进程 A 使用 pipefds_a[1] 写入.基于字节流的读写往往都是基于内核缓冲区,管道也不例外.当创建管道的时候,内核同时为管道分配了一块内存缓冲,管道写入的时候会写入到这块内核缓冲区上,管道读取的时候也会从这块内核缓冲区读走数据.如果写入的数据很大,内核缓冲区已经填满了,那么进程 A 就会阻塞在那,等待内核缓冲区清空,继续写入.然而此时,进程 B 并没有读取数据,也在使用 pipefds_b[1] 写入管道的内核缓冲区,也阻塞在那里.这样,两个进程也同时阻塞,什么都不做了.

建议:可能的话,把管道读写设置为非阻塞的.

使用系统调用 mkfifo() 创建命名管道,在进程间通信

管道 pipe() 有一个限制:只能在父-子进程之间使用(祖父-孙子也可以).在不相干的进程之间通信,可以使用命名管道 FIFO.int mkfifo(const char * filename, mode_t mode) 打开一个文件,然后进行写入数据,写入完毕关闭,另外一个进程就可以打开同一个文件进行读取数据.

说白了,管道是使用内存交换数据,命名管道使用一个实际的文件交换数据.操作一个命名管道和操作一个普通文件非常相似.另外,命名管道不能够同时读和写,要么读,要么写,每一个操作只能等待另一个的完成.因为是对文件的操作,需要有文件的读写权限.

从一个命名管道读取时,如果没有数据,会阻塞,而不是立刻返回 EOF(这与普通文件的读不同).当命名管道写入时,如果内核缓冲区塞满了,但是没有消费者(读取),进程会阻塞在那,直到内核缓冲区的数据被读取清空.

建议:可能的话,把命名管道读写设置为非阻塞的.

FIFO
#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() 创建套接字,在进程间通信

这是一个广博的话题,应该占用单独一个章节.

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() 创建的子进程,会复制父进程的"内存页",可以在这些内存页中映射共享内存
POSIX 程序设计 __ 多进程和进程通信_第2张图片
MMAP
#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() 创建子进程,在子进程中修改共享数据 datastate 字段为 1.最后在父进程中 wait 等待并应答子进程,打印最后的共享数据 datastate 值.你会发现,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)
      // 创建一个内存映射

友情提示:出于演示简洁,本文字中的函数调用,大部分没有检查创建的正确性,实际项目中,应该有严格的检查.

你可能感兴趣的:(POSIX 程序设计 __ 多进程和进程通信)