进程间通信

进程间通信 (Inter-Process Communication, IPC),进程之间通信的方式有很多种,下图是一个总结:

下图中给出了 IPC 工具之间的比较:

<w, 700px>

管道

管道,从一端写入数据,可以从另一端读出数据,就像水管一样。管道是使用起来非常方便的一种进程间通信的方式。

<w, 500px>

下面是一个使用管道的例子:

#include <string.h>
#include <stdio.h>
#include <unistd.h>

int main(){
    int fds[2];
    if(pipe(fds) < 0){
        perror("pipe");
    }

    char buffer[10];
    const char* str = "hello";

    // 写入
    write(fds[1], str, strlen(str)+1);

    // 读取
    int n = read(fds[0], buffer, sizeof(buffer));

    printf("%s\n", buffer);
    return 0;
}

使用 pipe 创建管道,创建完成后,两个文件描述符分别管道的两端。向 fds[1] 写入的数据,可以从 fds[0] 中读取出来。

一个使用 pipe 的场景是,创建管道后,使用 fork 创建子进程,由于父子进程共享文件表,父子进程可以使用管道进行通信。下面是一个例子,在子进程中先把管道的写端口定向到标准输出,然后执行 ls 命令。在父进程中,把管道的读端口定向到标准输入,然后执行 wc 命令。这样子进程中 ls 命令的结果就会传给 wc 命令,最终的效果就等同于在终端上执行 ls | wc

#include <string.h>
#include <stdio.h>
#include <unistd.h>

int main(){
    int fds[2];
    if(pipe(fds) < 0){
        perror("pipe");
    }

    switch (fork()) {
        case -1:
            perror("fork");
            break;
        case 0:
            close(fds[0]);
            dup2(fds[1], STDOUT_FILENO);
            execlp("ls", "ls", nullptr);
        default:
            close(fds[1]);
            dup2(fds[0], STDIN_FILENO);
            execlp("wc", "wc", nullptr);
    }
    return 0;
}

在父进程和子进程中,可以关闭掉不需要使用的文件描述符。

在 C 标准库中提供了一个 popen 函数,该函数的第一个参数就是由字符串表示的命令,新建一个进程执行该命令,并使用管道连接两个进程。调用者不用自己创建进程,只需要调用此函数传入参数,就可以打开一个管道。

如下代码,启动 ls 命令,调用方可以在打开的管道上读取到执行结果。

char buf[BUFSIZ];

FILE* fin = popen("ls", "r");

while(fgets(buf, BUFSIZ, fin) != nullptr){
    fputs(buf, stdout);
}

pclose(fin);

FIFO

FIFO (First In, First Out) 就像是一个管道。和 pipe 不同,FIFO 是和文件关联起来的,不同的进程通过读或写的方式打开 FIFO 文件,就可以使用该管道了。pipe 只能在同一个进程,或者父子进程之间使用管道,但是 FIFO 可以把毫不相关的进程中使用管道联系起来。

FIFO 文件可以使用命令 mkfifo <filename> 来创建,使用 ls -l 可以看到,管道文件是使用 p 字母来标识的。

$ mkfifo www
$ ls -l
prw-r--r--  1 wangyu  staff     0B Aug 16 22:27 www

在代码中也可以使用 mknod 来创建管道文件:

#include <unistd.h>
#include <fcntl.h>

mknod("www", S_IFIFO | 0666, 0);

管道文件创建好之后,可以使用 open 打开该文件,然后使用 write 写入数据,使用 read 读取。因为可以使用文件名打开 FIFO 文件,因此可以在两个不相关的进程间使用 FIFO 文件来传输信息。一个进程打开 FIFO 的一端,如果对端还没有被打开,那就会被阻塞,除非指定非阻塞式打开。

下面是一个使用管道的例子,有两个程序,一个是发送端一个是接收端。

发送端从标准输入接收用户的键入,然后把内容发送到 FIFO 管道中。

#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>

#define FIFO_NAME "www"

int main(){
    if(access(FIFO_NAME, F_OK) == -1){
        mknod(FIFO_NAME, S_IFIFO | 0666, 0);
    }
    char buffer[100];

    int fd = open(FIFO_NAME, O_WRONLY);
    int n;
    do{
        if((n = read(STDIN_FILENO, buffer, sizeof(buffer))) == -1){
            perror("read");
        }else{
            buffer[n] = '\0';
            write(fd, buffer, n);
        }
    }while(n > 0);
    return 0;
}

接收端,有多个进程,每个进程都尝试中管道中接收数据。接收到数据后,如果内容是 "quit" 就退出,否则打印出接收到的内容。

#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <sys/wait.h>

#define FIFO_NAME "www"

int main(){
    if(access(FIFO_NAME, F_OK) == -1){
        mknod(FIFO_NAME, S_IFIFO | 0666, 0);
    }

    char buffer[100];
    for(int i = 0; i < 5; i++){
        if(fork() == 0){
            int fd = open(FIFO_NAME, O_RDONLY);
            int n;
            do{
                if((n = read(fd, buffer, sizeof(buffer))) == -1){
                    perror("read");
                }else{
                    buffer[n] = '\0';
                    if(strncmp(buffer, "quit", 4) == 0){
                        close(fd);
                        exit(0);
                    }
                    printf("PID: %d recv: %s\n", (int)getpid(), buffer);
                }
            }while(n > 0);
            exit(1);
        }
    }
    while (wait(nullptr) != -1){}
    return 0;
}

FIFO 可以有多个发送方和多个接收方,整个模型是一个多生产者多消费者的模型。当管道中没有内容时,尝试读取数据会阻塞。当没有接收方存在的时,如果写数据,就会接收到信号 SIGPIPE,默认会结束进程。

FIFO 比起 pipe 强大了很多,在系统中创建一个 FIFO 文件,然后多个进程就可以进行通信了。FIFO 提供的字节流的传输,使用者需要自己设计分包的策略。

文件锁

对文件的某个部门加锁,如果加锁的区域已经部分或全部被锁定了,那么加锁操作就会阻塞,直到加锁的进程解锁为止。如果某个进程对文件加了锁,但是没有释放锁,在进程退出的时候锁会自动释放。

加锁和解锁操作都使用 flock 结构和 fcntl 函数。

struct flock {
	off_t   l_start;    /* 加锁的起始位置 */
	off_t   l_len;      /* 加锁长度,len = 0 表示从起始到文件结束 */
	pid_t   l_pid;      /* 锁的拥有进程 */
	short   l_type;     /* 锁的类型:读、写、清除,可取值为:F_RDLCK, F_WRLCK, F_UNLCK  */
	short   l_whence;   /* 和 lseek 中类似,用来指示 start 的类型,SEEK_SET, SEEK_CUR, SEEK_END */
};

fcntl 用来对文件加锁和解锁,其中第二个参数用来配置 fcntl 的行为:

  • F_SETLKW:设置文件锁,如果当前文件制定的区域已经加锁,就等到
  • F_SETLK:设置文件锁,如果已经加锁就返回 -1
  • F_GETLK:检查当前文件上是否存在一个文件锁和传入的文件锁冲突

下面是一个使用文件锁的例子,把下面这段代码编译后,运行两个进程,你就会发现第二个进程在尝试加锁的时候会被阻塞起来,直到第一个进程释放文件锁。

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main(){
    struct flock fl;
    fl.l_type = F_WRLCK;
    fl.l_whence = SEEK_SET;
    fl.l_start = 0;
    fl.l_len = 0;
    fl.l_pid = getpid();

    printf("try to get lock\n");
    int fd = open("test.txt", O_WRONLY);
    fcntl(fd, F_SETLKW, &fl);
    printf("got lock\n");

    printf("input to release lock\n");
    getchar();
    printf("release lock");
    fl.l_type = F_ULOCK;
    fcntl(fd, F_SETLKW, &fl);
    return 0;
}

消息队列

消息队列可以提供单向的数据传输服务,发送的内容是一个 struct,接收方需要使用同样的 struct 来接收。发送的 struct 的第一个字段必须是 mtype,之后的字段可以随机添加。

struct Message{
    long mtype;
    char name[20];
    int age;
};

在发送和接收的时候,只需要使用相同的结构体,因为相同的结构体才能对数据做相同的解释。

int msgget (key_t __key, int __msgflg); // 获得消息队列
ssize_t msgrcv (int __msqid, void *__msgp, size_t __msgsz, long int __msgtyp, int __msgflg); // 接收
int msgsnd (int __msqid, const void *__msgp, size_t __msgsz, int __msgflg); // 发送
int msgctl (int __msqid, int __cmd, struct msqid_ds *__buf); // 控制消息队列

下面是一个例子,这里在同一个进程中使用同一个消息队列发送和接收数据。

#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/msg.h>
#include <sys/ipc.h>

struct message{
    long mtype;
    char name[30];
};

int main(int argc, char *argv[]){
    key_t key =  ftok("/home/wy/file", 'a');
    int message_queue_id = msgget(key, IPC_CREAT | 0666);

    message msg = { 1, "wangyu123"};

    msgsnd(message_queue_id, &msg, sizeof(message), 0);

    message recv_msg;
    msgrcv(message_queue_id, &recv_msg, sizeof(message), 1, 0);
    printf("%s\n", recv_msg.name);

    msgctl(message_queue_id, IPC_RMID, nullptr);
}

其他进程可以使用相同的 key 来获取到已经创建好的消息队列,并使用它发送数据。不同的消息类型,可以使用不同的结构体,但是因为结构体中第一个字段是固定的,操作系统可以基于此字段得知消息的类型。在接收消息时,需要使用正确的结构体接收,并且指明消息类型。

  • 消息队列是使用标识符引用的,而不是文件描述符,没办法和 IO 多路复用的机制联系起来。
  • 使用键来标识消息队列会增加程序设计的复杂性。
  • 消息队列是无连接的,不知道存在多少个进程引用了该消息队列,不知道何时能安全地删除掉队列
  • 消息队列的总数是有限制的

信号量

TODO

共享内存

多个进程共享内存,其中一个进程修改了内存,其他进程都可以看到,这是不是很棒啊。和共享内存相关的 API 有以下几个:

int     shmget(key_t, size_t, int);
void    *shmat(int, const void *, int);
int     shmdt(const void *);
int     shmctl(int, int, struct shmid_ds *);

首先使用 shmget 得到共享内存的 id,然后使用 shmat 得到与该 id 关联的共享内存,这里 at 是 attach 的意思。这一步我想是操作系统会进行页表的操作,把共享内存映射到进程空间中。共享内存使用完成后,可以使用 shmdt 来 detach 这块内存。

下面的代码中,我把共享内存强制转换为某个对象的指针,然后在此对象上修改数据。在其他进程可以在此内存上读取到修改后的内容。

#include <string.h>
#include <stdio.h>
#include <sys/shm.h>
#include <sys/ipc.h>

#define SHM_SIZE 1024

struct User{
    char name[10];
    int age;
};

void writer_fn(){
    key_t key =  ftok("/home/wy/file", 'a');
    int shmid = shmget(key, SHM_SIZE,  IPC_CREAT | 0666);

    void* data = shmat(shmid, nullptr, 0);

    struct User* w = static_cast<User*>(data);
    strcpy(w->name, "wangyu");
    w->age = 18;

    getchar(); // 阻塞住当前进程

    shmdt(data);
}

void reader_fn(){
    key_t key =  ftok("/home/wy/file", 'a');
    int shmid = shmget(key, SHM_SIZE,  IPC_CREAT | 0666);

    void* data = shmat(shmid, nullptr, 0);

    User* w = static_cast<User*>(data);
    printf("name: %s   age: %d\n", w->name, w->age);

    shmdt(data);
}


int main(int argc, char *argv[]){
    // 运行程序的时候传入不同数量的参数,分别执行 write 和 read
    if(argc == 2){
        writer_fn();
    }else if(argc == 3){
        reader_fn();
    }
}

内存映射

把一个文件的全部或者部分映射到内存中,然后对内存进行读写,而后文件也跟着变化了,这是不是很爽啊。使用 mmap 就可以实现了,其原型如下:

void* mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset)
  • addr: 指定想要把文件映射到的地址,通常由操作系统自己选择一块合适的地址,这里填 0 即可
  • len: 想要映射多少字节
  • prot: 即 “protection”,指定对内存的保护措施,可选项为: PROT_READ, PROT_WRITE, PROT_EXEC,表示读、写、执行。prot 的配置需要和 open 的参数匹配,比如你这里想要能够读写,那么 open 文件的时候,自然也要设置可读
  • flags: 可选值有两个 MAP_SHARED, MAP_PRIVATE,如果设置为 share 那么对这块内存的修改会反映到文件上,如果多个进程都映射了同一个文件,那么其他进程能够看到本进程的修改。如果设置为 private,每个进程都有自己的一份拷贝。
  • fd: 需要映射的文件的文件描述符,如果设置此值为 0,操作系统也会返回一块内存,只是这块内存没有映射任何内容。可以使用这种机制来得到内存,malloc 就是使用这种方式得到大片内存,然后再精细化管理的。即,使用 mmap 从操作系统批发内存,然后零售给用户。
  • offset: 文件偏移位置,指定要从文件的那个位置开始映射。

下面是一个具体的例子,把一个文件映射到内存中,然后把文件中的小写字母全部转为大写。程序运行完毕后,文件的内容也就改变了。

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <ctype.h>
#include <sys/mman.h>
#include <sys/stat.h>

int main(int argc, char** argv){
    if(argc < 2){
        fprintf(stderr, "usage: %s <file>\n", argv[0]);
        _exit(1);
    }
    int fd = open(argv[1], O_RDWR);
    struct stat st{};
    fstat(fd, &st);

    char* data = (char*)mmap(0, st.st_size, PROT_WRITE | PROT_READ, MAP_SHARED, fd, 0);

    for(int i = 0; i < st.st_size; i++){
        if(islower(data[i])){
            data[i] = toupper(data[i]);
        }
    }

    munmap(data, st.st_size);
    close(fd);
    return 0;
}

socket

socket 是最具有灵活性的进程间通信方式了。相互通信的两个进程不必在同一台机器上,这一点前面的任何 IPC 方式都不能办到。因为 socket 非常常用,而且功能强大,我打算使用额外的一篇文章来写 socket。

TODO: 总结 socket 的用法


参考资料: