基于Linux的进程通信

在 Linux 操作系统里,进程间通信(IPC)是一个重要概念,它使得不同进程能够协同工作,实现数据传输、进程控制等功能。本文将详细介绍 Linux 中几种常见的进程间通信方式,包括管道、命名管道、共享内存、消息队列和信号量。

进程间通信概述

进程间通信的目的主要有以下几点:

  • 数据传输:一个进程需要将它的数据发送给另一个进程,实现信息的交换。
  • 资源共享:多个进程可能需要共同使用某些系统资源,如打印机、内存区域等。通过进程间通信,可以协调对这些资源的访问,避免冲突。
  • 进程控制:在某些情况下,一个进程可能需要控制另一个进程的执行。例如,调试工具进程需要拦截被调试进程的异常和陷入,以便进行调试分析。

管道(Pipe)

管道是 Unix 中最古老的进程间通信形式,它是从一个进程连接到另一个进程的数据流。管道分为匿名管道和命名管道。

(一)匿名管道

匿名管道由 pipe 函数创建,其函数原型为 int pipe(int fd[2]);,其中 fd[0] 表示读端,fd[1] 表示写端。它通常用于具有共同祖先的进程(有亲缘关系的进程)之间通信。例如,一个进程创建管道后调用 fork 函数,父子进程就可以通过管道进行通信。其基本原理是:父进程创建管道后,fork 出子进程,父子进程各自关闭不需要的端(因为管道只允许一个使用读端,一个使用写端),然后通过管道进行数据传输。

如图,某一进程创建管道后,操作系统将该进程的描述符表3,4位置指针分别指向专门负责读和写的文件,在Linux里,管道也被看作是文件,文件缓冲区就是管道。无法像普通文件那样在文件系统中被其他不相关的进程通过文件名来找到和使用,仅仅用于管道通信,所以称之为匿名管道。
当创建子进程后,子进程也会复制父进程的文件描述符表,二者都具有读写文件,且指向同一个文件缓冲区,此时关闭想要关闭的读写端即可。

如图,父进程负责读,子进程负责写。

  • 读写规则

    • 当没有数据可读且 O_NONBLOCK 禁用时,read 调用阻塞,进程暂停执行,一直等到有数据来到为止;当 O_NONBLOCK 启用时,read 调用返回 -1,errno 值为 EAGAIN
    • 当管道满的时候且 O_NONBLOCK 禁用,write 调用阻塞,直到有进程读走数据;当 O_NONBLOCK 启用时,调用返回 -1,errno 值为 EAGAIN
    • 如果管道写端对应的文件描述符被关闭,则 read 返回 0,表示读到了文件末尾。没有进程再向管道写入数据,读操作自然结束。
    • 如果管道读端对应的文件描述符被关闭,则 write 操作会产生信号 SIGPIPE,导致 write 进程退出。这一点体现了操作系统不希望发生资源浪费,读端被关闭,写端写给谁呢?
  • 使用进程规则:匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信。一般来说是一个进程创建管道后调用 fork 函数,此后父、子进程之间就可应用该管道。例如,父进程创建管道后,fork 出子进程,父子进程各自关闭不需要的描述符(父进程关闭读端,子进程关闭写端或者反之),然后通过保留的读端和写端进行数据传输。当然,兄弟进程间也可以使用管道来进行通信。比如父进程fork多个子进程,其多个子进程都将具有与父进程相同的文件描述表,自然也就有指向同一块文件缓冲区的读写文件。

  • 管道特性规则

    • 管道提供流式服务,数据在管道中像在文件流中一样按顺序传输。
    • 一般而言,进程退出,管道释放,所以管道的生命周期随进程。
    • 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。例如,进程 A 向进程 B 发送数据需要一个管道,进程 B 向进程 A 发送数据则需要另一个管道。

匿名管道示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
int pipefd[2]; //存储读端和写端的下标
if (pipe(pipefd) == -1) {
perror("pipe creation failed");
return 1;
}

pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
} else if (pid == 0) { // 子进程
close(pipefd[0]); // 关闭读端
char message[] = "Hello from child!";
write(pipefd[1], message, sizeof(message)); //管道通信自然站在文件视角
close(pipefd[1]);
exit(EXIT_SUCCESS);
} else { // 父进程
close(pipefd[1]); // 关闭写端
char buffer[100];
ssize_t bytesRead = read(pipefd[0], buffer, sizeof(buffer));
if (bytesRead > 0) {
buffer[bytesRead] = '\0';
printf("Received message: %s\n", buffer);
} else {
perror("read from pipe failed");
}
close(pipefd[0]);
}

return 0;
}

命名管道

命名管道不再遵循只能在亲缘关系进程间通信的限制,它是一种特殊类型的文件。匿名管道是由函数来创建管道,而命名管道可以由程序员自定义管道。可以在命令行使用 mkfifo 命令创建,如 $ mkfifo filename,也可以在程序中使用 int mkfifo(const char *filename, mode_t mode); 函数创建。

int mkfifo(const char *filename, mode_t mode); 函数用于创建命名管道。

  • 参数说明

    • filename:是一个指向以空字符结尾的字符串的指针,指定了要创建的命名管道的文件名。这个文件名在文件系统中必须是唯一的,并且遵循文件系统的命名规则。
    • mode:是一个 mode_t 类型的参数,用于设置命名管道的权限模式。它类似于创建普通文件时的权限设置,例如可以使用八进制数表示权限,如 0644 表示所有者具有读写权限,所属组和其他用户具有读权限。
  • 函数功能:当调用该函数时,如果成功创建命名管道,它会返回 0。创建的命名管道是一种特殊类型的文件,在文件系统中可见,可以被不同的进程通过文件名来访问。其他进程可以使用 open 函数以适当的模式(读或写)打开这个命名管道,从而实现进程间的通信。如果创建失败,函数会返回 -1。

命名管道示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

#define FIFO_NAME "./my_fifo" //在该目录下的my_fifo文件

int main() {
// 创建命名管道
if (mkfifo(FIFO_NAME, 0666) == -1) {
perror("mkfifo failed");
return 1;
}

pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
} else if (pid == 0) { // 子进程
int fd = open(FIFO_NAME, O_RDONLY);
if (fd == -1) {
perror("open FIFO for reading failed");
return 1;
}
char buffer[100];
ssize_t bytesRead = read(fd, buffer, sizeof(buffer));
if (bytesRead > 0) {
buffer[bytesRead] = '\0';
printf("Child received: %s\n", buffer);
} else {
perror("read from FIFO failed");
}
close(fd);
exit(EXIT_SUCCESS);
} else { // 父进程
int fd = open(FIFO_NAME, O_WRONLY);
if (fd == -1) {
perror("open FIFO for writing failed");
return 1;
}
char message[] = "Hello from parent!";
write(fd, message, sizeof(message));
close(fd);
}

// 父进程等待子进程结束
wait(NULL);

// 删除命名管道
if (unlink(FIFO_NAME) == -1) {
perror("unlink FIFO failed");
return 1;
}

return 0;
}

共享内存(Shared Memory)

共享内存通信简单来说,就是让多个进程看到同一个内存块。当进程被创建,可以认为天然的就具备了虚拟地址空间,虚拟地址与物理地址的一一对应需要借助页表的帮助。当进程申请一块共享内存,在该进程的共享区就会划出与该共享内存大小相等的地址空间,同样的,需要页表来记录映射关系。当另一个进程也通过页表建立与该共享内存的映射关系后,两个进程就能在自己的虚拟地址空间对同一块共享内存进行访问,当两个进程看到了同一份资源,也就认为实现了进程通信。

共享内存是最快的 IPC 形式。一旦内存映射到共享它的进程的地址空间,进程间数据传递不再涉及内核,即进程无需通过系统调用来传递数据。而诸如管道通信这样的方式,需要进行多次拷贝,速度会受到影响。

共享内存的数据结构为 struct shmid_ds,其中包含了共享内存的各种属性信息,如操作权限、大小、创建时间、最后操作时间、连接的进程数等。

相关函数如下:

  • shmget 函数用于创建共享内存,原型为 int shmget(key_t key, size_t size, int shmflg);,其中 key 是共享内存段名字,size 是共享内存大小,成功返回标识码,失败返回 -1。
  • shmat 函数将共享内存段连接到进程地址空间,原型为 void *shmat(int shmid, const void *shmaddr, int shmflg);shmid 是共享内存标识,shmaddr 指定连接地址,shmflg 有不同取值,成功返回指向共享内存第一个节的指针,失败返回 -1。
  • shmdt 函数将共享内存段与当前进程脱离,原型为 int shmdt(void *shmaddr);shmaddrshmat 返回的指针,成功返回 0,失败返回 -1。注意脱离不等于删除共享内存段。
  • shmctl 函数用于控制共享内存,原型为 int shmctl(int shmid, int cmd, struct shmid_ds *buf);shmid 是标识码,cmd 有不同操作命令(如 IPC_STATIPC_SETIPC_RMID),buf 指向保存共享内存状态和权限的数据结构,成功返回 0,失败返回 -1。

共享内存示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define MEMORY_SIZE 1024

int main() {
key_t key = ftok(".", 'a');
if (key == -1) {
perror("ftok failed");
return 1;
}

int shmid = shmget(key, MEMORY_SIZE, IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget failed");
return 1;
}

void *addr = shmat(shmid, NULL, 0);
if (addr == (void *)-1) {
perror("shmat failed");
return 1;
}

// 父进程向共享内存写入数据
char *data = "This is shared memory data!";
for (int i = 0; i < sizeof(data); i++) {
((char *)addr)[i] = data[i];
}

pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
} else if (pid == 0) { // 子进程
// 子进程从共享内存读取数据并输出
char buffer[MEMORY_SIZE];
for (int i = 0; i < MEMORY_SIZE; i++) {
buffer[i] = ((char *)addr)[i];
}
buffer[sizeof(data)] = '\0';
printf("Child read from shared memory: %s\n", buffer);

// 子进程脱离共享内存
if (shmdt(addr) == -1) {
perror("shmdt in child failed");
return 1;
}
exit(EXIT_SUCCESS);
} else { // 父进程
// 父进程等待子进程结束
wait(NULL);

// 父进程脱离共享内存并删除共享内存段
if (shmdt(addr) == -1) {
perror("shmdt in parent failed");
return 1;
}
if (shmctl(shmid, IPC_RMID, NULL) == -1) {
perror("shmctl failed");
return 1;
}
}

return 0;
}

需要注意的是,共享内存没有进行同步与互斥,在使用时需要开发者自行处理,否则可能会出现数据不一致等问题。