目录
一、什么是IPC?
IPC是指操作系统提供的允许进程之间交换数据或信号的机制。由于每个进程拥有独立的虚拟地址空间,无法直接访问对方的内存,因此需要内核提供的特殊机制来实现通信。Linux进程间通信经历了从Unix最古老的管道通信到共享内存IPC通信的发展演变,在Linux多任务操作系统中,进程各自运行在独立的内存空间,IPC打破了这种隔离,实现了进程间的协同工作,现代项目工程往往由多个模块构成,IPC使得这些独立运行的进程模块能够共享数据、同步任务,从而提升项目工程的整体效率和功能扩展性。
二、进程IPC
1、管道
(1)定义
管道是Unix中最古老的进程间通信方式,一个进程连接到另一个进程的一个数据流称为一个管道。

who | wc -l,管道描述符 '|' 决定了who进程、wc进程建立管道连接,who进程将写入数据到管道,wc从管道中读取数据并输出结果到终端,从而完成这两个进程的管道通信。
(2)匿名管道

pipe系统调用用于创建匿名管道,pipefd[2]为文件描述符数组,pipefd[0]为读端,pipefd[1]为写端,创建管道成功返回0,创建失败返回错误代码。

上图所示的就是一个进程调用pipe系统接口后的初始状态,此时该进程拥有管道的两端,fd[0]为管道的读端,fd[1]为管道的写端,管道本身位于内核中。
cpp
#include<iostream>
#include<unistd.h>
using namespace std;
int main()
{
int fd[2]={0};
int n=pipe(fd);
if(n<0)
{
cerr<<"pipe"<<endl;
return 1;
}
cout<<"fd[0]:"<<fd[0]<<endl;
cout<<"fd[1]:"<<fd[1]<<endl;
return 0;
}
fd[2]为文件描述符数组,用于存储文件描述符,通过pipe(fd)系统调用来创建管道,则fd[0]用于读取管道数据,fd[1]用于写入管道数据,运行结果如下:

由于标准输入stdin、标准输出stdout、标准错误stderr分别占据文件描述符表的0、1、2位置,故管道读端fd[0]为3,fd[1]为4。
管道是Linux中最简单的IPC通信机制,常用于父子进程间的数据传输,需要fork子进程来实现管道通信。

当父进程fork子进程后,子进程将复制父进程的文件描述符表,父子进程都拥有相同的fd[0]、fd[1],都指向同一个管道对象,要实现父子进程间的管道通信,需要父子进程各自关闭不需要的文件描述符,如实现父读子写,则父进程关闭fd[1]写端,子进程关闭fd[0]读端,每个进程只保留各自需要的一端,从而实现父子进程间的管道通信。
cpp
#include<iostream>
#include<unistd.h>
#include<cstdio>
#include<cstring>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
void Childwrite(int wfd)
{
char buffer[1024];
int cnt=5;
cout<<"子进程开始写入"<<endl;
while(cnt--)
{
sleep(3);
snprintf(buffer,sizeof(buffer),"I am a child,pid:%d,cnt:%d",getpid(),cnt);
write(wfd,buffer,strlen(buffer));
}
cout<<"子进程写入完成"<<endl;
}
void Fatherread(int rrd)
{
char buffer[1024];
while(true)
{
buffer[0]=0;
ssize_t n=read(rrd,buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n]=0;
cout<<"child say:"<<buffer<<endl;
}
else if(n==0)
{
cout<<"n:"<<n<<endl;
cout<<"父子进程退出"<<endl;
break;
}
}
}
int main()
{
int fd[2]={0};
int n=pipe(fd);
if(n<0)
{
cerr<<"pipe"<<endl;
return 1;
}
cout<<"fd[0]:"<<fd[0]<<endl;
cout<<"fd[1]:"<<fd[1]<<endl;
pid_t id=fork();
if(id==0)
{
close(fd[0]);
Childwrite(fd[1]);
close(fd[1]);
exit(0);
}
close(fd[1]);
Fatherread(fd[0]);
close(fd[0]);
int status=0;
int ret=waitpid(id,&status,0);
if(ret>0)
{
printf("exit signal:%d,exit code:%d\n",(status)&(0x7F),(status>>8)&(0xFF));
}
return 0;
}
父进程先通过pipe系统调用创建管道,随后fork创建子进程,子进程关闭读端fd[0],父进程关闭写端fd[1],实现子写父读通信,子进程每隔3秒写入一条数据,共写入5次,父进程循环读取,当管道无数据时父进程会阻塞等待,close(fd[1])子进程关闭写端后,父进程read返回0并退出,最后通过waitpid对子进程进行回收,结果如下所示:

站在文件描述符的角度理解管道:


父进程调用pipe后,内核创建管道对象,并在父进程的文件描述表中分配两个空闲位置,0、1、2已被标准输入、输出、错误占用,那么新分配的文件描述符就是3、4,文件描述符3指向的就是管道的读端,4指向的就是管道的写端,fork创建子进程后,子进程会复制父进程的文件描述符表,且父子进程指向同一个管道对象。为了实现父子进程间的单向通信,父子进程需要关闭各自不需要的那一端,如要实现父写子读,则父进程需要关闭读端fd[0],子进程关闭写端fd[1],从而实现二者之间的通信。
站在内核角度理解管道:

当进程调用pipe系统接口时,内核会在内存中创建一个管道对象,这个管道对象本质上是一个循环缓冲区,由多个数据页组成,用于暂存写入的数据。对于管道两端的文件描述符,内核会为它们分别创建对应的file结构体,这两个file结构体通过private_data指针指向同一个管道对象,当进程调用write向管道写入数据时,内核会通过file结构体找到对应的管道对象,将数据拷贝到管道的环形缓冲区中,当进程调用read从管道读取数据时,内核将从管道缓冲区中取出数据拷贝到用户空间。
管道的一个重要特性就是引用计数机制,每个指向管道的file结构体都维护着一个引用计数。当进程关闭文件描述符时,对应的file结构体引用计数减一,只有当所有读端的file结构体都被关闭后,写入操作才会触发SIGPIPE信号,只有当所有写端的file结构体都被关闭后,读取操作才会返回0表示文件结束。
可以看出,看待管道,就如同看待文件一样,管道的使用基本与文件一致,迎合了"Linux一切皆文件"的设计思想。
管道特点:
(1) 管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信,通常一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。
(2) 进程退出,管道释放,所以管道的生命周期随进程。
(3) 内核会对管道操作进行同步与互斥。
(4) 管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。

(3)命名管道
匿名管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。如果需要在不相关的进程之间交换数据,则需要使用FIFO文件来完成这项工作,FIFO称为命名管道,命名管道是一种特殊类型的文件。

bash
mkfifo filename
命名管道可以从命令行上创建,mkfifo是一个创建命名管道的Linux命令,它能让不相关的进程也能通过管道通信,在终端直接输入mkfifo管道名就能创建一个命名管道文件,可通过-m选项设置权限

此外命名管道也可以在程序中创建,通过mkfifo系统调用实现,pathname用于指定要创建的管道文件路径,mode用于指定权限,创建成功返回0,失败返回-1。
创建完命名管道后,两个进程可以像操作普通文件一样打开它,如一个进程以只读方式打开,另一个以只写方式打开,当读端和写端都打开后,数据就可以从写端流入读端,如果某一端先打开,则进程会阻塞等待另一端出现,这就是命名管道的同步特性。
命名管道是Linux文件系统中的真实文件,使用完后需要像删除普通文件一样用rm命令删除,否则将一直存在。
下面实现的就是服务端和客户端通过命名管道来完成二者之间的通信,客户端将数据写入命名管道中,服务端从命名管道中读取客户端写入的数据。
cpp
#include<iostream>
#include<string>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#define FIFO_FILE "./file"
using namespace std;
int main()
{
umask(0);
int n=mkfifo(FIFO_FILE,0666);
if(n<0)
{
cerr<<"mkfifo error"<<endl;
return 1;
}
cout<<"mkfifo success"<<endl;
int fd=open(FIFO_FILE,O_RDONLY);
if(fd<0)
{
cerr<<"open fifo error"<<endl;
return 2;
}
cout<<"open fifo success"<<endl;
while(true)
{
char buffer[1024];
int n=read(fd,buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n]=0;
cout<<"client say:"<<buffer<<endl;
}
else if(n==0)
{
cout<<"client quit,me to"<<endl;
break;
}
else
{
cout<<"read error"<<endl;
break;
}
}
close(fd);
n=unlink(FIFO_FILE);
if(n==0)
{
cout<<"remove fifo success"<<endl;
}
else
{
cout<<"remove fifo error"<<endl;
}
return 0;
}
umask(0)确保创建的管道权限不受默认掩码的影响,服务端通过调用mkfifo在当前路径下创建命名管道文件file,权限为0666(读写权限),并以只读方式打开管道,open会阻塞等待客户端,直到客户端以只写方式连接,通过while循环持续读取并显示客户端发送的消息,当读到0字节时,表示客户端关闭了写端,程序退出,最后通过unlink删除管道文件。
cpp
#include<iostream>
#include<string>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#define FIFO_FILE "./file"
using namespace std;
int main()
{
int fd=open(FIFO_FILE,O_WRONLY);
if(fd<0)
{
cerr<<"open fifo error"<<endl;
return 1;
}
cout<<"open fifo success"<<endl;
string message;
while(true)
{
cout<<"Please Enter:";
getline(cin,message);
write(fd,message.c_str(),message.size());
}
close(fd);
return 0;
}
客户端以只写方式打开管道文件,并循环读取用户输入,每行输入后立即写入管道发送给服务端,需要注意的是客户端必须等待服务端先打开管道才能成功open,即服务端需先于客户端启动,close(fd),客户端退出时关闭写端文件描述符fd,此时服务端read将返回0,服务端检测到客户端退出,清理管道文件并结束。
Makefile
bash
.PHONY:all
all:client server
client:client.cc
g++ -o $@ $^ -std=c++11
server:server.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf client server
通过Makefile即可完成一键化编译client.cc、server.cc,生成client、server可执行文件,生成可执行文件之后,启动服务端server,再打开另一个终端,在另一个终端中启动客户端client,即可实现服务端和客户端二者的通信,运行结果如下所示:
服务端:

客户端:

可以看出服务端和客户端成功实现了二者的通信,服务端从管道中接收客户端的数据并显示在终端上,最后客户端退出,服务端read返回0,清理管道文件并退出,结束二者通信。
2、共享内存
(1)特性

共享内存是最快的IPC形式,一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递就不再涉及到内核,即进程不再通过执行进入内核的系统调用来传递彼此的数据,因此共享内存传输进程数据速率就快了,但共享内存没有同步和互斥,缺乏访问控制,会带来并发问题。
(2)shmget

shmget系统调用用于创建共享内存,key为这个共享内存段名字,size为共享内存的大小,shmflg由九个权限标志构成,IPC_CREAT表示若共享内存不存在,创建并返回,若共享内存存在,获取并返回。IPC_CREAT | IPC_EXCL 组合表示若共享内存不存在,创建并返回,若共享内存已存在,出错返回。创建成功后返回一个非负整数,作为该共享内存的标识码,失败则返回-1。
(3)shmat

shmat系统调用用于将共享内存段连接到进程地址空间,shmid为共享内存标识,shmaddr用于指定连接的地址,shmflg可能取值为SHM_RND、SHM_RDONLY,连接成功返回一个指针,指向共享内存第一个节,失败返回-1。
(4)shmdt

shmdt系统调用用于将共享内存段与当前进程脱离,参数shmaddr为由shmat所返回的指针,脱离成功返回0,失败则返回-1,将共享内存段与当前进程脱离并不等于删除共享内存段。
(5)shmctl

shmctl系统调用用于控制共享内存,shmid为由shmget返回的共享内存标识码,cmd表示将要采取的动作,有三个可取值,IPC_STAT表示把shmid_ds描述共享内存结构体中的数据设置为共享内存的当前关联值,IPC_SET表示在进程有足够权限的前提下,把共享内存的当前关联值设置为shmid_ds数据结构中给出的值,IPC_RMID用于删除共享内存段,buf指针指向一个保存着共享内存的模式状态和访问权限的数据结构,调用成功返回0,失败返回-1。
下面实现的就是一个利用共享内存来实现进程间通信的例子:
cpp
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<unistd.h>
int main()
{
key_t key = ftok(".", 's');
int shmid = shmget(key, 4096, IPC_CREAT | 0666);
if(shmid == -1)
{
perror("shmget");
exit(1);
}
char *shmaddr = (char*)shmat(shmid, NULL, 0);
if(shmaddr == (char*)-1)
{
perror("shmat");
exit(1);
}
printf("服务端启动,等待数据...\n");
while(1)
{
if(shmaddr[0] != '\0')
{
printf("收到: %s\n", shmaddr);
shmaddr[0] = '\0';
}
usleep(100000);
}
shmdt(shmaddr);
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
服务端首先调用ftok生成一个唯一的key值,确保客户端和服务端能使用相同的key找到同一块共享内存,接着调用shmget创建共享内存,通过shmat将共享内存附加到当前进程的地址空间,通过while循环进行共享内存中字符的读取。
cpp
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/ipc.h>
#include<sys/shm.h>
int main()
{
key_t key = ftok(".", 's');
int shmid = shmget(key, 4096, 0666);
if(shmid == -1)
{
perror("shmget");
exit(1);
}
char *shmaddr = (char*)shmat(shmid, NULL, 0);
if(shmaddr == (char*)-1)
{
perror("shmat");
exit(1);
}
printf("客户端启动,输入消息: \n");
char input[256];
while(1)
{
printf("> ");
fgets(input, sizeof(input), stdin);
input[strcspn(input, "\n")] = '\0';
if(strcmp(input, "quit") == 0)
break;
strcpy(shmaddr, input);
}
shmdt(shmaddr);
return 0;
}
客户端同样先通过ftok生成与服务端相同的key,随后调用shmget获取已经存在的共享内存,获取到shmid后,同样调用shmat附加共享内存,获得指向共享内存的指针,客户端进入循环,等待用户输入,将输入内容拷贝至共享内存。
Makefile
bash
.PHONY:all
all:client server
g++ -o client client.cc -std=c++11
g++ -o server server.cc -std=c++11
.PHONY:clean
clean:
rm -rf client server
通过Makefile就可实现客户端和服务端的一键化编译,生成可执行文件client、server,须先启动服务端再启动客户端,服务端负责创建共享内存并进入等待状态,客户端启动后获取共享内存并开始写入数据,服务端读取并打印数据,从而完成二者的通信,结果如下所示:


三、写在最后
管道和共享内存是Linux IPC通信的经典代表,管道可以说是Unix哲学的灵魂体现,管道用最简单的设计实现了进程间通信,而共享内存是高性能系统的基石,共享内存是唯一一种实现零拷贝的IPC机制,其他IPC方式都需要经过内核缓冲区,数据至少拷贝两次---------从发送进程到内核,从内核到接收进程,共享内存直接映射同一块物理内存,内核不参与数据传输,只在附加和分离时介入,从而将通信速度推向内存访问的极限。管道和共享内存在使用上各有各的适配场景,传递小数据时,管道是最优雅的选择,追求传输性能时,共享内存是不二选择,管道和共享内存共同构成了Linux进程通信的基石,理解了它们,就理解了Linux进程IPC的半壁江山。