C/C++ Linux系统编程:进程通讯完全指南,管道通讯、共享内存以及消息队列
引言
本文是笔者在学习 Linux 系统编程进程间通讯部分时的一份学习笔记总结与记录。内容涵盖了进程间通讯的基本概念、进程通讯的两大标准,并以POSIX标准为主深度讲解管道通讯(有名/无名)、共享内存和消息队列。希望通过清晰的逻辑和丰富的示例,帮助自己和大家更好地理解和掌握这一关键知识点。
一、什么是进程间通信(IPC)?
进程间通信 (Inter-Process Communication, IPC) 是指在不同进程之间传播或交换信息的一系列技术和方法。
每个进程都拥有自己独立的虚拟地址空间,一个进程无法直接访问另一个进程的数据或者进行通讯,为了实现协同工作,操作系统必须提供专门的机制来促成这种通信,这就是IPC的意义所在。
常见的 IPC 方式如下:
管道 (Pipe) / 命名管道 (FIFO)
信号 (Signal)
消息队列 (Message Queue)
共享内存 (Shared Memory)
信号量 (Semaphore)
套接字 (Socket)
本文将重点讨论管道、共享内存和消息队列。
二、两种通信标准:POSIX 和 System V
2.1 System V IPC:
System V(读作 System Five )是一种基于UNIX的操作系统版本,最初由 AT&T(American TelePhone and Telegraph Company,美国电话电报公司,由Bell TelePhone Company发展而来)开发。它在1983年首次发布,对UNIX操作系统的发展产生了深远的影响。SystemV引入了许多新的特性和标准,后来被许多 UNIX 系统和类 UNIX 系统(如 Linux )采纳。
System V IPC是UNIX和类UNIX系统中常用的IPC方法之一,它通过关键字(key)来标识和访问IPC资源。
2.2 POSIX IPC:
由 IEEE 的 POSIX 标准定义,旨在提高可移植性和一致性。POSIX IPC是POSIX标准中的一部分,提供了一种更现代和标准化的进程间通信方式。
POSIX IPC 使用名字(name)作为唯一标识。这些名字通常是以正斜杠(/)开头的字符串,用于唯一地识别资源如消息队列、信号量或共享内存对象。
💡 建议:现代开发推荐使用 POSIX IPC,更直观、易管理;但System V仍广泛存在于遗留系统中,需掌握。
本文后续将专注于POSIX标准下的IPC机制。
三、管道(Pipe)通信
管道是Unix中最古老的IPC形式,它提供了一种单向、字节流的通信通道。
3.1 无名管道(Pipe)
无名管道没有实体文件与之关联,只能用于具有亲缘关系的进程间(如父子进程、兄弟进程)通信。
核心API原型:pipe()
#include <unistd.h>int pipe(int pipefd[2]);
功能:创建一个无名管道
参数:pipefd[2]
:一个整型数组,用于返回两个文件描述符。
pipefd[0]
:用于从管道读取(read)数据。pipefd[1]
:用于向管道写入(write)数据。
返回值:
成功:返回
0
。失败:返回
-1
,并设置errno
。
工作流程:
父进程调用
pipe()
创建管道,获取两个文件描述符。父进程调用
fork()
创建子进程。子进程会继承父进程的文件描述符表,因此也拥有这两个fd。通信双方需要预先约定好数据流向。通常,父进程会关闭一端,子进程关闭另一端,形成一个单向通道。
示例代码:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>int main() {int pipefd[2];pid_t pid;char buf[1024];// 创建管道if (pipe(pipefd) == -1) {perror("pipe");return 1;}pid = fork();if (pid == -1) {perror("fork");return 1;}if (pid == 0) { // 子进程:写入数据close(pipefd[0]); // 关闭读端const char *msg = "子进程消息内容";write(pipefd[1], msg, strlen(msg));close(pipefd[1]);printf("子进程发送消息\n");} else { // 父进程:读取数据close(pipefd[1]); // 关闭写端ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);if (n > 0) {buf[n] = '\0';printf("父进程接受数据: %s\n", buf);}close(pipefd[0]);wait(NULL); // 等待子进程结束}return 0;
}
3.2 有名管道(FIFO)
无名管道的最大限制是只能用于亲缘进程。有名管道(FIFO) 通过提供一个文件系统路径名来解决这个问题,任何进程(只要有适当权限)都可以通过打开这个“文件”来进行通信。
核心AP原型I:mkfifo()
#include <sys/types.h>
#include <sys/stat.h>int mkfifo(const char *pathname, mode_t mode);
功能:在文件系统中创建一个FIFO特殊文件(命名管道)。
参数:
pathname
:FIFO文件的路径名。mode
:指定文件的权限(如0666
)
工作流程:
一个进程(通常是服务器或生产者)使用
mkfifo()
创建一个FIFO文件。通信双方都使用标准的文件I/O函数(
open
,read
,write
,close
)来操作这个FIFO。open
的阻塞行为:以只读方式
open
FIFO会阻塞,直到另一个进程以只写方式打开它。以只写方式
open
FIFO也会阻塞,直到另一个进程以只读方式打开它。使用
O_NONBLOCK
标志可以改变这种阻塞行为。
示例代码:
有名管道通讯可以适用于任何进程之间的通讯,所以使用两个独立进程实现
写进程(fifo_write_test.c)
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <string.h>
#include <errno.h>int main(int argc, char const *argv[])
{int fd;char *pipe_path = "/tmp/myfifo";if (mkfifo(pipe_path,0664) != 0){perror("mkfifo");if (errno != 17){exit(EXIT_FAILURE);}}// 对有名管道的特殊文件创建fdfd = open(pipe_path,O_WRONLY);if (fd == -1){perror("open");exit(EXIT_FAILURE);}char buf[100];ssize_t read_num;while ((read_num = read(STDIN_FILENO,buf,sizeof(buf))) > 0){write(fd,buf,read_num);}if(read_num < 0){perror("read");close(fd);exit(EXIT_FAILURE);}printf("发送数据到管道完成\n");close(fd);// 释放管道// 清除对应的特殊文件if (unlink(pipe_path) == -1){perror("unlink");}return 0;
}
读进程(fifo_read_test.c)
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <string.h>
#include <errno.h>int main(int argc, char const *argv[])
{int fd;char *pipe_path = "/tmp/myfifo";// 对有名管道的特殊文件创建fdfd = open(pipe_path,O_RDONLY);if (fd == -1){perror("open");exit(EXIT_FAILURE);}char buf[100];ssize_t read_num;// 读取管道信息写入到控制台while ((read_num = read(fd,buf,sizeof(buf))) > 0){write(STDOUT_FILENO,buf,read_num);}if(read_num < 0){perror("read");close(fd);exit(EXIT_FAILURE);}printf("接收数据到管道完成\n");close(fd);return 0;
}
编译运行:
gcc fifo_write_test.c -o writer
gcc fifo_read_test.c -o reader
# 终端1运行写进程(它会阻塞等待读进程)
./writer
# 终端2运行读进程
./reader
注意!!!
调用 open() 打开有名管道时,flags设置为 O_WRONLY 则当前进程用于向有名管道写入数据,设置为 O_RDONLY 则当前进程用于从有名管道读取数据。设置为 O_RDWR 技术上是可行的,但正如上文提到的,此时管道既读又写很可能导致一个进程读取到自己发送的数据,通信出现混乱。因此,打开有名管道时,flags 只应为 O_WRONLY 或 O_RDONLY。
四、共享内存(Shared Memory)通信
共享内存是最快的IPC方式。它允许多个进程将同一块物理内存映射到它们各自的地址空间。一个进程写入共享内存的数据,立即可被其他所有映射了该内存区域的进程看到。
4.1关键步骤:
创建/获取共享内存对象。
映射共享内存到进程地址空间。
进行读写操作。
解除映射。
(可选)删除共享内存对象
4.2 POSIX共享内存核心API
4.2.1 shm_open()
函数原型
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>int shm_open(const char *name, int oflag, mode_t mode);
功能:创建或打开一个共享内存对象。它返回一个文件描述符,类似于 open()
。
参数:
name
:共享内存对象的名字,必须以/
开头(例如/my_shm
)。它会在/dev/shm
目录下创建对应的文件。oflag
:标志位。可以是O_RDONLY
,O_RDWR
。与O_CREAT
(不存在则创建)、O_EXCL
(与O_CREAT连用,确保创建的是新对象,如果存在则返回错误,避免覆盖原有对象)、O_TRUNC
(如果对象已存在,将其截断长度为0,只有包含 O_RDWR 有效)进行按位或。mode
:权限位(如0666
),仅在创建时(使用了O_CREAT
)有效。
4.2.2 shm_unlink()
函数原型
#include <sys/mman.h>int shm_unlink(const char *name);
功能:删除一个先前由 shm_open 创建的命名共享内存对象。
参数:name
,与 shm_open
使用的名字相同。
注意!尽管这个函数被称为 unlink ,但是它并没有真正删除共享内存段本身,而是移除了与共享内存对象相关联的名称,使得无法通过该名称再次打开该内存段对象。当所有进程都解除映射后,系统会销毁该对象并释放资源。
4.2.3 ftruncate()
和 truncate()
函数原型
#include <unistd.h>
#include <sys/types.h>int ftruncate(int fd, off_t length);
int truncate(const char *path,off_t length)
这两个函数的功能完全相同:将普通文件或共享内存对象的大小设置为参数 length
指定的值。
如果文件原先的大小大于参数
length
,则超出部分的数据将被丢弃。如果文件原先的大小小于参数
length
,则文件将被扩展,扩展的部分用零字节('\0'
)填充(这被称为“创建一个空洞”)。
对于 ftruncate()
通过已经打开的文件描述符来操作文件。这是在调整共享内存大小时最常用的方法,因为你已经通过 shm_open()
拿到了一个文件描述符 shm_fd
。
- 参数:
fd
:一个已经打开的可写文件描述符(例如,由open
,shm_open
返回的描述符)。
而对于 truncate()
则直接通过文件的路径名来操作,无需先打开文件。
参数
path
:需要调整大小的文件的路径名。
4.2.4 mmap()
和 munmap()
对于 mmap()
函数原型
#include <sys/mman.h>void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
功能:将文件映射到内存区域,进程可以直接对内存区域进行读写操作,就像操作普通内存一样,但实际上是对文件或设备进行读写,从而实现高效的 I/O 操作。
参数:
addr
:指向期望映射的内存地址的指针,通常设为NULL
,由系统内核自动选择合适地址。length
:映射区域的长度,以字节为单位。通常与ftruncate
设置的大小一致。prot
:映射区域的保护方式。PROT_READ
:可读PROT_WRITE
:可写PROT_EXEC
:执行
flags
:控制映射区域的特性。MAP_SHARED
:映射区域是共享的,对映射区域的修改会影响文件和其他映射到同一区域的进程,其他进程可见。必须使用此标志。MAP_PRIVATE
:映射区域是私有的,对映射区域的修改不会影响原始文件,对文件的修改会被暂时保存在一个私有副本中。MAP_ANONYMOUS:
创建一个匿名映射,不与任何文件关联
fd
:shm_open()
返回的文件描述符。offset
:从文件/共享内存起始处的偏移量,通常为0
。
返回值:成功返回映射区域的起始地址,失败返回 MAP_FAILED
(即 (void *)-1
)。
对于 munmap()
函数原型
#include <sys/mman.h>int munmap(void *addr, size_t length);
功能:用于取消之前通过 mmap() 函数建立的内存映射关系
参数:
addr
:mmap()
返回的地址。length
:这是要解除映射的内存区域的大小(以字节为单位),它必须与之前通过 mmap() 映射的大小一致
示例代码
下面给出一个综合的案例介绍各个API的使用
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <string.h>int main(int argc, char const *argv[])
{// 创建一个共享内存对象char shm_name[100] = {0};sprintf(shm_name,"/letter%d",getpid());int fd = shm_open(shm_name,O_RDWR | O_CREAT,0644);if (fd < 0){perror("shem_open");exit(EXIT_FAILURE);}// 设置共享内存大小ftruncate(fd,1024);// 内存映射char *share = mmap(NULL,1024,PROT_READ | PROT_WRITE,MAP_SHARED,fd,0);if (share == MAP_FAILED){perror("mmap");exit(EXIT_FAILURE);}// 映射完成之后,关闭fd连接,不是释放close(fd);// 使用内存映射实现进程之间的通讯// 创建父子进程pid_t pid = fork();if (pid < 0) {// 创建子进程失败perror("fork");}else if (pid == 0){// 子进程strcpy(share,"子进程共享内存通讯内容\n");printf("子进程%d完成通讯\n",getpid());}else{// 父进程waitpid(pid,NULL,0);printf("父进程%d收到子进程%d的消息内容:%s",getpid(),pid,share);// 释放映射int re = munmap(share,1024);if (re == -1){perror("munmap");exit(EXIT_FAILURE);}}// 释放共享内存对象shm_unlink(shm_name);return 0;
}
4.3 内存共享对象在临时文件系统中的表示
内存共享对象在临时文件系统中的表示位于/dev/shm目录下。
为看到共享对象在临时文件系统中的表示,我们修改程序,在创建后不销毁。
五、消息队列(Message Queue)通信
消息队列提供了一种在进程间发送格式化消息数据块的机制。每个消息都是一个有类型的数据包,读进程可以按类型读取,提供了比FIFO更强的灵活性。
特点:
消息被赋予一个优先级(类型),可以按优先级读取,而非严格FIFO。
消息队列有内核持久性,即使没有进程连接着它,队列及其消息也会一直存在,直到被显式删除或系统重启。
通信可以是双向的。
5. 1 POSIX消息队列核心数据结构
5.1.1 mqd_t
#include <mqueue.h>
typedef int mqd_t;
该数据类型定义在mqueue.h中,是用来记录消息队列描述符的。实质上是int类型的别名。
5.1.2 struct mq_attr
struct mq_attr {
long mq_flags; /* Flags (ignored for mq_open()) */
long mq_maxmsg; /* Max. # of messages on queue */
long mq_msgsize; /* Max. message size (bytes) */
long mq_curmsgs; /* # of messages currently in queue(ignored for mq_open()) */
};
mq_flags 标记,对于mq_open,忽略它,因为这个标记是通过前者的调用传递的
mq_maxmgs 队列可以容纳的消息的最大数量
mq_msgsize 单条消息的最大允许大小,以字节为单位
mq_curmsgs 当前队列中的消息数量,对于mq_open,忽略它
5.1.3 timespec
struct timespec {time_t tv_sec; // 秒 (Seconds)long tv_nsec; // 纳秒 (Nanoseconds) [0, 999,999,999]
};
时间结构体,提供了纳秒级的UNIX时间戳,前一个数据是秒,后一个是纳秒
5. 2 POSIX消息队列核心API
5.2.1 mq_open()
#include <mqueue.h>mqd_t mq_open(const char *name, int oflag, ... /* mode_t mode, struct mq_attr *attr */);
功能:打开一个或创建一个消息队列
参数:
name
:消息队列的名字,必须以/
开头(例如/my_mq
)。oflag
:标志位,与open
和shm_open
类似(O_RDONLY
,O_WRONLY
,O_RDWR
,O_CREAT
,O_EXCL
,O_NONBLOCK
)。mode
:(可变参数,仅在O_CREAT
时需要)权限位。attr
:(可变参数,仅在O_CREAT
时可以指定)指向mq_attr
结构的指针,用于设置队列属性。如果为NULL
,则使用默认属性。
5.2.2 mq_timedsend()
和 mq_timedreceive()
#include <mqueue.h>
#include <time.h>int mq_timedsend(mqd_t mqdes, const char *msg_ptr,size_t msg_len, unsigned int msg_prio,const struct timespec *abs_timeout);ssize_t mq_timedreceive(mqd_t mqdes, char *msg_ptr,size_t msg_len, unsigned int *msg_prio,const struct timespec *abs_timeout);
参数:
mqdes
:mq_open()
返回的消息队列描述符。msg_ptr
:指向要发送的消息缓冲区的指针。msg_len
:消息的长度(字节数),必须小于等于创建队列时设置的mq_msgsize
。msg_prio
:消息的优先级(一个非负整数)。数值越大,优先级越高。如果不需要优先级,设为0
。abs_timeout
:这是最关键的新参数。它是一个指向struct timespec
结构的指针,指定了一个绝对时间(Absolute Time),表示操作等待的最终截止时间。
返回值:
成功返回发送字节数
失败时返回 -1
,并设置 errno
。如果失败是因为超时,errno
会被设置为 ETIMEDOUT
5.2.3 clock_gettime()
- 获取精确时间
为了正确地设置 abs_timeout
,我们需要一个高精度的函数来获取当前时间,并在此基础上进行计算。
#include <time.h>int clock_gettime(clockid_t clk_id, struct timespec *tp);
功能:获取指定时钟的当前时间。
参数:
clk_id
:时钟的标识符,指定要使用哪种时钟。CLOCK_REALTIME
:系统实时时间,即我们通常理解的时间(年月日时分秒)。这个时间可以被系统管理员或ntp
服务调整,可能会导致时间跳跃。这是最常用的,也是mq_timed*
函数默认使用的时钟。CLOCK_MONOTONIC
:单调时钟。从某个未指定的起点开始(通常是系统启动时间)开始计时,不受系统时间调整的影响。非常适合用于测量时间间隔。如果你的超时逻辑要求绝不因为系统时间被调快或调慢而受影响,就应该使用这个时钟。
tp
:指向struct timespec
结构的指针,用于存储获取到的时间。
示例代码
#include <fcntl.h>
#include <sys/stat.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>int main(int argc, char const *argv[])
{// 创建消息队列struct mq_attr attr;// 有用的参数 表示消息队列的容量attr.mq_maxmsg = 10;attr.mq_msgsize = 100;// 被忽略的消息 在创建爱你消息队列的时候用不到attr.mq_flags = 0;attr.mq_curmsgs = 0;char *mq_name = "/father_son_mq";mqd_t mqdes = mq_open(mq_name,O_RDWR | O_CREAT,0664,&attr);if (mqdes == (mqd_t)-1){perror("mq_open");exit(EXIT_FAILURE);}// 创建父子进程pid_t pid = fork();if (pid < 0){perror("fork");exit(EXIT_FAILURE);}else if (pid == 0){// 子进程 等待接受消息队列中的信息char read_buf[100];struct timespec time_info;// 清空缓冲区memset(read_buf,0,100);// 设置接收数据的等待时间clock_gettime(0,&time_info);time_info.tv_sec += 15;// 接受消息队列的数据同时打印到控制台终端for (size_t i = 0; i < 10; i++){// 清空缓冲区memset(read_buf,0,100);// 设置接收数据的等待时间clock_gettime(0,&time_info);time_info.tv_sec += 15;// 接受消息队列的数据同时打印到控制台终端if (mq_timedreceive(mqdes,read_buf,100,NULL,&time_info) == -1){perror("mq_timedreceive");}printf("子进程接收到数据:%s\n",read_buf);}}else{// 父进程 发送消息到消息对列char send_buf[100];struct timespec time_info;for (int i = 0; i < 10; i++){// 清空处理bufmemset(send_buf,0,100);sprintf(send_buf,"父进程的第%d次的消息发送\n",(i+1));// 获取当前的具体时间clock_gettime(0,&time_info);time_info.tv_sec += 5;if (mq_timedsend(mqdes,send_buf,strlen(send_buf),0,&time_info) == -1){perror("mq_timedesend");}printf("父进程发送一条消息,休息1s\n");sleep(1);}}// 最终:不管是父进程还是子进程都需要释放对消息队列的引用close(mqdes);// 清除消息队列,只需要执行一次if (pid > 0){mq_unlink(mq_name);}return 0;
}
结语
进程间通信是Linux系统编程的核心技能之一。理解每种IPC机制的原理、适用场景和API细节,有助于你编写高效、可靠的多进程程序。
🙋♂️ 如有疑问,欢迎留言讨论!
👍 如果本文对你有帮助,别忘了点赞 + 收藏!