【Linux】进程间通信——管道(1w5字图文详解,附代码和运行动图)

CSDN 2024-09-09 13:37:01 阅读 53

目录

一、进程间通信

1.1 概念

1.2 手段

二、管道

2.1 概念

2.2 匿名管道

2.3 管道的特征

2.4 管道的读写规则

2.5 管道的应用场景

三、命名管道

3.1 创建命名管道

3.2 使用命名管道

3.3 命名管道的读写规则


一、进程间通信

1.1 概念

进程间通信(Inter-Process Communication,IPC)是指在不同的进程之间共享信息或数据,实现数据层面的交互。

我们知道,进程具有独立性,默认情况下两个进程是无法进行信息交流的,因此我们需要某些技术手段来让不同进程之间能够共享信息

进程间通信的本质,就是让两个进程能够看到同一份“资源”,这份资源一般由操作系统提供,因此进程访问这份资源进行通信,本质上就是在访问操作系统,所以要实现进程间通信,我们需要调用对应的系统调用接口。

操作系统中的两大IPC模块定制标准:System V和Posix,为我们提供了一系列进程间通信方法

1.2 手段

常见的进程间通信手段主要有:

匿名管道(Pipe):适用于父子进程间通信,只能单向传输数据,没有同步机制命名管道(Named Pipe):可用于任意两个进程间的通信消息队列(Message Queue):允许进程将消息发送到队列中,其他进程可以从队列中读取这些消息信号(Signal):最古老的进程间通信的方法之一,用于通知进程发生了某个事件共享内存(Shared Memory)在内存中创建一个共享区域,并让多个进程能够访问该区域套接字(Socket):适用于需要进行网络通信的进程信号量(Semaphore):主要用于多进程、多线程之间的同步互斥问题


二、管道

2.1 概念

管道是Linux中的一种进程间通信方式

我们可以把管道想象成连接两个进程间的一条管子,一个进程的数据流就能通过这个管子流向另一个进程。

管道分为匿名管道pipe命名管道FIFO两种,通常我们所说的管道指匿名管道,二者除了创建、使用等方式不同,原理是相同的,都是通过内核的一块缓冲区实现数据传输

2.2 匿名管道

匿名管道(pipe)是一个临时创建的对象

站在文件描述符角度来看,我们可以把两个进程的文件描述符分别指向管道的读端和写端,一个进程向管道中写,另一个进程从管道中读,就实现了进程间通信。

站在内核角度来看,管道的本质就是两个file结构体(一个用于写一个用于读)、一个临时创建的inode节点加上一个内存的物理页。进程向管道中写入时,数据被写入到了这个共享数据页中;进程从管道中读取时,数据又从这个页中被拷贝出来

理解了管道的本质,我们就理解了为什么管道只能进行单向通信

创建匿名管道的接口:

<code>#include <unistd.h>

int pipe(int fd[2]);

该函数的参数是一个输出型参数,我们需要向函数内传入一个大小为2,元素类型为int的数组。匿名管道创建完毕后,传入的数组内部会存放读端和写端的文件描述符,其中fd[0]为读端,fd[1]为写端

创建匿名管道成功,函数会返回0,创建失败返回-1并设置errno

我们可以用一段简单的代码来验证一下:

#include <iostream>

#include <unistd.h>

using namespace std;

int main()

{

int fd[2] = {0};

int n = pipe(fd);

if(n < 0)

return 1;

cout << "fd[0]=" << fd[0] << " fd[1]=" << fd[1] << endl;

return 0;

}

运行结果:

我们知道,进程的前三个文件描述符分别被标准输入流、标准输出流和标准错误流占用,所以创建匿名管道时文件描述符只能从3开始,符合预期

但匿名管道又是如何实现父子进程间通信的呢?

通过fork创建子进程后,子进程会继承父进程的各种信息,其中就包括文件描述符表,因此父进程如果在创建子进程时就已经创建了匿名管道,后续子进程的文件描述符也会与该管道对应

 

<code>#include <iostream>

#include <unistd.h>

using namespace std;

int main()

{

int fd[2] = {0};

int n = pipe(fd); //父进程建立匿名管道

if(n < 0)

return 1;

pid_t id = fork(); //创建子进程

if(id < 0)

return 2;

return 0;

}

父进程向子进程通信的情况,再将父进程指向管道读端的文件描述符关闭,子进程指向管道写端的文件描述符关闭。如果要实现子进程向父进程通信,则反过来即可

<code>#include <iostream>

#include <cstdlib>

#include <unistd.h>

using namespace std;

int main()

{

int fd[2] = {0};

int n = pipe(fd); //父进程建立匿名管道

if(n < 0)

return 1;

pid_t id = fork(); //创建子进程

if(id < 0)

return 2;

if(id == 0) //子进程

{

close(fd[1]); //关闭写端

//开始通信

//...

close(fd[0]); //通信完毕

exit(0);

}

//父进程

close(fd[0]); //关闭读端

//开始通信

//...

close(fd[1]); //通信完毕

return 0;

}

所以,通过匿名管道实现进程间通信的前提,是两个进程间有血缘关系(文件描述符可被继承)

匿名管道不需要将数据拷贝到磁盘中,属于内存级文件,没有路径、文件名和inode,因此而得名

以上就是使用匿名管道实现进程间通信的前置操作——建立通信信道,接下来我们才开始真正的进程间通信

文件描述符也有了,我们只需要让父子进程一个向管道写入内容,一个从管道读取内容,就可以完成通信了。这里用一段简单的代码来验证:

#include <iostream>

#include <string>

#include <cstring>

#include <cstdio>

#include <cstdlib>

#include <unistd.h>

#include <sys/wait.h>

#include <sys/types.h>

using namespace std;

void Writer(int wfd)

{

//随便准备一些用于通信的内容

string s = "hello, I am father";

pid_t self = getpid();

char buffer[1024];

while(true)

{

buffer[0] = 0; //清空字符串

snprintf(buffer, sizeof(buffer), "%s, pid:%d", s.c_str(), self); //将内容格式化输入到目标字符串中

write(wfd, buffer, strlen(buffer)); //将字符串写入管道

sleep(1);

}

}

void Reader(int rfd)

{

char buffer[1024];

while(true)

{

buffer[0] = 0;

ssize_t n = read(rfd, buffer, sizeof(buffer)); //从管道读取内容

if(n > 0)

{

buffer[n] = 0; //这里的0相当于'\0'

cout << "child get a massage[" << getpid() << "]#" << buffer << endl; //打印读取到的内容

}

}

}

int main()

{

int fd[2] = {0};

int n = pipe(fd); //父进程建立匿名管道

if(n < 0)

return 1;

pid_t id = fork(); //创建子进程

if(id < 0)

return 2;

if(id == 0) //子进程

{

close(fd[1]); //关闭写端

//向管道读取

Reader(fd[0]);

close(fd[0]); // 通信完毕

exit(0);

}

//父进程

close(fd[0]); //关闭读端

//向管道写入

Writer(fd[1]);

//等待子进程

pid_t rid = waitpid(id, nullptr, 0);

if(rid < 0)

return -1;

close(fd[1]); // 通信完毕

return 0;

}

运行结果:

可以看到,子进程确实完整的接收到了父进程发送的内容

但是,我把字符串定义为全局的,子进程不是也能访问到吗?实际上进程间通信的内容并不只是传输静态的数据,一些动态变化的数据只能依靠进程间通信来传输

2.3 管道的特征

(1)大小固定

前面提到,匿名管道是通过数据页完成数据的交互的,所以管道是有其固定的大小的

我们可以通过 ulimit -a 命令来查看系统资源的设置

其中:

open files:一个进程能够打开的文件数量file size:单个文件的大小,单位为blockspipe size:管道的大小,单位为512字节

可以看到,管道的大小为512字节*8=4KB,和页的大小一致

但是实际上管道是否真的是4KB呢?我们可以让写端不停的写,一次只写一个字符,并计算写端写入了多少次,而读端10秒后才开始读取,看看结果如何:

<code>#include <iostream>

#include <string>

#include <cstring>

#include <cstdio>

#include <cstdlib>

#include <unistd.h>

#include <sys/wait.h>

#include <sys/types.h>

using namespace std;

void Writer(int wfd)

{

int number = 1;

while(true)

{

char c = 'a';

write(wfd, &c, 1); //一次只写一个字符

cout << number++ << endl; //用于说明写端写了几次

}

}

void Reader(int rfd)

{

char buffer[1024];

while(true)

{

sleep(10); //10秒后才开始读取

buffer[0] = 0;

ssize_t n = read(rfd, buffer, sizeof(buffer)); //从管道读取内容

if(n > 0)

{

buffer[n] = 0; //这里的0相当于'\0'

cout << "child get a massage[" << getpid() << "]#" << buffer << endl; //打印读取到的内容

}

}

}

int main()

{

int fd[2] = {0};

int n = pipe(fd); //父进程建立匿名管道

if(n < 0)

return 1;

pid_t id = fork(); //创建子进程

if(id < 0)

return 2;

if(id == 0) //子进程

{

close(fd[1]); //关闭写端

//向管道读取

Reader(fd[0]);

close(fd[0]); // 通信完毕

exit(0);

}

//父进程

close(fd[0]); //关闭读端

//向管道写入

Writer(fd[1]);

//等待子进程

pid_t rid = waitpid(id, nullptr, 0);

if(rid < 0)

return -1;

close(fd[1]); // 通信完毕

return 0;

}

运行结果:

 

可以看到写端写入了65536次,也就是最多写入了65536个字符即65536字节,换算为64KB,这和我们说好的可不一样啊!

实际上管道在不同的内核里,大小可能是不同的。在Linux 2.6.11之前管道大小和页大小一样(4KB),后面变为64KB

(2)血缘限制

创建匿名管道后,管道的读写端与进程的文件描述符关联,要想让另一个进程也拿到与管道关联的文件描述符,就需要通过血缘关系继承的方式

(3)单向通信

管道的结构注定其只能进行单向通信,如果要实现双向通信则需要两个管道

(4)同步与互斥

通信的进程间会保持进程协同,当读写的内容大小小于4KB时可保证读写的原子性,这一点在后面管道的读写规则部分会讲到

(5)面向字节流

管道是面向字节流的,这一点在后面管道的读写规则部分会讲到

(6)基于文件

管道是基于文件的,而文件的生命周期是随进程的,所以其实在代码最后也不一定要把读写端关闭,进程退出后,操作系统会自动回收

2.4 管道的读写规则

(1)未设置O_NONBLOCK读写端正常,管道如果为空,读端就要阻塞

例如上面的代码,我们让父进程向管道写入时,限定一秒写一次,而子进程读取数据也是一秒读一次,说明当管道为空时,读端没有内容可以读取,于是阻塞

(2)未设置O_NONBLOCK读写端正常,管道如果为满,写端就要阻塞

我们可以修改一下上面的代码,让写端不停写,而读端隔一会才读一次,看看效果如何

<code>#include <iostream>

#include <string>

#include <cstring>

#include <cstdio>

#include <cstdlib>

#include <unistd.h>

#include <sys/wait.h>

#include <sys/types.h>

using namespace std;

void Writer(int wfd)

{

//随便准备一些用于通信的内容

string s = "hello, I am father";

pid_t self = getpid();

int number = 0;

char buffer[1024];

while(true)

{

buffer[0] = 0; //清空字符串

snprintf(buffer, sizeof(buffer), "%s, pid:%d", s.c_str(), self); //将内容格式化输入到目标字符串中

write(wfd, buffer, strlen(buffer)); //将字符串写入管道

cout << number++ << endl; //用于说明写端写了几次

}

}

void Reader(int rfd)

{

char buffer[1024];

while(true)

{

sleep(5); //隔5秒读一次

buffer[0] = 0;

ssize_t n = read(rfd, buffer, sizeof(buffer)); //从管道读取内容

if(n > 0)

{

buffer[n] = 0; //这里的0相当于'\0'

cout << "child get a massage[" << getpid() << "]#" << buffer << endl; //打印读取到的内容

}

}

}

int main()

{

int fd[2] = {0};

int n = pipe(fd); //父进程建立匿名管道

if(n < 0)

return 1;

pid_t id = fork(); //创建子进程

if(id < 0)

return 2;

if(id == 0) //子进程

{

close(fd[1]); //关闭写端

//向管道读取

Reader(fd[0]);

close(fd[0]); // 通信完毕

exit(0);

}

//父进程

close(fd[0]); //关闭读端

//向管道写入

Writer(fd[1]);

//等待子进程

pid_t rid = waitpid(id, nullptr, 0);

if(rid < 0)

return -1;

close(fd[1]); // 通信完毕

return 0;

}

运行效果:

可以看到,父进程一下就写了两千多次,把管道写满了,而子进程5秒才读一次,所以父进程无法继续写入,于是阻塞

我们发现子进程第一次读取时,由于其缓冲区大小限制,并没有完整的把管道中的所有数据读完,但读取后管道中空出来了部分空间,而父进程并没有继续进行写入。这是因为管道的读写具有原子性(前提是写入的内容小于管道大小),其内部的数据必须被读端完整读取完毕后才会继续进行写入,可以保证数据安全

如果写入的内容大于管道大小,那么当管道被写满就无法再写入了,只能一次写一部分,也就无法保证原子性了

但是,明明我们父进程是一行行写的,怎么子进程读取的时候一次读取了一大块呢?

因为管道是面向字节流的,它不会区分读取的内容是字符串还是什么,只负责读取内容,至于后续对内容的处理是用户需要考虑的事情

我们可以对写端写入的内容作一个规定,让其按照某种格式或方式写入,读端在读取内容后就可以按照规定的方式来处理这些内容,于是诞生了协议。关于协议在网络部分会提到

(3)设置了O_NONBLOCK, 读写端正常,管道如果为空,读端返回-1,并设置errno值为EAGAIN

(4)设置了O_NONBLOCK, 读写端正常,管道如果为满,写端返回-1,并设置errno值为EAGAIN

(5)读端正常,写端被关闭,读端不会被阻塞,且读取的返回值始终为0,表示读取到文件结尾

要验证这一点,我们就得修改一下之前的代码,让子进程写入,父进程读取

<code>#include <iostream>

#include <string>

#include <cstring>

#include <cstdio>

#include <cstdlib>

#include <unistd.h>

#include <sys/wait.h>

#include <sys/types.h>

using namespace std;

void Writer(int wfd)

{

string s = "hello, I am child";

char buffer[1024];

int cnt = 3;

while (cnt--) //只写入三次

{

buffer[0] = 0; //清空字符串

snprintf(buffer, sizeof(buffer), "%s", s.c_str()); //将内容格式化输入到目标字符串中

write(wfd, buffer, strlen(buffer)); //将字符串写入管道

cout << "child write: " << s << endl;

sleep(1);

}

}

void Reader(int rfd)

{

char buffer[1024];

while(true)

{

buffer[0] = 0;

ssize_t n = read(rfd, buffer, sizeof(buffer)); //从管道读取内容

if(n > 0)

{

buffer[n] = 0; //这里的0相当于'\0'

cout << "father read: " << buffer << endl; //打印读取到的内容

}

cout << "n=" << n << endl; //打印read返回值

}

}

int main()

{

int fd[2] = {0};

int n = pipe(fd); //父进程建立匿名管道

if(n < 0)

return 1;

pid_t id = fork(); //创建子进程

if(id < 0)

return 2;

if(id == 0) //子进程

{

close(fd[0]); //关闭读端

//向管道写入

Writer(fd[1]);

close(fd[1]); // 通信完毕

exit(0);

}

//父进程

close(fd[1]); //关闭写端

//向管道读取

Reader(fd[0]);

//等待子进程

pid_t rid = waitpid(id, nullptr, 0);

if(rid < 0)

return -1;

close(fd[0]); // 通信完毕

return 0;

}

运行效果:

可以看到,子进程每秒写入一个字符串,父进程读取一个字符串,n为读取到的字节数

而子进程写入3次后就退出,写端被关闭,读端虽然不停的读,但是也没有内容可读了,所以n为0

(6)写端正常,读端被关闭,操作系统会通过发送信号杀掉正在写入的进程

操作系统是不会做任何低效浪费的工作的,当管道的读端被关闭,继续写入也就没有任何意义了,所以操作系统会直接杀掉正在写入的进程 

我们可以让父进程读取几次后就将读端关闭,然后通过进程等待来获取子进程的退出信息,查看子进程是否真的收到了信号

<code>#include <iostream>

#include <string>

#include <cstring>

#include <cstdio>

#include <cstdlib>

#include <unistd.h>

#include <sys/wait.h>

#include <sys/types.h>

using namespace std;

void Writer(int wfd)

{

string s = "hello, I am child";

char buffer[1024];

while (true)

{

buffer[0] = 0; //清空字符串

snprintf(buffer, sizeof(buffer), "%s", s.c_str()); //将内容格式化输入到目标字符串中

write(wfd, buffer, strlen(buffer)); //将字符串写入管道

cout << "child write: " << s << endl;

sleep(1);

}

}

void Reader(int rfd)

{

char buffer[1024];

int cnt = 3;

while(cnt--) //读3次就退出

{

buffer[0] = 0;

ssize_t n = read(rfd, buffer, sizeof(buffer)); //从管道读取内容

if(n > 0)

{

buffer[n] = 0; //这里的0相当于'\0'

cout << "father read: " << buffer << endl; //打印读取到的内容

}

cout << "n=" << n << endl; //打印read返回值

}

}

int main()

{

int fd[2] = {0};

int n = pipe(fd); //父进程建立匿名管道

if(n < 0)

return 1;

pid_t id = fork(); //创建子进程

if(id < 0)

return 2;

if(id == 0) //子进程

{

close(fd[0]); //关闭读端

//向管道写入

Writer(fd[1]);

close(fd[1]); // 关闭写端

exit(0);

}

//父进程

close(fd[1]); //关闭写端

//向管道读取

Reader(fd[0]);

cout << "read over, close read fd" << endl;code>

close(fd[0]); // 关闭读端

// 等待子进程

int status = 0; //获取子进程退出信息

pid_t rid = waitpid(id, &status, 0);

if(rid < 0)

return -1;

cout << "child process exit signal: " << (status & 0x7F) << endl; //打印子进程退出信号

return 0;

}

运行结果:

可以看到子进程确实收到了13号信号 

我们可以输入 kill -l 命令,查看是哪个信号

2.5 管道的应用场景

我们在shell中输入指令时,使用的管道 “|” ,其底层就是匿名管道

我们还可以用管道来实现一个简易的进程池,通过管道实现父进程向子进程派发任务

既然是派发任务,那么我们需要设计一些自定义的任务内容

<code>//Task.hpp

#pragma once

#include <iostream>

#include <vector>

typedef void(*task_t)();

void task1()

{

std::cout << "task1" << std::endl;

}

void task2()

{

std::cout << "task2" << std::endl;

}

void task3()

{

std::cout << "task3" << std::endl;

}

void task4()

{

std::cout << "task4" << std::endl;

}

void LoadTask(std::vector<task_t> *tasks) //将任务加载到任务队列

{

tasks->push_back(task1);

tasks->push_back(task2);

tasks->push_back(task3);

tasks->push_back(task4);

}

接下来就是进程池的设计了,我们依旧是通过父进程创建匿名管道,然后创建子进程继承文件描述符,然后关闭对应的文件描述符来建立通信信道

具体代码如下:

#include <iostream>

#include <cstdlib>

#include <unistd.h>

#include <string>

#include <vector>

#include <cassert>

#include <ctime>

#include <sys/types.h>

#include <sys/wait.h>

#include <sys/stat.h>

#include "Task.hpp"

#define processnum 5 // 子进程数量

std::vector<task_t> tasks; // 任务队列

class channel // 描述信道信息

{

public:

channel(int cmdfd, int slaverid, const std::string &processname)

: _cmdfd(cmdfd), _slaverid(slaverid), _processname(processname)

{

}

public:

int _cmdfd; // 子进程对应的父进程写端fd

int _slaverid; // 子进程pid

std::string _processname; // 子进程名称

};

void worker() // 子进程工作内容

{

while (true)

{

int cmdcode = 0;

int n = read(0, &cmdcode, sizeof(int)); // 从管道读入工作码

if (n == sizeof(int))

{

std::cout << "child-" << getpid() << " : get cmdcode: " << cmdcode << std::endl; // test

if (cmdcode >= 0 && cmdcode < tasks.size())

tasks[cmdcode](); // 执行任务

}

if (n == 0) // 一旦写端关闭,n为0

break; // n == 0时直接退出

}

}

void InitProcessPool(std::vector<channel> *channels)

{

for (int i = 0; i < processnum; i++)

{

int pipefd[2];

int n = pipe(pipefd); // 建立管道

assert(!n);

pid_t id = fork(); // 创建子进程

if (id == 0) // 子进程

{

close(pipefd[1]); // 关闭写端

dup2(pipefd[0], 0); // 将管道重定向到标准输入,子进程可以直接从0号文件描述符读取内容

close(pipefd[0]);

worker(); // 开始工作

std::cout << "子进程:" << getpid() << "退出" << std::endl;

exit(0);

}

// 父进程

close(pipefd[0]); // 关闭读端

std::string name = "process-" + std::to_string(i);

channels->push_back(channel(pipefd[1], id, name)); // 初始化信道

}

}

void Debug(const std::vector<channel> &channels) // 测试代码

{

for (const auto &it : channels) // 测试进程池是否正确初始化

{

std::cout << it._cmdfd << " " << it._slaverid << " " << it._processname << std::endl;

}

}

void CtrlWorker(std::vector<channel> &channels)

{

int which = 0;

int cnt = 5;

while (cnt--) //派发5次就结束

{

int cmdcode = rand() % tasks.size(); // 任务码(模拟随机任务)

//除了随机方式,还可以设计界面并让用户自行选择任务

std::cout << "father: " << "cmdcode-" << cmdcode << " has send to "

<< channels[which]._processname << std::endl; // test

write(channels[which]._cmdfd, &cmdcode, sizeof(cmdcode)); // 向管道写入任务码

which++; // 顺序选择进程

which %= channels.size();

sleep(1);

}

}

void QuitProcess(const std::vector<channel> &channels)

{

for (const auto &c : channels)

close(c._cmdfd); // 关闭对应父进程写端

for (const auto &c : channels)

waitpid(c._slaverid, nullptr, 0); // 回收子进程

}

int main()

{

LoadTask(&tasks); // 初始化任务队列

srand(time(0));

std::vector<channel> channels; // 信道列表

InitProcessPool(&channels); // 初始化进程池

// Debug(channels);

CtrlWorker(channels); // 派发任务并选择进程

QuitProcess(channels); // 退出子进程

return 0;

}

其中有些思路或许需要说明一下

上面的代码中,子进程将读端重定向到了标准输入,所以可以直接从0号文件描述符读取父进程发送的内容,因此后续无需再保存所有子进程的读端,直接关闭原读端fd即可channel类包含了父子进程通信信道的信息,其中包含子进程对应的父进程写端fd,通过创建一个channel数组,我们就可以通过在父进程中访问不同的channel对象,实现对不同子进程的通信创建一个任务队列,通过向子进程传输任务码,就可以让子进程执行对应任务码的任务。这里为了便于演示,通过产生随机数的方式来模拟随机任务派送最后退出子进程时,只需要遍历channel数组,关闭父进程的写端,子进程在读取内容时返回0,就说明写端被关闭,直接退出即可

代码运行结果:

可以看到,父进程传输给子进程的任务码都能够被正确接收,且对应的任务也被执行 

当然,代码中还存在一个小bug,虽然不影响程序的运行

我们会发现,最后子进程退出的时候,是所有子进程一起退出的。但我们最后关闭父进程写端的时候,可是用循环一个个关闭的,难道子进程不应该也是一个个退出的吗?

实际上有一个非常容易被忽略的点,就是父进程在创建完第一个子进程后,其写端fd指向了第一个管道。而这个写端fd也是会被后面创建的子进程继承的!

也就是说,后面每一个子进程,都会指向前面所有管道的写端

这就导致后续我们通过关闭写端让子进程退出的时候,前面的n-1次关闭都是无效的,因为始终会有其他的子进程指向管道写端,既然还有进程指向写端,那么对应的读端就不会读到0,而是保持阻塞

直到关闭了最后一个管道的写端,由于后续没有子进程指向,只有父进程唯一一个写端,此时关闭才是有效的。

最后一个管道的写端被关闭,导致最后被创建出来的子进程退出,释放所有的文件描述符。这些文件描述符中就有部分指向了前面管道的写端,这样就又导致第n-1个子进程退出...就像多米诺骨牌一样,最后一个倒下了,前面的都会一个个倒下

因此我们也可以从后往前关闭写端,这样就能实现子进程一个个退出的效果了

当然,我们也可以用一个数组保存历史上创建的写端fd,每次创建一个新的子进程就遍历一遍数组,把每个继承的写端fd都关闭即可


三、命名管道

匿名管道限制了只能在具有亲缘关系的进程进行通信时使用,如果我们想让两个没有关联的进程进行通信,则需要使用另一种管道——命名管道

命名管道也称为FIFO文件,相较于匿名管道存在于系统内核中,命名管道作为一种特殊的文件存放在文件系统中

3.1 创建命名管道

我们可以通过mkfifo命令创建命名管道,例如:

也可以通过函数创建命名管道

<code>#include <sys/types.h>

#include <sys/stat.h>

int mkfifo(const char* pathname, mode_t mode);

其中:

pathname:创建命名管道的路径mode:命名管道的权限

创建命名管道成功,函数返回0,失败则返回-1

既然能用函数创建命名管道,那么也能用函数删除命名管道

#include <unistd.h>

int unlink(const char* path);

删除成功返回0,失败返回-1 

3.2 使用命名管道

既然命名管道也是文件,数据先写入文件再被其他进程读取,为什么我们不使用普通文件进行进程间通信呢?

虽然命名管道存放在文件系统中,但是它并不会将数据刷新到磁盘上,是一个内存级文件。如果使用普通文件,还多了一个刷盘的操作,因此我们不使用普通文件。

命名管道有独立的inode、路径和文件名,通过这些我们就能保证两个不同的进程能够打开同一个命名管道

具体如何使用命名管道:我们只需要像使用普通文件一样使用命名管道即可

既然命名管道可用于两个不相关的进程,那么我们就写两份简单的代码,来模拟客户端与服务端之间最基础的通信场景

因为两份代码间可能有较多的重复代码,例如头文件等内容,所以我们可以把这部分的重复代码拆分出来,减少代码的重复性

首先是 shared.hpp,内容主要是两份代码重复的部分:

//shared.hpp

#pragma once

#include <iostream>

#include <cerrno>

#include <cstring>

#include <cstdlib>

#include <cstdio>

#include <unistd.h>

#include <sys/types.h>

#include <sys/stat.h>

#include <fcntl.h>

#define FIFO_FILE "./myfifo" //命名管道路径

#define MODE 0664 //命名管道权限位

enum //退出码,代表不同错误

{

FIFO_CREATE_ERR = 1,

FIFO_DELETE_ERR,

FIFO_OPEN_ERR

};

然后是 server.cc,代表服务端

//server.cc

//服务端

#include "shared.hpp"

using namespace std;

int main()

{

int n = mkfifo(FIFO_FILE, MODE); //创建命名管道

if(n == -1) //创建失败

{

perror("mkfifo");

exit(FIFO_CREATE_ERR);

}

int fd = open(FIFO_FILE, O_RDONLY); //读方式打开命名管道

if(fd < 0) //打开管道失败

{

perror("open");

exit(FIFO_OPEN_ERR);

}

while(true) //开始通信

{

char buffer[1024] = {0};

int x = read(fd, buffer, sizeof(buffer)); //从命名管道中读

if(x > 0)

{

buffer[x] = 0;

cout << "The server receives a message from the client: " << buffer << endl;

}

}

close(fd); //关闭fd

int m = unlink(FIFO_FILE); //删除命名管道

if(m == -1) //删除管道失败

{

perror("unlink");

exit(FIFO_DELETE_ERR);

}

return 0;

}

最后是 client.cc,代表客户端

//client.cc

//客户端

#include "shared.hpp"

using namespace std;

int main()

{

//服务端已经创建管道了,客户端无需再创建

int fd = open(FIFO_FILE, O_WRONLY); //写方式打开命名管道

if(fd < 0) //打开失败

{

perror("open");

exit(FIFO_OPEN_ERR);

}

string message;

while(true)

{

cout << "Please enter# " << endl; //用户输入内容

cin >> message;

write(fd, message.c_str(), message.size()); //将内容写入命名管道

}

close(fd); //关闭fd

return 0;

}

运行效果如下:

需要注意,先启动server程序再启动client程序,否则命名管道没有被创建会导致打开文件失败

可以看到两个进程间已经能够完成基础的通信了,我们可以在原代码的基础上进行一些修改,让客户端退出时服务端也跟着退出,这里就不做额外的展示了

3.3 命名管道的读写规则

需要注意的是,从命名管道读取内容的一方在打开管道时不是马上就打开的,而是会等待写入方打开管道。如果要验证这一点,我们只需要在两份代码中的open函数后打印一些内容,就能观察到了

这里直接给出运行效果:

可以看到server先运行,但是没有打印内容。直到client也开始运行,二者才一起打印

命名管道的读写规则如下:

未设置O_NONBLOCK,读方式打开FIFO,但没有对应写入方,阻塞直到有进程以写方式打开该FIFO设置了O_NONBLOCK,读方式打开FIFO,但没有对应写入方,立即返回成功未设置O_NONBLOCK,写方式打开FIFO,但没有对应读取方,阻塞直到有进程以读方式打开该FIFO设置了O_NONBLOCK,写方式打开FIFO,但没有对应读取方,立即返回失败,错误码为ENXIO

所以是否设置O_NONBLOCK,就决定了读端或写端在没有另一半的时候是否会阻塞等待

如有错误,欢迎在评论区指出

完.



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。