添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
首发于 ReadtheDocs
进程间通信的五种方式

进程间通信的五种方式

进程间通信 (Inter-Process Communication, 简写为 IPC) 是两个进程之间进行信息交流的一种机制, 不仅仅会发生在同一主机的两个进程之间, 也可以发生在不同主机的两个进程之间, UNIX 的进程间通信方式有很多, 例如管道 (pipe), 信号量 (semaphore), 共享内存 (shared memory), 消息队列 (message queue) 以及套接字 (socket) 等, 本文整理了 UNIX 进程间通信的五种方式, 并给出部分场景的示例代码, 为了不陷入讨论冗长的 API 用法, 本文对部分 IPC 只给出相应的函数原型, 关于具体的用法读者可自行查阅 man page

1. UNIX 管道 (pipe)

管道 (pipe) 是使用非常频繁的进程间通信机制之一, 它最早出现于 Version 6 AT&T UNIX 上, 在 shell 中, 我们经常使用 | 将两个命令连接起来, 将前一个命令的输出作为后一个命令的输入, 这是管道使用最典型的例子, 常见的 UNIX 系统都有关于管道操作的 API, 最简单的使用管道的方式是通过 popen 调用和 pclose 调用 (该命令最早由 Version 7 AT&T UNIX 实现), popen 函数可以实现一个程序将另一个程序作为新进程来启动, 并且可以读取新进程的输出或向新进程输入数据, 这两个函数原型如下:

FILE *popen(const char *command, const char *open_mode);
int pclose(FILE *stream_to_close);

open_mode 参数将决定两个进程之间的数据流向 (我们将调用 popen 的进程称为 调用进程 , 将通过 popen 被调用的进程称为 被调用进程 ), 当 open_mode 为 r 时, 调用进程可以通过 popen 函数返回的文件流指针利用诸如 fread 这样的函数来读取被调用进程的输出, 反过来, 当 open_mode 为 w 时, 调用进程可以使用 fwrite 函数向被调用进程写数据, 此时被调用进程可以通过标准输入流读取调用进程传递给它的数据, pclose 函数用来关闭调用进程和被调用进程之间建立的管道, 注意 pclose 函数只有在被调用进程退出以后才会返回, 若在被调用进程退出之前调用 pclose 函数, 则 pclose 函数将阻塞直到被调用进程退出

我们来给出一个具体的示例, ls 是 UNIX 内置的列出当前目录文件列表的命令, 我们可以写一个程序, 通过 popen 来调用 ls 程序并将 ls 进程的输出传递给我们写的程序, 这样实现了 ls 进程与我们使用的程序示例进程的进程间通信, 代码示例如下:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main() {
    FILE *read_fp;
    char buffer[BUFSIZ + 1];
    int chars_read;
    memset(buffer, '\0', sizeof(buffer));
    read_fp = popen("ls -la", "r");
    if (read_fp != NULL) {
        chars_read = fread(buffer, sizeof(char), BUFSIZ, read_fp);
        if (chars_read > 0) {
            printf("Output was:-\n%s\n", buffer);
        pclose(read_fp);
        exit(EXIT_SUCCESS);
    exit(EXIT_FAILURE);
}

编译 / 运行上面这段代码, 可以看到如下输出:

Output was:-
total 192
drwxr-xr-x   8 yunqiang  staff    256 Feb 14 11:08 .
drwxr-xr-x   6 yunqiang  staff    192 Feb 14 11:08 ..
-rw-r--r--   1 yunqiang  staff  22845 Feb 14 11:06 CMakeCache.txt
drwxr-xr-x  14 yunqiang  staff    448 Feb 14 11:08 CMakeFiles
-rw-r--r--   1 yunqiang  staff   5502 Feb 14 11:06 Makefile
-rw-r--r--   1 yunqiang  staff   1406 Feb 14 11:06 cmake_install.cmake
-rwxr-xr-x   1 yunqiang  staff  49920 Feb 14 11:08 linux_practice
-rw-r--r--   1 yunqiang  staff   5503 Feb 14 11:06 linux_practice.cbp

在内部实现上, popen 将调用 fork() 产生子进程, 然后从子进程中调用 /bin/sh -c 来执行参数 command 的命令, 因此对于每次 popen 调用, 不仅会启动被调用的程序, 还会启动 shell

关于 popen 的说明
在早期, popen 函数创建的管道是单向的, open_mode 参数决定管道数据的流向, 但我使用 `man popen` 查阅该命令的说明时发现新的实现 (FreeBSD 2.2.6 引入) 已经改为双向 pipe 了 (open_mode 可以传入 r+ 来建立双向管道), 读者在使用时应了解对应的环境中的 popen 是否支持双向管道 (以下是我在 Darwin 上执行 `man popen` 后给出的命令描述)
The popen() function ``opens'' a process by creating a bidirectional pipe, forking, and invoking the shell. Any streams opened by previous popen() calls in the parent process are closed in the new child process. Historically, popen() was implemented with a unidirectional pipe; hence, many implementations of popen() only allow the mode argument to specify reading or writing, not both. Because popen() is now implemented using a bidirectional pipe, the mode argument may request a bidirectional data flow. The mode argument is a pointer to a null-terminated string which must be `r' for reading, `w' for writing, or `r+' for reading and writ- ing.

再来看一个由调用进程向被调用进程通过管道传递数据的例子, 被调用程序为 grep, 它通过正则表达式匹配输入的字符串中的数字, 并将匹配结果打印到标准输出上, 调用进程将原始字符串通过管道传递给被调用进程, 代码示例如下:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main() {
    FILE *write_fp;
    char buffer[BUFSIZ + 1];
    memset(buffer, '\0', sizeof(buffer));
    sprintf(buffer, "abc123b");
    write_fp = popen("grep -E '\\d+' -o", "w");
    if (write_fp != NULL) {
        fwrite(buffer, sizeof(char), strlen(buffer), write_fp);
        pclose(write_fp);
        exit(EXIT_SUCCESS);
    exit(EXIT_FAILURE);
}

编译 / 运行如上代码, 将会得到如下输出:

123

除了 popen 之外, UNIX 还有 pipe 调用, 它比 popen 更底层, 它的函数原型如下:

int pipe(int file_descriptor[2]);

其参数是一个文件描述符数组, 该数组只有两个元素, 向 file_descriptor[1] 中写入的数据可以从 file_descriptor[0] 中读取, 二者是 FIFO 的关系, 来看一个具体的代码示例

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main(){
    int data_processed;
    int file_pipes[2];
    const char some_data[] = "123";
    char buffer[BUFSIZ + 1];
    memset(buffer, '\0', sizeof(buffer));
    if (pipe(file_pipes) == 0) {
        data_processed = write(file_pipes[1], some_data, strlen(some_data));
        printf("Wrote %d bytes\n", data_processed);
        data_processed = read(file_pipes[0], buffer, BUFSIZ);
        printf("Read %d bytes: %s\n", data_processed, buffer);
        exit(EXIT_SUCCESS);
    exit(EXIT_FAILURE);
}

上面这段程序向 file_pipes[1] 中写入字符序列 "123", 然后从 file_pipes[0] 中读取, 编译 / 运行如上的程序, 将会得到如下的输出:

Wrote 3 bytes
Read 3 bytes: 123

pipe 调用最有用的场景是用在进程 fork() 之后, 父进程和子进程之间的进程间通信, 代码示例如下:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main() {
    int data_processed;
    int file_pipes[2];
    const char some_data[] = "123";
    char buffer[BUFSIZ + 1];
    pid_t fork_result;
    memset(buffer, '\0', sizeof(buffer));
    if (pipe(file_pipes) == 0) {
        fork_result = fork();
        if (fork_result == -1) {
            fprintf(stderr, "Fork failure");
            exit(EXIT_FAILURE);
        if (fork_result == 0) {
            data_processed = read(file_pipes[0], buffer, BUFSIZ);
            printf("Read %d bytes: %s\n", data_processed, buffer);
            exit(EXIT_SUCCESS);
        } else {
            data_processed = write(file_pipes[1], some_data,
            strlen(some_data));
            printf("Wrote %d bytes\n", data_processed);
    exit(EXIT_SUCCESS);
}

在上面的代码示例中, 在进行 fork() 调用后, 根据 fork() 的返回值来判断当前是子进程还是父进程, 当返回值为 0 时代表当前是子进程, 子进程读取 file_pipes[0], 而父进程向 file_pipes[1] 中写入数据, 从而实现父子进程之间的进程间通信

在上面我们所讨论的利用管道进行进程间通信的例子中, 通信的进程都是有关联的, 即都是在一个进程和该进程所创建的进程之间进行的通信, 如果是对于两个独立的进程利用管道进行进程间通信可以使用命名管道 (named pipe), 命名管道是一种特殊的文件, 可以在 shell 中使用命名管道做一个进程间通信的实验, 在使用命名管道通信之前首先创建命名管道, 可以使用如下的命令

mkfifo <pipe-name>

其中 <pipe-name> 是命名管道的名称, 例如我们创建一个名为 my-named-pipe 的命名管道, 然后同时打开两个 shell 进程, 左侧向 my-named-pipe 写入数据, 右侧通过 cat 命令读取数据, 如果在创建完命名管道之后首先运行 cat 命令, 则 cat 命令会处于阻塞状态, 因为当前管道中没有数据可读, 直到左侧的 shell 进程将数据写入命名管道后, 右侧的 cat 命令输出管道的数据并退出, 如下图所示:

此时, 创建了命名管道 my-named-pipe, 并在右侧的 shell 中执行 cat 输出管道数据, 因为还没有向管道写入数据, 所以 cat 命令处于阻塞状态:

在左侧 shell 中向 my-named-pipe 写入数据, 右侧 cat 命令的阻塞状态接触, 输出管道数据并退出

命名管道也有相应的 UNIX API, 读者可以查阅 man page 获取相应的函数原型与用法, 此处不再赘述

2. UNIX 信号量 (semaphore)

信号量是由荷兰学者 Dijkstra 提出的, 它的原理比较简单, 但却能非常好地实现并发控制, 在并发执行的程序中, 如果它们都要访问同一个共享资源 (临界资源), 若此时不加以控制则可能会造成数据错误, 信号量可以非常方便地解决这个问题, 最简单的信号量可以是一个只能取 0 和 1 的变量, 信号量的操作有两个, 分别称之为 P 操作和 V 操作, 我们将信号量记为 s, 则 P(s) 调用的结果是若 s 的值大于 0 则减去 1, 否则挂起进程, V(s) 调用的结果是如果此时有因为执行 P(s) 操作而被挂起的进程, 则恢复该进程的运行, 否则将 s 的值加一, 每次程序要进入临界区时, 都首先调用 P(s), 如果调用成功, 说明当前没有其它进程或线程在访问临界区, 调用 P(s) 的同时也会将 s 的值减成 0, 从而阻止其它想要访问临界区的程序进入, 当操作完毕后, 调用 V(s) 释放对临界区的占用, 在 UNIX 中, 信号量操作有如下的函数:

int semctl(int sem_id, int sem_num, int command, ...);
int semget(key_t key, int num_sems, int sem_flags);
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);

关于它们的详细用法和参数的语义可以查阅 man page

3. UNIX 共享内存 (shared memory)

在同一操作系统上运行的多个进程之间, 它们是相互独立的, 每个进程都有自己的地址空间, 其它进程无法访问当前进程的内存区域, UNIX 的另一种进程间通信机制是使用共享内存, 共享内存是进程创建的特殊的地址空间, 不同进程可以将同一块内存地址连接到它们自己的内存空间中, 此时任何一个进程向共享内存区写入数据, 其它进程都可以读取到, 但共享内存本身没有提供同步机制, 共享内存区的读写需要程序员来维护, UNIX 关于共享内存有如下的 API:

// 创建共享内存
int shmget(key_t key, size_t size, int shmflg);
// 将创建的共享内存连接到进程自身的地址空间中
void *shmat(int shm_id, const void *shm_addr, int shmflg);