前言
本节我们开始学习进程间通信相关的知识,并详细探讨一下管道,学习匿名管道和命名管道的原理和代码实现等相关操作…
目录
1. 进程间通信背景
我们知道进程是具独立性的。但是,相互之间还是需要进行一些信息交互,简称为 IPC (Inter - Process Communication)
1.1 进程通信的目的:
- 数据传输: 一个进程需要将它的数据发送给另一个进程。
- 资源共享: 多个进程之间共享同样的资源。 比如:共享内存库
- 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。 比如:进程等待
- 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
需要多进程进行协同处理一件事情(并发处理)。单纯的数据传输,一个进程想把数据发给另一个进程。多进程之间共享同样的资源。一个进程想让另一个进程做其他的事情,进程控制。
进程间通信的发展历史:
- 管道 :最古老的进程间通信的形式
- System V进程间通信 (用的非常少了,设计的非常重,更多的用来本地通信)
- POSIX进程间通信 (设计的很轻,可以本地,可以做成网络,因为里面有套接字)
注意:我们学习的是 POSIX进程间通信
2 管道的引入:
刚学Linux时,就接触过
竖划线| 的 操作.
- 管道是Unix中最古老的进程间通信的形式.
- 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道.
1.进程间通信的前提条件:
两个进程看到同一份资源才具备通信的条件
1.通信之前,让不同的进程看到同一份资源(文件、内存块…)
2.进程间通信,就是让两个进程如何看到同一份资源
3.资源的不同,决定了,不同种类的通信方式!2.如何才能让两个进程看到同一份资源?:
让两个进程同时看到磁盘上的同一份文件:
这种方法,要考虑CPU 和外设之间得到读写速度,效率太低了 因为通信是一个相对常规的操作,将数据刷到外设,再从外设上读取,效率太低了,
- 让两个进程打开同一个文件:
重点注意:
管道只能单向通信。
管道是基于文件实现的,管道就是文件,两个进程之间通过文件来实现的。
进程间的通信,大部分都是基于内存级别的,不会刷新到磁盘里面,都是临时数据
进程退出,文件描述符会被关掉,但文件不一定会被关闭。
进程通信的核心思想:让两个进程获取到同一份资源
2.1 匿名管道:
2.1.1 匿名管道的原理:
- 如何做到让不同的进程,看到同一份资源的呢?
fork让子进程继承—能够让具有血缘关系的进程进行进程间通信(同一文件Struct file)—常用于父子进程。。
- struct file中有个引用计数,是指对该文件的引用数量,用于跟踪文件被多少个进程或内核对象所引用。
- 父进程指向什么文件,子进程也指向什么文件。
- 这也就是为什么,创建fork子进程之后,我们让父子printf打印的时候,父子进程都会向同一个显示器打印,因为它们俩都指向了同一个文件
- Linux中可以通过特定的系统调用来判断文件是普通文件还是管道文件。
- 自己读写数据时,就在这个文件对应的内存缓冲区里面来完成数据交互,我们把这个文件我们称之为管道
Linux系统设计的时候就设计成,如果是普通文件就往磁盘上写,如果是管道文件也往缓冲区里写,但是就
不再往磁盘上刷新了。如果是管道,就把它和对应的磁盘去掉关联。
- Linux下一切皆文件,管道也是文件~
2.1.2 匿名管道的创建:
匿名管道主要用于父子进程之间的通信,用pipe接口来创建管道:
#include <unistd.h>
功能:创建匿名管道
原型
int pipe(int fd[2]);
参数
fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端,是输出性参数
返回值:成功返回0,失败返回-1 错误代码
2.1.3 总结管道的特点:
在使用系统调用pipe()创建管道时,pipefd[0] ---------》管道读取端的文件描述符,pipefd[1] ---------》管道写入端的文件描述符。
- 管道是用来进行具有血缘关系的进程做进程间通信的–常用于父子通信
- 管道具有通过让进程间协调,提供了访问限制。(读不到了会阻塞)
- 管道提供的是面向流式的通信服务,—面向字节流,协议
- 管道是基于文件的,文件的生命周期是随进程的,管道的生命周期是随进程的。
- 管道的pipefd[0]和pipefd[1]的用途是固定的,读取端只能从pipefd[0]读取数据,而写入端只能向pipefd[1]写入数据
代码演示:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(int argc, char *argv[])
{
int pipefd[2];
if (pipe(pipefd) == -1)
perror("make pipe"),exit(1);
if (pid == 0) {
close(pipefd[0]);
write(pipefd[1], "hello", 5);
close(pipefd[1]);
exit(0);
}
close(pipefd[1]);
char buf[10] = {0};
read(pipefd[0], buf, 10);
printf("buf=%s\n", buf);
return 0;
}
2.1.4 管道读写规则:
当父进程没有写入数据的时候,子进程在等!所以,父进程写入之后,子进程才能read到数据,子进程打印读取数据要以父进程的节奏为主!
管道内部,当没有数据可读时, read 读端就必须阻塞等待
— 等待管中有数据,否则无法执行后面的代码。
----read调用返回-1管道内部,如果数据被写满了,写端(write)就必须阻塞等待
-----等待管中有空间,否则此时写入会覆盖之前的数据。
----- write调用返回-1
当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性.
管道特点
只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
管道提供流式服务 一般而言,进程退出,管道释放,所以管道的生命周期随进程
一般而言,内核会对管道操作进行同步与互斥
管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
父进程给子进程派发任务:
结合上述所学知识,就可以简单写一个通过通信管道父进程给子进程派发任务执行的代码了。
#include <iostream>
#include <vector>
#include <string>
#include <unordered_map>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <cassert>
using namespace std;
// 父进程控制子进程
typedef void (*functor)();
vector<functor> functors; // 方法集合
//for debug
unordered_map<uint32_t, string> info;
void f1()
{
cout << "这是一个处理日志的任务, 执行的进程 ID [" << getpid() << "]"
<< "执行时间是[" << time(nullptr) << "]\n"
<< endl;
}
void f2()
{
cout << "这是一个备份数据任务, 执行的进程 ID [" << getpid() << "]"
<< "执行时间是[" << time(nullptr) << "]\n"
<< endl;
}
void f3()
{
cout << "这是一个处理网络连接的任务, 执行的进程 ID [" << getpid() << "]"
<< "执行时间是[" << time(nullptr) << "]\n"
<< endl;
}
void loadFunctor()
{
info.insert({functors.size(), "处理日志的任务"});
functors.push_back(f1);
info.insert({functors.size(), "备份数据任务"});
functors.push_back(f2);
info.insert({functors.size(), "网络连接的任务"});
functors.push_back(f3);
}
int main()
{
// 0. 加载任务列表
loadFunctor();
// 1. 创建管道
int pipefd[2] = {0};
if (pipe(pipefd) != 0)
{
cerr << "pipe error" << endl;
return 1;
}
// 2. 创建子进程
pid_t id = fork();
if (id < 0)
{
// 创建失败
cerr << "fork error" << endl;
return 2;
}
else if (id == 0)
{
// 子进程,read - 读取
// 3. 关闭不需要的文件fd
close(pipefd[1]);
// 子进程不断根据收到的信息,执行对应的方法
// 如果没有人往管道中写,此时子进程就卡在了read这里等待别人分配任务
while (true)
{
uint32_t operatorType = 0;
// 从fd为pipefd[0]的文件里读sizeof(uint32_t)个字节的内容,写到operatorType中去
// 如果有数据就读取,如果没有数据就阻塞等待,等待任务的到来。
ssize_t s = read(pipefd[0], &operatorType, sizeof(uint32_t));
if (s == 0)
{
cout << "我要退出了..." << endl;
break;
}
assert(s == sizeof(uint32_t));
(void)s;
// 走到这里一定是一个成功的读取
if (operatorType < functors.size())
{
functors[operatorType]();
}
else
{
cerr << "bug? operatorType = " << operatorType << endl;
}
}
close(pipefd[0]);
exit(0);
}
else if (id > 0)
{
srand((long long)time(nullptr));
// 父进程,write - 操作
// 3. 关闭不需要的文件fd
close(pipefd[0]);
// 4. 指派任务
int num = functors.size();
int cnt = 10;
while (cnt--)
{
// 5. 形成任务码
uint32_t commandCode = rand() % num;
cout << "父进程指派任务完成,任务是:" << info[commandCode] << "任务的编号是: " << cnt << endl;
// 向指定的进程下达执行任务的操作
write(pipefd[1], &commandCode, sizeof(uint32_t));
sleep(1);
}
close(pipefd[1]);
pid_t res = waitpid(id, nullptr, 0);
if (res) cout << "wait success" << endl;
}
return 0;
}
2.1.6 控制多个子进程(进程池):
命令行中输入的|命令,其实就是一个匿名管道:
3 命名管道
- 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
- 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
- 命名管道是一种特殊类型的文件
3.1 命名管道的创建
int mkfifo(const char *filename,mode_t mode)
返回值:成功返回0,失败返回-1 错误代码
创建命名管道时候,要指明路径,和umask值,为了防止默认umask的扰乱,我们一开始将`umask``置为0。
管道文件是以p开头的:
int main(int argc, char *argv[])
{
mkfifo("p2", 0644);
return 0;
}
3.2 匿名管道与命名管道的区别
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,打开用open(要自己进行打开)
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完 成之后,它们具有相同的语义
命名管道的打开规则
- 如果当前打开操作是为读而打开FIFO时,
O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
O_NONBLOCK enable:立刻返回成功 - 如果当前打开操作是为写而打开FIFO时,
O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
O_NONBLOCK enable:立刻返回失败,错误码为ENXIO
3.3 两个进程之间的通信
- 匿名管道之间的通信是基于父子进程继承的关系来实现的。
而让两个毫不相干的进程实现进程通信则是命名管道做的事情.
命名管道:通过一个fifo文件,有路径就具有唯一性,通过路径,就能找到同一资源。
只要打开的是同一个文件在内核里用的就是同一个struct file,那么指向的就是同一个inode,用的就是同一个缓冲区。
命名管道是让两个进程之间是看到同一个文件,这个文件做了符号处理,相当于管道文件(通信时,数据不会刷新到磁盘上),操作系统一看到这个文件就知道了,这个文件的数据不用刷新到磁盘上,所以此时就在内存里,就有了管道。
代码演示:
#pragma
#include <iostream>
#include <cstdio>
#include <string>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <unistd.h>
#define IPC_PATH "./.fifo"
using namespace std;
服务端:
#include "comm.h"
// 读取
int main()
{
umask(0);
// server创建好了,client就不用创建了
if (mkfifo(IPC_PATH, 0600) != 0)
{
cerr << "mkfifo error" << endl;
return 1;
}
int pipeFd = open(IPC_PATH, O_RDONLY);
if (pipeFd < 0)
{
cerr << "open fifo error" << endl;
return 2;
}
#define NUM 1024
// 正常的通信过程
char buffer[NUM];
while (true)
{
ssize_t s = read(pipeFd, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = '\0';
cout << "客户端->服务器#" << buffer << endl;
}
else if (s == 0)
{
cout << "客户退出了,我也退出了" << endl;
break;
}
else
{
// do nothing
cout << "read: " << strerror(errno) << endl;
}
}
close(pipeFd);
cout << "服务端退出了" << endl;
// 跑完之后删除管道
unlink(IPC_PATH);
return 0;
}
客户端:
#include "comm.h"
// 写入
int main()
{
int pipeFd = open(IPC_PATH, O_WRONLY);
if (pipeFd < 0)
{
cerr << "open: " << strerror(errno) << endl;
return 1;
}
#define NUM 1024
char line[NUM];
// 进行通信
while (true)
{
printf("请输入你的消息# ");
fflush(stdout);
memset(line, 0, sizeof(line));
// fgets -> C语言的函数 -> line结尾自动添加\0
if (fgets(line, sizeof(line), stdin) != nullptr)
{
line[strlen(line) - 1] = '\0';
write(pipeFd, line, strlen(line));
}
else
{
break;
}
}
close(pipeFd);
cout << "客户端退出了" << endl;
return 0;
}
3.4 命名管道的特点:
- 命名管道,会在磁盘上,但是里面没有内容,不会将内存数据进行刷新到磁盘上
- 命名管道,手段不一样,是靠系统文件路径的唯一性,来看到同一份文件的
尾声
看到这里,相信大家对这个Linux 有了解了。
如果你感觉这篇博客对你有帮助,不要忘了一键三连哦