一、进程间通信的基本概念:
什么是进程间通信?我们都知道进程具有独立性,进程地址空间相互独立,每个进程都有各自的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核,所以我们需要在内核中开辟一块缓冲区,进程1把数据从用户空间拷贝到内核缓冲区,进程2再把数据从内核缓冲区读走,内核提供的这种机制我们称之为进程间通信。

那么进程间通信如何来实现?实现进程间通信不能只有一种方式。那么下面我将列举出几种常见的进程间通信方式:
匿名管道、命名管道、共享内存、消息队列、信号。
管道是使用最简单的一种方式。下面我将从管道开始讲起。
二、匿名管道:
管道是一种最基本的ipc机制,匿名管道应用于有血缘关系的进程之间(不非得是父子进程,爷孙进程也可以),完成数据传递,那么如何创建一个管道?
提到创建管道,就不得不得到我们的pipe函数了。
cpp
#include<unistd.h>
功能:创建一个匿名管道
int pipe(int fd[2]);
参数fd:文件描述符数组,其中fd[0]表示读端,fd[1]表示写端。
返回值:如果成功则返回0,失败返回错误码。
fd[0]是读端, fd[1]是写端 ,向管道读写数据是通过使用这两个文件描述符进行的,读写管道的实质是操作内核缓冲区
对于文件描述符,我这里再相对而言扩容一点知识:
cppstruct task_struct { // ... 进程的各种信息 pid_t pid; // 进程ID struct files_struct *files; // 关键:指向进程文件表 // ... };首先,task_struct这个进程描述符里面有一个指针files,这个files指针指向了一个file_struct的结构体(也就是进程文件表),这个进程文件表,每个进程独有一份,而这个file_struct里面一个指针数组fd_array[ ],这个数组的每个对象是指向文件对象(也就是struct file)的指针,而我们所说的文件描述符,就是这个数组的下标。
所以:文件描述符的本质就是数组的下标,这个数组的基本元素是一个文件指针。
理解了上面的知识对应理解进程间通信绝对是相当于有了一个buff加持。
接着再谈匿名管道。
管道具有一下的特点:
1、管道的本质是一块内核缓冲区,上面我们提到了,这里不做过多的阐述。
2、由两个文件描述符引用,一个用来表示读端,一个用来表示写端。
3、规定从管道的写端流入管道,从读端流出管道。
4、管道的读端与写端默认都是阻塞的。
5、一般而言,进程退出,管道就进行释放,所以管道的生命周期随进程。
6、只能用于有公共祖先的进程之间进行通信。
7、管道是半双工的,数据只能从一个方向流到另一段,如果需要双方向通信,需要创建两个管道,来对应不同的方向。
8、一般而言,内核会对管道操作进行同步和互斥。
管道的原理:
管道的本质就是内核缓冲区,内部使用环形队列来实现。
默认缓冲区的大小为4k,可以使用ulimit-a的命令来获取大小。

父子进程使用匿名管道进行通信:
首先,我们使用pipe函数创建一个匿名管道,然后一般会fork一个字进程,然后通过我们创建出来的匿名管道来实现进程间消停,所以只要两个进程中存在血缘关系,这里的血缘关系不单指父子关系,只有具有公共的祖先都可以采用管道的方式来进行通信,父子进程就用相同的文件描述符,且指向同一管道,对于文件描述符,上述我做了详细的讲解,但是,没有血缘关系的进程不能获得pipe函数产生的两个文件描述符,所以就不能使用同一个管道进行通信。
第一步:
首先,父进程先创建管道:

第二步:父进程通过fork函数创建子进程:

第三步:父进程关闭读端,子进程关闭写端:

对于上述过程的总结:
首先,父进程通过pipe函数创建管道,得到两个文件描述符fd[0],和fd[1],分别指向管道的读端和写端。其次,父进程通过fork函数来创建子进程,那么子进程也就有了两个文件描述符,这两个文件描述符指向同一个管道,所以,父进程关闭管道读端,子进程关闭管道写端,父进程向管道内写入数据,那么子进程就可以从这个管道里面来读取数据,这样就实现了父子间的进程间通信,那么下面我们将用代码来验证这一说法:
cpp
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <cstring>
#include <cstdio>
#define SIZE 1024
using namespace std;
int main()
{
// 先创建一个匿名管道
int ret = -1;
int fds[2];
ret = pipe(fds);
if (ret == -1)
{
perror("pipe");
return 1;
}
// 创建子进程
pid_t id = fork();
if(id < 0)
{
perror("fork false");
return 1;
}
else if(id == 0)
{
// 子进程负责读,关闭写端
close(fds[1]);
char buffer[SIZE]; // 添加buffer声明
memset(buffer, 0, SIZE);
ret = read(fds[0], buffer, SIZE - 1); // 留一个位置给字符串结束符
if(ret > 0) {
printf("child process buffer: %s\n", buffer);
} else if(ret == 0) {
printf("child: pipe closed by parent\n");
} else {
perror("child: read error");
}
// 关闭读端
close(fds[0]);
// 进程退出
exit(0);
}
else
{
// 父进程负责写,关闭读端
close(fds[0]);
char buffer[SIZE];
printf("parent: please input message: ");
fgets(buffer, sizeof(buffer), stdin);
// 去掉fgets可能读取的换行符
size_t len = strlen(buffer);
if(len > 0 && buffer[len-1] == '\n') {
buffer[len-1] = '\0';
}
ret = write(fds[1], buffer, strlen(buffer));
if(ret < 0) {
perror("parent: write error");
} else {
printf("parent: sent %d bytes\n", ret);
}
// 关闭写端,让子进程知道写入结束
close(fds[1]);
// 等待子进程结束
waitpid(id, nullptr, 0);
printf("parent: child process exited\n");
}
return 0;
}

这样就完成了,父子进程之间的进程间通信。
需要注意的一点是,在进行读操作的时候,只要有数据,就能返回读出的字节数,但是如果没有数据:
在写端全部关闭的时候:
read会解除阻塞,返回0,表示这个文件读到了末尾。
读端没有全部关闭:
如果缓冲区写满了,write就进行阻塞,否则继续write。
进程间通信的前提是什么?是让两个不同的进程看到同一块资源,都看不到同一块资源或者说访问同一块资源,那么还怎么通信呢?那么在匿名管道中,父子进程是如何看到同一块资源的?
通过继承,fork创建的子进程会赋值父进程的地址空间,包括打开的文件描述符,这意味着文件描述符会被复制到子进程中,指向同一个管道对象,管道在操作系统内核中维护一个缓冲区,文件描述符只是对这个缓冲区的引用,所以,父子进程可以通过继承的文件描述符访问同一个管道缓冲区,从而进行资源共享,也就是我们所说的看到同一块资源。
我们都知道,管道有一个读端和一个写端,那么如果写端不断写入,而读端不读取,会发生什么?
cpp
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main()
{
int fds[2];
pipe(fds);
if (fork() == 0)
{
// 子进程
close(fds[1]);
cout << "子进程:休眠中..." << endl;
sleep(5);
cout << "子进程:开始读取" << endl;
char buf;
read(fds[0], &buf, 1); // 只读1个字节
close(fds[0]);
exit(0);
}
else
{
// 父进程
close(fds[0]);
// 创建一个大于管道缓冲区的大数据
char* data = new char[100000]; // 100KB
cout << "开始写入大量数据..." << endl;
cout << "注意:当管道满时,下面的输出会卡住!" << endl;
// 这个write调用会阻塞,因为数据量大于管道缓冲区
write(fds[1], data, 100000);
// 如果执行到这里,说明子进程开始读取了
cout << "写入完成,子进程已开始读取" << endl;
delete[] data;
close(fds[1]);
wait(nullptr);
}
return 0;
}
在上面的代码中,父进程也就是写端,尝试一次写入100000(100kb)字节的数据,但是管道缓冲区的大小是有限的,通常是64kb,所以当父进程写入的数据量超过缓冲区的大小的时候,写入操作会堵塞。子进程先休眠5秒,然后开始读取数据,在这5秒呢,父进程再写入过程中会因为管道满了而则色,所以当子进程休眠结束后并开始读取数据的时候,府进程的写入操作才能继续完成。
由此,我们可以得出如下结论:
写端在缓冲区满时会自动阻塞,等待读端读取数据,从而防止数据丢失。
言外之意,这个管道就像一根水管,在这根水管里面的水足够多的时候,要放点水出去,才能继续向里面注水。
那么如果将写端关闭,读端不断读取会发生什么现象呢?
cpp
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <cstring>
#include <cstdio>
#define SIZE 1024
using namespace std;
int main()
{
// 先创建一个匿名管道
int ret = -1;
int fds[2];
ret = pipe(fds);
if (ret == -1)
{
perror("pipe");
return 1;
}
// 创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork false");
return 1;
}
else if (id == 0)
{
// 子进程负责读,关闭写端
close(fds[1]);
int read_count = 0;
while (1)
{
char buffer[SIZE];
memset(buffer, 0, SIZE);
ret = read(fds[0], buffer, SIZE - 1);
printf("child: read returned %d\n", ret); // 打印返回值
if (ret > 0)
{
printf("child process buffer: %s\n", buffer);
read_count++;
}
else if (ret == 0)
{
printf("child: pipe closed by parent (read_count: %d)\n", read_count);
break;
}
else
{
perror("child: read error");
break;
}
}
// 关闭读端
close(fds[0]);
// 进程退出
exit(0);
}
else
{
// 父进程负责写,关闭读端
close(fds[0]);
// 只写入有限的数据
for (int i = 0; i < 5; i++)
{
char buffer[SIZE];
snprintf(buffer, SIZE, "Message %d from parent", i + 1);
ret = write(fds[1], buffer, strlen(buffer));
if (ret < 0)
{
perror("parent: write error");
}
else
{
printf("parent: sent %d bytes\n", ret);
}
sleep(1); // 每秒写一次
}
cout << "父进程:关闭写端文件描述符" << endl;
// 关闭写端,让子进程知道写入结束
close(fds[1]);
// 等待子进程结束
waitpid(id, nullptr, 0);
printf("parent: child process exited\n");
}
return 0;
}

当写端关闭后,读端的read调用会返回0,表示没有更多数据可读。
接着往下看。
如果子进程(也就是读端)进行休眠,那么父进程将做什么?父进程会进行等待,直到子进程休眠结束并开始读取数据。
cpp
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <cstring>
#include <cstdio>
#define SIZE 1024
using namespace std;
int main()
{
// 先创建一个匿名管道
int ret = -1;
int fds[2];
ret = pipe(fds);
if (ret == -1)
{
perror("pipe");
return 1;
}
// 创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork false");
return 1;
}
else if (id == 0)
{
// 子进程负责读,关闭写端
close(fds[1]);
cout << "子进程:开始休眠5秒..." << endl;
sleep(5); // 子进程休眠5秒
cout << "子进程:休眠结束,开始读取数据" << endl;
int count = 0;
while (1)
{
char buffer[SIZE];
memset(buffer, 0, SIZE);
ret = read(fds[0], buffer, SIZE - 1);
if (ret > 0)
{
printf("child process buffer: %s\n", buffer);
}
else if (ret == 0)
{
printf("child: pipe closed by parent\n");
break;
}
else
{
perror("child: read error");
break;
}
}
// 关闭读端
close(fds[0]);
// 进程退出
exit(0);
}
else
{
// 父进程负责写,关闭读端
close(fds[0]);
cout << "父进程:准备写入数据,但子进程在休眠..." << endl;
// 父进程会阻塞在这里,因为子进程在休眠,没有读取数据
// 当管道缓冲区满时,write会阻塞
char buffer[SIZE] = "Hello from parent process!";
ret = write(fds[1], buffer, strlen(buffer));
if (ret < 0)
{
perror("parent: write error");
}
else
{
printf("parent: sent %d bytes\n", ret);
}
// 关闭写端,让子进程知道写入结束
close(fds[1]);
// 等待子进程结束
waitpid(id, nullptr, 0);
printf("parent: child process exited\n");
}
return 0;
}

在子进程休眠的这10秒中,父进程会一直进行等待,直到子进程结束休眠开始读取数据。
那么如何把这个阻塞改成非阻塞呢?
通过fcntl这个函数:
int fcntl(int fd,int cmd,...)
第一个参数就是文件描述符
第二个参数就是命令参数,比如
F_GETFL- 获取文件状态标志,F_SETFL- 设置文件状态标志。
O_NONBLOCK- 非阻塞模式。这个参数如何使用这里就不过多阐述了,可以通过man fcntl命令进行查看。
匿名管道有一个巨大的缺点,就是只能让两个有血缘关系的进程完成进程间通信,这有点太过于局限了,那么如果是没有血缘关系的进程呢?我们可以通过命名管道来实现通信。
三、命名管道:
什么是命名管道?FIFO常被称之为命名管道,FIFO是linux基础文件类型中的一种,我们可以通过ls-l来查看文件类型。
如何创建命名管道?创建命名管道的方式有两种,一种是直接使用命令,第二种是通过mkfifo函数来创建命名管道。
int mkfifo(const char *filename,mode_t mode);
第一个参数:
pathname--管道文件路径
第二个参数:
mode--文件权限模式 比如0666(所以用户可读写) ,0644(用户可读写,其他用户只读), 0600(仅用户可读写)。
那么下面将用代码来实现一个简单的命名管道完成进程间通信。
cpp
//createpipe.cc
#include <iostream>
#include <sys/stat.h>
#define PIPE_NAME "/tmp/myfifo"
using namespace std;
int main()
{
int n = mkfifo(PIPE_NAME, 0666);
if (n == -1)
{
perror("mkfifo false\n");
exit(1);
}
else
{
cout << "创建FIFO文件成功" << PIPE_NAME << endl;
}
return 0;
}
cpp
//客户端,负责写入数据
#include<iostream>
#include<stdlib.h>
#include<unistd.h>
#include<fcntl.h>
#include<string.h>
#include<sys/types.h>
#define PIPE_NAME "/tmp/myfifo"
using namespace std;
int main()
{
//这是一个客户端,用来写入数据,发送到服务器
int wfd;
cout<<"客户端:开始连接服务器..."<<endl;
wfd=open(PIPE_NAME,O_WRONLY);
cout<<"客户端:已经连接服务器,开始发送消息..."<<endl;
char message[1024];
fgets(message,sizeof(message),stdin);
write(wfd,message,strlen(message)+1);
cout<<"客户端:消息已经发送出去了"<<endl;
close(wfd);
return 0;
}
cpp
//服务端,负责读取客户端发送的数据
#include<iostream>
#include<iostream>
#include<unistd.h>
#include<fcntl.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#define PIPE_NAME "/tmp/myfifo"
using namespace std;
int main()
{
// 这是我们的服务端,用来读客户端写入的数据
int rfd;
char buffer[1024];
// 创建命名管道
if (mkfifo(PIPE_NAME, 0666) == -1)
{
// 如果管道已存在,可以继续使用
if (errno != EEXIST)
{
perror("mkfifo");
exit(1);
}
}
cout << "我是服务端,我正在等待客户端连接" << endl;
rfd = open(PIPE_NAME, O_RDONLY);
cout << "客户端连接成功" << endl;
cout << "开始读取数据" << endl;
int ret = read(rfd, buffer, sizeof(buffer));
cout<<"服务端收到"<<endl<<buffer<<endl;
close(rfd);
unlink(PIPE_NAME);
return 0;
}
首先在createpipe里,我们创建了一个命名管道,然后通过readpipe.cc和writepipe.cc分别实现了读和写,readpipe.cc实现的是服务端,wirtepipe.cc实现的是客户端,客户端写数据,然后服务端来读取数据,这样就实现了一个简单的,通过命名管道的进程间通信。
那么下面,我们来看一下运行的结果:
客户端:
服务端:
如上就通过命名管道实现了进程间通信。
命名管道并不要求两个进程直接具有血缘关系,这是命名管道相比于匿名管道的优势所在。
四、共享内存:
进程是操作系统分配资源的基本单位,因此,不同的两个进程,它们的虚拟地址空间是不同的,因为要防止它们互相影响,不同进程能顺利进行通信的本质是不同的进程看到同一份资源,都看不到共同的资源,那么如何进行通信呢?System V IPC提供了共享内存的设施,可以让不同的进程看到一块资源。

下面将通过代码更好地展示这一点:
先创建共享内存,linux提供了shmget函数用来创建共享内存,该函数创建由键值key标识的共享内存块,并返回标识号。
下面我讲通过一段比较简单的代码来为大家展示,如何使用共享内存,在这里,我使用了生产者消费者模型,如果有学习过线程的朋友应该有所了解,这个模型我会在线程那里详细介绍。
cpp#include <iostream> #include <unistd.h> #include <sys/shm.h> #include <sys/ipc.h> #include <cstring> using namespace std; #define NUM 1024 // 定义共享内存这个结构体: struct shared_mm { int _flag; // 0:空 1:有新数据 2:传输结束 char buffer[NUM]; }; enum//枚举类型,枚举错误退出码 { SHMAT_ERR = 1, SHMGET_ERR }; class ShMemory { public: ShMemory(key_t key) : _key(key) { // 1、创建共享内存 _shmid = shmget(key, sizeof(struct shared_mm), IPC_CREAT | IPC_EXCL | 0666); if (_shmid == -1) { // If exists, get existing _shmid = shmget(key, sizeof(struct shared_mm), 0); if (_shmid == -1) { std::cerr << "创建共享内存失败!" << std::endl; exit(SHMGET_ERR); } } // 2、返回一个虚拟地址空间 _shmptr = (struct shared_mm *)shmat(_shmid, 0, 0); if (_shmptr == (void *)-1) { cout << "空间映射成虚拟地址失败!" << endl; exit(SHMAT_ERR); } // 走到这里说明共享内存创建成功了!!! memset(_shmptr->buffer, 0, NUM); // 清空缓冲区 _shmptr->_flag = 0; // 初始化标记位为0 cout << "共享内存创建成功" << endl; } ~ShMemory() { // 将共享内存从进程中分离 if (_shmptr != nullptr && _shmptr != (void *)-1) { shmdt(_shmptr); } shmctl(_shmid, IPC_RMID, nullptr); // 删除共享内存 } bool writedata(const char *data) { // 先从标记位来判断共享内存的状态 if (_shmptr->_flag != 0) { // 说明此时共享内存不为空,不能进行数据写入 return false; } // 走到这里说明此时共享内存为空,是不是就可以进行数据写入了? strncpy(_shmptr->buffer, data, NUM - 1); // 将传进来的参数拷贝到这个共享内存中 _shmptr->buffer[NUM - 1] = '\0'; // 确保以\0结束 _shmptr->_flag = 1; // 表示有此时共享内存有新数据了(我们刚刚写入的) return true; } char *readdata() // 从共享内存读取数据 { // 什么时候才能读取数据?前提得是有新数据吧?换言之也就是标记位为1的时候 if (_shmptr->_flag != 1) { return nullptr; // 没有新数据直接返回空指针即可 } // 走到这里说明此时共享内存中有新数据!!! return _shmptr->buffer; } int getflag() // 返回标志位 { return _shmptr->_flag; } void setflag(int flag) { _shmptr->_flag = flag; // 设置标志位 } private: // _表示为该类内部的成员变量 int _shmid; key_t _key; struct shared_mm *_shmptr; };这个头文件中包含了一个类shMemory表示共享内存,里面有三个成员变量_shmid,_key,以及指向struct shared_mm结构的指针_shmptr。
在这个struct shared_mm结构中,我们有两个变量一个是缓冲区buffer,一个是_flag,这个_flag变量是为了实现同步机制的,0表示共享内存为空,1表示共享内存中有新的数据,2表示不再进行写入数据了,也就是完成通信了。
生产者进程代码如下:
cpp
// 生产者
#include "SharedMemory.hpp"
#include <iostream>
#include <string>
#include <unistd.h>
using namespace std;
int main(int argc, char *argv[])
{
ShMemory shm(0x12345); // 固定k值 类实例化这个对象自动调用构造函数进行创建共享内存
// 什么是生产者?产生数据呀
if (argc != 2)
{
cout << "请输入./shmprod启动生产者" << endl;
}
cout << "生产者进程启动!" << endl;
string input; // 从命令行输入的数据
int count=0;//表示第几次写入的数据
while (true)
{
while (shm.getflag() == 1) // 循环等待消费者读取数据
{
// 只要进入这个循环说明此时共享内存中有新的数据,但是消费者并没有进行读取这个数据
usleep(100000); // 等待100ms
std::cout << "等待消费者读取..." << std::endl;
}
// 走到这里说明可以生产数据了
cout << "#请输入数据:" << endl;
getline(cin, input);
if (input == "exit")
{
shm.setflag(2); // 设置标志位为2,表示退出了
break;
}
string message = "消息#" + to_string(++count) + ": " + input;
if (shm.writedata(message.c_str()))
{
cout << "成功写入:" << message << endl;
sleep(2);
}
else
{
cout << "写入失败,共享内存忙" << endl;
}
}
return 0;
}
消费者进程代码:
cpp
// 消费者
#include "SharedMemory.hpp"
#include <iostream>
#include <string>
#include <unistd.h>
using namespace std;
int main(int argc, char *argv[])
{
if (argc != 2)
{
cout << "请输入./shmcons启动消费者" << endl;
}
int count=0;//标记第几次收到的数据
ShMemory shm(0x12345); // 使用相同的key
cout << "消费者进程启动!" << endl;
while (true)
{
// 是一个死循环
if (shm.getflag() == 2)
{
cout << "收到终止信号,消费者退出" << endl;
sleep(2);
}
// 走到这里说明没有退出,要读取数据
char *data = shm.readdata();
if (data != nullptr)
{
// 进入这里说明成功读取到了数据
cout <<"["<<++count<<"]"<<"我收到了数据:" << data << endl;
shm.setflag(0); // 把数据读取之后再把共享内存标志位设置为0,表示此时为空
}
else
{
// 说明没有读取到数据,要进行等待,等待生产者进程生产数据
sleep(2); // 两秒钟打印一次
cout << "等待生产者生产数据..." << endl;
}
}
return 0;
}
对于上述代码,我都做了详细的注释,需要补充的就是,这份代码可以进一步进行改善,比如使用互斥量更好地实现进程同步。在这里我目的是为了给大家讲清楚这个共享内存的接口,就不做深入改善了!
在两个终端分别输入./shmproc与./shmcons就可以进行两个不同的进程通过这个共享内存进行通信!


使用ipcs命令就可以看到我们刚刚创建的共享内存,如果想把这个共享内存删除,可以使用ipcrm -m +对应共享内存的shmid就可以删除这个共享内存,这个shmid,你使用ipcs命令就可以查看到!
至此进程间通信部分就写完了,后续部分可能会补充消息队列或者信号量的知识!
本文至此结束,如上述内容对您有所帮助可以点赞收藏,关注一下,如有错误可在评论区指出,本人会及时进行更正。
后续会持续更新linux相关的内容。


