进程间通信

65 阅读5分钟

进程间通信方式有:信号量、消息队列、共享内存、基于文件进程间通信、socket、管道

管道
管道是父进程和子进程间通信的常用手段,看一下man pipe示例

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

int main(int argc, char *argv[])
{
    int pipefd[2];
    pid_t cpid;
    char buf;

    if (argc != 2) {
        fprintf(stderr, "Usage: %s <string>\n", argv[0]);
        exit(EXIT_FAILURE); 
    }

    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);    
    }

    cpid = fork();
    if (cpid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);    
    }

    if (cpid == 0) {    /* Child reads from pipe */
        close(pipefd[1]);          /* Close unused write end */

        while (read(pipefd[0], &buf, 1) > 0)
        write(STDOUT_FILENO, &buf, 1);               
        write(STDOUT_FILENO, "\n", 1);
        close(pipefd[0]);
        exit(EXIT_SUCCESS);               
    } else {            /* Parent writes argv[1] to pipe */
        close(pipefd[0]);          /* Close unused read end */
        write(pipefd[1], argv[1], strlen(argv[1]));
        close(pipefd[1]);          /* Reader will see EOF */
        wait(NULL);                /* Wait for child */
        exit(EXIT_SUCCESS);
    }
}

管道能在父子进程间传递数据,利用的是fork调用之后两个管道文件描述符(fd[0]和fd[1])都保持打开。一对这样的文件描述符只能保证父、子进程间一个方向的数据传输,父进程和子进程必须有一个关闭fd[0],另一个关闭fd[1]。显然,如果要实现父、子进程之间的双向数据传输,就必须使用两个管道。这种在只能用于有关联的两个进程(父子进程)间通信的管道称为匿名管道。还有一种特殊的管道FIFO(First in First Out先进先出)称为命名管道,能用于无关联进程之间的通信。
命名管道通过mkfifo创建

#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);

man手册关于函数的描述

Opening a FIFO for reading normally blocks until some other process opens the same FIFO  for  writing,  and  vice versa.

打开一个FIFO用于读取通常会阻塞,直到其他进程打开相同的FIFO写入,反之亦然。在写入一个FIFO时会阻塞直到被读取,看一下这个示例:

//mkfifo_w.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    if ((mkfifo("./namepipe"0700)) < 0) {
        perror("mkfifo()");
        exit(1);
    }
    char buf[1024] = { "hello, world\n" };
    int fd_w;
    if ((fd_w = open("./namepipe", O_WRONLY)) < 0) {
        perror("open()");
        exit(1);
    }
    int len = write(fd_w, buf, sizeof(buf));
    printf("%d\n", len);
    return 0;
}

程序会在当前目录下创建一下namepipe的管道文件,写入一行hello, world\n,程序运行会被阻塞起来,直到被下面这段程序读取后。

//mkfifo_r.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    char buf[1024] = {0};
    int fd_r;
    if ((fd_r = open("./namepipe", O_RDONLY)) < 0) {
        perror("open()");
        exit(1);
    }
    int len = read(fd_r, buf, 1024);
    printf("%s\n", buf);
    return 0;
}

信号量
信号量就涉及到PV操作
P(SV),如果SV的值大于0,就将它减1;如果SV的值为0,则挂起进程的执行。
V(SV),如果有其他进程因为等待SV而挂起,则唤醒之;如果没有,则将SV加1。
注意:使用一个普通变量来模拟二进制信号量是行不通的,因为所有高级语言都没有一个原子操作可以同时完成如下两步操作:检测变量是否为true/false,如果是则再将它设置为false/true

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
int semop(int semid, struct sembuf *sops, size_t nsops);
int semctl(int semid, int semnum, int cmd, ...);

semget系统调用创建一个新的信号量集,或者获取一个已经存在的信号量集。
semop系统调用改变信号量的值,即P、V操作。
semctl系统调用允许调用者对信号量进行直接控制。
每个信号量都对应内核的一些变量:

unsigned short semval;//信号量的值
unsigned short semzcnt;//等待信号量值变为0的进程数量
unsigned short semncnt;//等待信号量值增加的进程数量
pid_t sempid;//最后一次执行semop操作的进程ID

通过系统调用对信号量的操作实际是就是对这些内核变量的操作。

示例:
一个进程创建一个信号量,执行P操作,睡眠10秒,另个一进程等待这个信号量可用,被挂起,当第一个进程睡眠完成后执行V操作,随后唤醒第二个进程,第二个进程得以执行

//semw.c
#include <stdio.h>
#include <sys/sem.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

union semun {
    int val;
    struct semid_ds *buf;
    unsigned short int *array;
    struct seminfo *__buf;
};

//op为-1时执行p操作,为1时执行v操作
void pv(int sem_id, int op) {
    struct sembuf sem_b;
    sem_b.sem_num = 0;
    sem_b.sem_op = op;
    sem_b.sem_flg = SEM_UNDO;
    semop(sem_id, &sem_b, 1);
}

int main() {
    int sem_id = semget(0x1110666 | IPC_CREAT);
    if (sem_id < 0) {
        perror("semget");
        exit(1);
    }
    union semun sem_un;
    sem_un.val = 1;
    semctl(sem_id, 0, SETVAL, sem_un);
    pv(sem_id, -1);
    printf("%s""first process\n");
    sleep(10);
    pv(sem_id, 1);
    return 0;
}

第二个进程

#include <stdio.h>
#include <sys/sem.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short int *array;
    struct seminfo *__buf;
};

void pv(int sem_id, int op) {
    struct sembuf sem_b;
    sem_b.sem_num = 0;
    sem_b.sem_op = op;
    sem_b.sem_flg = SEM_UNDO;
    semop(sem_id, &sem_b, 1);
}

int main() {
    int sem_id = semget(0x1100666);
    if (sem_id < 0) {
        perror("semget");
        exit(1);
    }
    pv(sem_id, -1);
    printf("%s""second process\n");
    pv(sem_id, 1);
    union semun sem_un;
    sem_un.val = 0;
    //删除信号量
    semctl(sem_id, 0, IPC_RMID, sem_un);
    return 0;
}

首先运行第一个程序,紧接着运行第二个程序,可以观察到大约10秒后,第二个程序得以执行输出。
在信号量被创建后,我们可以通过ipcs -s查看新建的信号量

DM_20230411194047_001.png

共享内存

共享内存是最高效的IPC机制,因为不涉及进程间任何数据传输。这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态体条件。因此,共享内存通常和其他进程间通信方式一起使用。

#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
int shmget(key_t key, size_t size, int shmflg);
void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

shmget系统调用创建一段新的共享内存,或者获取一段已经存在的共享内存。key参数是一个键值,用来标识一段全局唯一的共享内存。key可以通过ftok系统调用获得,也可以自定义。
共享内存被创建/获取之后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中,使用完共享内存后,需要将它从进程地址空间中分离,这些通过shmatshmdt实现。
shmctl系统调用控制共享内存的某些属性。
示例:
一个进程往共享内存写数据,另一个进程从共享内存读数据

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

int main() {
    int shmid;
    if ((shmid = shmget(0x114096, SHM_W | IPC_CREAT)) < 0) {
        perror("shmget()");
        exit(1);
    }
    char *buf;
    if ((buf = shmat(shmid, NULL0)) == (void *)-1) {
        perror("shmat()");
        exit(1);
    }
    strcpy(buf, "hello, world!\n");
    return 0;
}

另一个进程读

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

int main() {
    int shmid;
    if ((shmid = shmget(0x114096, SHM_R)) < 0) {
        perror("shmget()");
        exit(1);
    }
    char *buf = NULL;
    if ((buf = shmat(shmid, NULL0)) == (void *)-1) {
        perror("shmat()");
        exit(1);
    }
    printf("%s", buf);
    //删除共享内存
    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}

消息队列

消息队列是在两个进程之间传递二进制块数据的一种简单有效的方式。每个数据块都有一个特定的类型,接收方可以根据类型来有选择地接收数据,而不一定像管道和命名管道那样必须先进先出的方式接收数据。

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
int msgsnd(int msgid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msgid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
int msgctl(int msgid, int cmd, struct msqid_ds *buf);

msgget系统调用创建一个消息队列,或者获取一个已有的消息队列
msgsnd系统调用把一条消息添加到消息队列中
msgrcv系统调用从消息队列中获取消息
msgctl系统调用控制消息队列的某些属性
示例:
一个进程往消息队列写数据,另一个进程从消息队列读数据

//msgw.c
#include <stdio.h>
#include <sys/msg.h>
#include <string.h>
#include <stdlib.h>

int main() {
    int msg_id;
    if ((msg_id = msgget(0x110666 | IPC_CREAT) < 0)) {
        perror("msgget()");
        exit(1);
    }
    struct msgbuf {
        long mtype;
        char mtext[512];
    } msgbuf;
    msgbuf.mtype = 3;
    strcpy(msgbuf.mtext, "hello, world!\n");
    int ret = msgsnd(msg_id, (void *)&msgbuf, sizeof(msgbuf.mtext), IPC_NOWAIT);
    if (ret < 0) {
        perror("msgsnd()");
        exit(1);
    }

    return 0;
}

另一个进程从消息队列取

#include <stdio.h>
#include <sys/msg.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>

int main() {
    int msg_id;
    if ((msg_id = msgget(0x110666) < 0)) {
        perror("msgget()");
        exit(1);
    }
    struct msgbuf {
        long mtype;
        char mtext[512];
    } msgbuf;
    int ret = msgrcv(msg_id, (void *)&msgbuf, sizeof(msgbuf.mtext), 3, IPC_NOWAIT);
    if (ret < 0) {
        perror("msgrcv()");
        exit(1);
    }
    printf("%s", msgbuf.mtext);
    //移除消息队列
    msgctl(msg_id, IPC_RMID, NULL);
    return 0;
}

同样,我们可以用ipcs查看消息队列

基于文件进程间通信
通过文件进行进程间通信,显而易见,两个进程写或读同一个文件进行数据交换,这时就涉及到数据竞争,因此,我们需要通过文件锁flock来保证同步。
示例
该示例通过创建5个子进程,五个子进程抢夺文件锁,进行数据累加操作并写入文件,最终输出累加结果

//运行:./a.out -s 0 -e 100
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/file.h>
#include <sys/types.h>
#include <unistd.h>

#define INS 5

char num_file[] = "./.num";
//单独一个锁文件,用来控制读写num_file操作
char lock_file[] = "./.lock";

struct Num {
    int now;
    int sum;
};

struct Num num;

size_t set_num(struct Num *num) {
    FILE *fp = fopen(num_file, "w");
    size_t nwrite = fwrite(num, sizeof(struct Num), 1, fp);
    fclose(fp);
    return nwrite;
}

size_t get_num(struct Num *num) {
    FILE *fp = fopen(num_file, "r");
    if (fp == NULL) {
        fclose(fp);
        perror("fopen()");
        return -1;
    }
    size_t nread = fread(num, sizeof(struct Num), 1, fp);
    if (nread < 0) {
        fclose(fp);
        return -1;
    }
    fclose(fp);
    return nread;
}

void do_add(int end) {
    while (1) {
        FILE *lock = fopen(lock_file, "w");
        flock(lock->_fileno, LOCK_EX);
        if (get_num(&num) < 0) {
            fclose(lock);
            continue;
        }
        if (num.now > end) {
            fclose(lock);
            break;
        }
        num.sum += num.now;
        num.now++;
        set_num(&num);
        flock(lock->_fileno, LOCK_UN);
        fclose(lock);
    }
    return ;
}

int main(int argc, char **argv) {
    int opt, start, end;
    while ((opt = getopt(argc, argv, "s:e:")) != -1) {
        switch (opt) {
            case 's':
                start = atoi(optarg);
                break;
            case 'e':
                end = atoi(optarg);
                break;
            default:
                fprintf(stderr, "Usage : %s -s start -e end!\n", argv[0]);
                exit(1);
        }
    }
    printf("start = %d, end = %d\n", start, end);

    num.now = 0;
    num.sum = 0;
    //初始
    set_num(&num);

    int pid_num = 0;
    pid_t pid;

    for (int i = 0; i < INS; i++) {
        pid_num = i;
        if ((pid = fork()) < 0) {
            perror("fork()");
            exit(1);
        }
        if (pid == 0) {
            break;
        }
    }

    if (pid == 0) {
        //子进程进行计算
        do_add(end);
        exit(0);
    }

    int ins = INS;
    while (ins--) {
        wait(NULL);
    }
    get_num(&num);
    printf("sum = %d\n", num.sum);
    return 0;
}

socket
通过socket通信则是通过网络,通过ip+端口指定主机上的某个程序

关于上述系统调用,相关细节实际上有很多,可以配置各种选项。因此,在实际应用场景下需要我们深入去了解。

参考资料
[1] 游双.Linux高性能服务器编程[M].北京:机械工业出版社,2013.