⭐️ 本篇博客要给大家介绍一些关于进程间通信的一些知识。Linux下进程通信常见的几种方式,例如管道、共享内存等。
概念: 进程间通信(IPC,Interprocess communication)是一组编程接口,让程序员能够协调不同的进程,使之能在一个操作系统里同时运行,并相互传递、交换信息。这使得一个程序能够在同一时间里处理许多用户的要求。IPC方法包括管道(PIPE)、消息排队、旗语、共用内存以及套接字(socket)(本篇博客只介绍共享内存和管道两种)。
通信目的:
如何实现通信?
要让两个不同的进程实现通信,前提条件是让它们看到同一份资源。所以要想办法让他们看到同一份资源,就需要采取一些手段,可以分为下面几种
通信方式分类:
概念: 管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。它的特点是单向传输数据的,先进先出。
管道相信大家之前都知道一些。我们之前也会用到管道命令‘|’。例如:cat file.txt | head -1。
cat是一个进程,这个进程先处理,然后将处理后得到的标准输出到管道中,再由head进程通过标准输入将管道中的数据读出,再进行处理。
概念: 匿名管道用于进程之间通信,这两个进程需要具有亲缘关系(父子进程等)。
这里介绍一个系统调用接口——pipe。
功能: 创建一个匿名管道
函数原型:#include
int pipe(int pipefd[2]) 参数:
fd:文件描述符数组,这是一个输出型参数,fd[0]表示读端,fd[1]表示写端
返回值:
创建管道成功返回0,失败返回-1
匿名管道创建原理:
调用pipe函数后,OS会在fd_array数组中分配两个文件描述符给管道,一个是读,一个是写,并把这两个文件描述符放到用户传进来的数组中,fd[0]代表管道读端,fd[1]代表管道写端。这样一个管道就创建好了。
实例演示:
实例1: 观察fd[0]和fd[1]的值
#include
#include
int main()
{
int pipefd[2];
int ret = pipe(pipefd);
if (ret == -1){
// 管道创建失败
perror("make piep");
exit(-1);
}
// 成功返回0
// pipefd[0] 代表读端
// pipefd[1] 代表写端
printf("fd[0]:%d, fd[1]:%d\n", pipefd[0], pipefd[1]);
return0;
}
代码运行结果: 显然,pipefd这个数组里面放的是两个文件描述符
实例2: 尝试使用管道读写数据
#include
#include
#include
#include
#include
int main()
{
int pipefd[2];
int ret = pipe(pipefd);
if (ret == -1){
// 管道创建失败
perror("make piep");
exit(-1);
}
char buf[64] = "hello world";
// 写数据
write(pipefd[1], buf, sizeof(buf)/sizeof(buf[0]));
// 读数据
buf[0] = 0;// 清空buf
ssize_t s = read(pipefd[0], buf, 11);
buf[s] = '\0';
printf("%s\n", buf);
return 0;
}
代码运行结果如下: 可以看出,管道也可以读写数据,和文件使用方法是一致的
上面介绍的都是关于管道如何创建,接下来就要介绍如何使用管道进行通信。
Linux下一切皆文件,看待管道,其实时可以像看待文件一样。且管道和文件使用方法是一致的。管道的生命周期随进程。
匿名管道是提供给有亲缘关系两个进程进行通信的。所以我们可以在创建管道之后通过fork函数创建子进程,这样父子进程就看到同一份资源,且父子进程都有这个管道的读写文件描述符。我们可以关闭父进程的读端,关闭子进程的写端,这样子进程往管道里面写数据,父进程往管道里面读数据,这样两个进程就可以实现通信了。
具体过程如下:
#include
#include
#include
#include
#include
int main()
{
int pipefd[2];
int ret = pipe(pipefd);
if (ret == -1){
// 管道创建失败
perror("make piep");
exit(-1);
}
pid_t id = fork();
if (id < 0){
perror("fork failed");
exit(-1);
}
else if (id == 0){
// child
// 关闭读端
close(pipefd[0]);
const char* msg = "I am child...!\n";
//int count = 0;
// 写数据
while (1){
ssize_t s = write(pipefd[1], msg, strlen(msg));
printf("child is sending message...\n");
sleep(1);
}
}
else{
// parent
close(pipefd[1]);
char buf[64];
while (1){
ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = '\0';// 字符串后放一个'\0'
printf("father get message:%s", buf);
}
else if (s == 0){
// 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾
printf("father read end of file...\n ");
}
sleep(1);
}
}
return 0;
}
在这里我们分四种情况来进行研究:
#include
#include
#include
#include
#include
#include
int main()
{
int pipefd[2];
int ret = pipe(pipefd);
if (ret == -1){
// 管道创建失败
perror("make piep");
exit(-1);
}
pid_t id = fork();
if (id < 0){
perror("fork failed");
exit(-1);
}
else if (id == 0){
// child
// 关闭读端
close(pipefd[0]);
const char* msg = "I am child...!\n";
//int count = 0;
// 写数据
while (1){
ssize_t s = write(pipefd[1], msg, strlen(msg));
sleep(5);// 管道大部分时间是空的,读条件不满足时,读端处于阻塞状态
printf("child is sending message...\n");
}
}
else{
// parent
close(pipefd[1]);
char buf[64];
//int count = 0;
while (1){
ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = '\0';// 字符串后放一个'\0'
printf("father get message:%s", buf);
}
else if (s == 0){
// 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾
printf("father read end of file...\n ");
}
sleep(1);
}
}
return 0;
}
代码运行结果如下: 读端处于阻塞
总结: 当读条件不满足时,读端进程会处于阻塞,从task_struct会从运行队列调到等待队列,知道有数据来,才会转移到运行队列中。
pid_t id = fork();
if (id < 0){
perror("fork failed");
exit(-1);
}
else if (id == 0){
// child
// 关闭读端
close(pipefd[0]);
const char* msg = "I am child...!\n";
//int count = 0;
// 写数据
while (1){
ssize_t s = write(pipefd[1], msg, strlen(msg));
printf("child is sending message...\n");
}
}
else{
// parent
close(pipefd[1]);
char buf[64];
//int count = 0;
while (1){
ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = '\0';// 字符串后放一个'\0'
printf("father get message:%s", buf);
}
else if (s == 0){
// 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾
printf("father read end of file...\n ");
sleep(5);// 管道大部分时间都是满的,写条件不满足时,写端处于阻塞状态
}
}
}
代码运行结果如下: 写端写了一会后,管道满了,此时写端处于阻塞状态
总结: 当写条件不满足时,写端处于阻塞状态
// child
// 关闭读端
close(pipefd[0]);
const char* msg = "I am child...!\n";
int count = 0;
// 写数据
while (1){
ssize_t s = write(pipefd[1], msg, strlen(msg));
printf("child is sending message...\n");
printf("CHILD: %d\n", count++);
if (count == 5){
close(pipefd[1]);
exit(-1);
}
sleep(1);
}
// parent
close(pipefd[1]);
char buf[64];
while (1){
ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = '\0';// 字符串后放一个'\0'
printf("father get message:%s", buf);
sleep(5);// 管道大部分时间都是满的,写条件不满足时,写端处于阻塞状态
}
else if (s == 0){
// 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾
printf("father read end of file...\n ");
}
}
代码运行结果如下: 3s后,关闭写端,读端会读到文件结尾
总结: 如果关闭写端,读端进程会读到文件结尾
#include
#include
#include
#include
#include
#include
int main()
{
int pipefd[2];
int ret = pipe(pipefd);
if (ret == -1){
// 管道创建失败
perror("make piep");
exit(-1);
}
pid_t id = fork();
if (id < 0){
perror("fork failed");
exit(-1);
}
else if (id == 0){
// child
// 关闭读端
close(pipefd[0]);
const char* msg = "I am child...!\n";
// int count = 0;
// 写数据
while (1){
ssize_t s = write(pipefd[1], msg, strlen(msg));
printf("child is sending message...\n");
sleep(1);
}
}
else{
// parent
close(pipefd[1]);
char buf[64];
int count = 0;
while (1){
ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = '\0';// 字符串后放一个'\0'
printf("father get message:%s", buf);
//sleep(5);// 管道大部分时间都是满的,写条件不满足时,写端处于阻塞状态
}
else if (s == 0){
// 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾
printf("father read end of file...\n ");
}
sleep(1);
if (count++ == 3){
close(pipefd[0]);// 读端关闭文件描述符,写端进程后序会被操作系统直接杀掉,没有进程读,写时没有意义的
break;
}
}
int status;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0){
// 等待成功
printf("child exit singal is %d\n", status&0x7f);
}
else{
// 等待失败
perror("wait failed");
exit(-1);
}
}
return 0;
}
代码运行结果如下: 可以看出,关闭读端后,子进程收到操作系统发送的13号信号(SIGPIPE)杀死
总结: 读端关闭,写端进程会被操作系统发送信号杀死。
为什么写端进程会被OS杀死?
操作系统不做任何浪费空间和低效的事情,如果读端关闭,那么写还有什么意义呢?所以操作系统会通过信号把写端进程干掉
读写规则总结:
有以下几点:
全双工和半双工:
概念: 命名管道是一种特殊类型(符号性)的文件。在不相关(没有亲缘关系)的进程之间交换数据,可以使用FIFO文件来做这项工作,
命名管道可以通过命令行创建,指令如下:
mkfifo filename
也可以通过mkfifo函数创建:
函数原型:
#include
#include int mkfifo(const char *pathname, mode_t mode); 功能: 创建一个命名管道
参数:
pathname: 管道名称
mode: 权限
返回值: 创建成功返回0,失败返回-1
实例演示
实例1: 使用命令创建管道
实例2: 使用mkfifo函数创建
#include
#include
#include
#include
#include
#include
#define FIFO "./fifo"
int main()
{
umask(0);
// 创建管道
int ret = mkfifo(FIFO, 0666);
if (ret == -1){
perror("make fifo");
exit(-1);
}
}
接下来,我会创建两个文件,一个是server.c,还有一个是client.c,用两个进程来模拟客户端和服务端进行通信,客户端往管道发消息,服务端读消息。
两段代码如下:
client.c
#include
#include
#include
#include
#include
#include
#include
#define FIFO "./fifo"
int main()
{
// 以写的方式打开管道文件
int fd = open(FIFO, O_WRONLY);
if (fd < 0){
perror("open pipefile");
exit(-1);
}
char buf[64];
while (1){
printf("Please Enter Message# ");
fflush(stdout);
// 使用read读取用户输入的数据
ssize_t s = read(0, buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
buf[s] = 0;
write(fd, buf, s+1);
}
else{
perror("read");
exit(-1);
}
}
return 0;
}
server.c
#include
#include
#include
#include
#include
#include
#include
#define FIFO "./fifo"
int main()
{
umask(0);
// 创建管道
int ret = mkfifo(FIFO, 0666);
if (ret == -1){
perror("make fifo");
exit(-1);
}
// 以读的方式打开管道
int fd = open(FIFO, O_RDONLY);
if (fd < 0){
perror("open fail");
exit(-1);
}
char buf[64];
while (1){
printf("wait client...\n");
ssize_t s = read(fd, buf, sizeof(buf)/sizeof(buf[0])-1);
if (s > 0){
// 正常读取
buf[s] = '\0';
printf("client say# %s", buf);
}
else if (s == 0){
// 客服端写端关闭,服务器读端读到文件末尾
printf("server exit...\n");
exit(0);
}
else{
// 读错误
perror("read");
exit(-1);
}
}
return 0;
}
注意,这里我们需要先将服务进程跑起来,这样管道才可以被创建,以读得方式打开管道,然后将客户端进程跑起来,以写的方式打开管道。客户端只要不写入数据,服务端读会处于阻塞状态,如果客户端退出,此时管道为空,服务端会读到文件结尾,此时read返回0,让服务端进程也退出。
代码运行结果如下:
两个进程通信时,我们查看fifo的大小:
可以发现,管道的大小没有发生变化。其实两个进程通信是在内存中进行的,并没有把数据写到管道中,因为管道只是一个符号性的文件。如果是在管道写数据,那么IO次数会很多,效率太低了。
共享内存区是最快的IPC形式。共享内存是在物理内存上申请一块空间,再让两个进程各自在页表建立虚拟地址和这块空间的映射关系。这样两个进程看到的就是同一份资源,这一份资源就叫做共享内存。
共享内存的数据结构: 其中shm_perm这个结构体中有key值(共享内存唯一标识符)
struct shmid_ds {
struct ipc_perm shm_perm; /* operation perms */
int shm_segsz; /* size of segment (bytes) */
__kernel_time_t shm_atime; /* last attach time */
__kernel_time_t shm_dtime; /* last detach time */
__kernel_time_t shm_ctime; /* last change time */
__kernel_ipc_pid_t shm_cpid; /* pid of creator */
__kernel_ipc_pid_t shm_lpid; /* pid of last operator */
unsigned short shm_nattch; /* no. of current attaches */
unsigned short shm_unused; /* compatibility */
void *shm_unused2; /* ditto - used by DIPC */
void *shm_unused3; /* unused */
};
两个进程通过共享内存进行通信需要经过以下几个步骤:
注意: 前两个步骤是为了让两个进程实现通信,后面两个步骤是释放共享内存空间,要不然就会内存泄漏了。(与我们之前用的malloc是类似的)
下面介绍共享内存函数:
函数原型:
#include
#include key_t ftok(const char *pathname, int proj_id); 功能: 获取一个共享内存的唯一标识符key
函数参数:
pathname:可以传入任何文件名
proj_id:只有是一个非0的数都可以
返回值:
成功返回key值,失败返回-1
函数原型:
#include
#include int shmget(key_t key, size_t size, int shmflg); 功能: 创建共享内存
函数参数:
key:传入ftok函数获取的共享内存唯一标识符
size:共享内存的大小(页(4kb)的整数倍)
shmflg:权限,由9个权限标准构成
这里介绍两个选项
IPC_CREAT: 如果底层存在这个标识符的共享内存空间,就打开返回,不存在就创建
IPC_EXCL: 如果底层存在这个标识符的共享内存空间,就出错返回
两个选项合起来用就可以穿甲一个权限的共享内存空间
返回值:
成功返回共享内存标识码值(给用户看的),失败返回-1
函数原型:
#include
#include void *shmat(int shmid, const void *shmaddr, int shmflg); 功能: 将共享内存空间关联到进程地址空间
参数:
shmid:共享内存标识符
shmaddr:指定连接地址。
shmfig:两个可能取值是SHM_RND和SHM_RDONLY
返回值: 成功返回一个指针(虚拟地址空间中共享内存的地址,是一个虚拟地址),失败返回-1
说明:
shmaddr为NULL,核心自动选择一个地址
shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整> 数倍。公式:shmaddr -(shmaddr % SHMLBA)
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存
函数原型:
#include
#include int shmdt(const void *shmaddr); 功能: 取消共享内存空间和进程地址空间的关联
参数:
shmaddr:共享内存的起始地址(shmat获取的指针)
返回值: 成功返回0,失败返回-1
函数原型:
#include
#include int shmdt(const void *shmaddr); 功能: 取消共享内存空间和进程地址空间的关联
参数:
shmaddr:共享内存的起始地址(shmat获取的指针)
返回值: 成功返回0,失败返回-1
函数原型:
#include
#include int shmctl(int shmid, int cmd, struct shmid_ds *buf); 功能: 控制共享内存
参数:
shmid:共享内存标识符
cmd:命令,有三个
IPC_STAT:
把shmid_ds结构中设置为共享内存当前关联值
IPC_SET:
把共享内存的当前关联值设置为shmid_ds数据结构中的值
IPC_RMID:
删除共享内存段
buf:指向一个报错这共享内存的模式状态和访问权限的数据结构
返回值: 成功返回0,失败返回-1
实例演示:
实例1: 获取共享内存唯一标识符,并创建一块共享内存
#include
#include
#include
#include
#include
#include
#define PATHNAME "."
#define PROJ_ID 0x666
#define SIZE 4096
int main()
{
// 先通过ftok函数 利用pathname和proj_id来生成一个共享内存标识符key,用来标识共享内存(给OS看的)
key_t key = ftok(PATHNAME, PROJ_ID);
if (key == -1){
// 标识符生成失败
perror("ftol fail");
exit(-1);
}
printf("key:%p\n", key);
// 创建内存空间
// IPC_CREAT 要创建的共享内存如果存在,就打开返回,不存在就创建
// IPC_EXCL 如果底层共享内存已经存在就出错返回
// 结合使用可以创建一个全新的共享内存
int shmid = shmget(key, SIZE, IPC_CREAT|IPC_EXCL|0664);
if (shmid < 0){
perror("shmget");
exit(-1);
}
printf("shmid:%d\n", shmid);
return 0;
}
代码运行结果如下:
实例2: 通过指令ipcs -m
查看ipc资源
实例3: 通过指令ipcrm -m shmid
删除共享内存,IPC的声明周期随内核
实例4: 开辟一块共享内存空间,然后将进程和这块共享内存关联起来,5秒后取消关联并删除共享内存空间
#include
#include
#include
#include
#include
#include
#define PATHNAME "."
#define PROJ_ID 0x666
#define SIZE 4096
int main()
{
key_t key = ftok(PATHNAME, PROJ_ID);
if (key == -1){
// 标识符生成失败
perror("ftol fail");
exit(-1);
}
printf("key:%p\n", key);
int shmid = shmget(key, SIZE, IPC_CREAT|IPC_EXCL|0664);
if (shmid < 0){
perror("shmget");
exit(-1);
}
printf("shmid:%d\n", shmid);
// 连接共享内存(关联)
char* str = (char*)shmat(shmid, NULL, 0);
sleep(5);
// 取消关联
if (shmdt(str) == -1){
perror("shmdt");
exit(-1);
}
// 删除共享内存段
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
同时打开命令行监控脚本:
while :; do ipcs -m; echo "#####################"; sleep 1;done;
和匿名管道那里一样,这里有client.c和server.c两个文件,还有一个comm.h一个头文件,里面存放两个进程公共的pathname和proj_id,这样两个进程就可以得到相同的共享内存唯一标识符。
这里我们选择使用服务端创建共享内存,然后连接到共享内存,让客户端也连接上这块共享内存,客户端写数据,服务端不断读
代码如下:
comm.h
#pragma once
#include
#include
#include
#include
#include
#include
#define PATHNAME "."
#define PROJ_ID 0x666
#define SIZE 4096
server.c
#include "comm.h"
int main()
{
// 先通过ftok函数 利用pathname和proj_id来生成一个共享内存标识符key,用来标识共享内存(给OS看的)
key_t key = ftok(PATHNAME, PROJ_ID);
if (key == -1){
// 标识符生成失败
perror("ftol fail");
exit(-1);
}
printf("key:%p\n", key);
// 创建内存空间
// IPC_CREAT 要创建的共享内存如果存在,就打开返回,不存在就创建
// IPC_EXCL 如果底层共享内存已经存在就出错返回
// 结合使用可以创建一个全新的共享内存
int shmid = shmget(key, SIZE, IPC_CREAT|IPC_EXCL|0664);
if (shmid < 0){
perror("shmget");
exit(-1);
}
printf("shmid:%d\n", shmid);
// 连接共享内存(关联)
char* str = (char*)shmat(shmid, NULL, 0);
//sleep(5);
// 服务端每隔一秒在显示器上刷新共享内存段中的数据
while (1){
printf("client say# %s\n", str);
sleep(1);
}
// 取消关联
if (shmdt(str) == -1){
perror("shmdt");
exit(-1);
}
// 删除共享内存段
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
client.c
#include "comm.h"
int main()
{
// client通过相同的pathname 和 proj_id 可以创建出一个和server.c相同的共享内存唯一标识符
key_t key = ftok(PATHNAME, PROJ_ID);
if (key == -1){
// 标识符生成失败
perror("ftol fail");
exit(-1);
}
// 直接获取服务端创建的共享内存
int shmid = shmget(key, SIZE, 0);
if (shmid < 0){
perror("shmget");
exit(-1);
}
// 连接共享内存(关联)
char* str = (char*)shmat(shmid, NULL, 0);
// 客户端每个5s在共享内存写数据
char start = 'a';
while (start <= 'z'){
str[start-'a'] = start;
++start;
sleep(3);
}
// 取消关联
if (shmdt(str) == -1){
perror("shmdt");
exit(-1);
}
return 0;
}
代码运行结果如下:
客户端每3s往共享内存多写入一个字符,服务端不断读取共享内存的数据。当其中一个进程终止,并不会影响另一个进程。
结论: 共享内存底层不提供任何同步与互斥的机制