进程间通信(中)
对于上篇的学习,由于内容太多,我们接着继续(上篇可点击进入查看)进程间通信(上)https://blog.csdn.net/Small_entreprene/article/details/145623853?fromshare=blogdetail&sharetype=blogdetail&sharerId=145623853&sharerefer=PC&sharesource=Small_entreprene&sharefrom=from_link
进程IPC
管道
命名管道
有了上篇对匿名管道的理解,我们接下来对命名管道就可以很好理解。
匿名管道是只能是对于具有血缘关系的进程之间,进行进程间通信(常用于父子进程),可是如果我们今天的需求是两个毫不相干的进程要进行进程间通信呢?我们该如何进行通信呢?
另外,假设现在有两个进程:
- 进程A:打开了一个文件/a/b/c.txt
- 进程B:打开了一个文件/a/b/c.txt
那么在内核中,操作系统会不会把这个/a/b/c.txt文件(inode,文件内容等),在内存中加载两次?
答案是不会的,因为没有必要,先不谈AB要进行什么操作,就单纯的我们为什么要把一个文件inode,文件内容等,加载两次呢?两个进程访问的是同一个文件,操作系统不允许自己管理的文件数据在操作系统内重复出现,因为操作系统不会做浪费时间与空间的事情。
在现代操作系统中,文件的内容(如 /a/b/c.txt
)在内存中的加载和管理是通过页缓存机制实现的。当多个进程访问同一个文件时,操作系统并不会将文件内容在内存中加载多次。具体来说:
-
文件内容的共享:当第一个进程访问文件时,操作系统会将文件内容加载到内存中的页缓存中。如果其他进程随后访问同一个文件,操作系统会直接从页缓存中读取数据,而不是再次从磁盘加载。这样可以提高效率并减少不必要的磁盘I/O操作。
-
写时拷贝(Copy-on-Write, COW):如果多个进程需要写入同一个文件,操作系统会采用写时拷贝机制。这意味着,当一个进程尝试写入文件时,操作系统会为该进程创建一个文件内容的副本,以避免影响其他进程。这样,每个进程都可以独立地修改自己的副本,而不会干扰其他进程。
-
inode的管理:文件的inode信息(如文件的元数据)也会被加载到内存中,并且在需要时同步到磁盘。inode的加载和更新操作确保了文件状态的一致性,但不会导致文件内容在内存中被重复加载。
因此,操作系统通过页缓存和写时拷贝机制,确保了文件内容的高效共享和独立修改,避免了不必要的内存占用和磁盘I/O操作。
像上图,就可以实现让不同的进程,看到同一份资源。
我们匿名管道是通过让父子继承来使父子进程看到同一份资源,而今天,不同进程间,通过打开同一个路径下的同一个文件,就可以实现两进程看到同一份资源。文件有路径,路径是具有唯一性的,而且文件有名字,所以该通信的原理就是基于文件的通信方式,所以称该通信方式为命名管道!
根据上图,那么file.txt是磁盘级文件,将来我们进程读写时,是要刷新到磁盘当中的,所以我们不能让进程A,B打开我们历史上学习到的这种普通文件,我们需要让Linux内核支持一种特殊的文件类型,叫作:管道文件。
该管道文件有个特点:只能被打开,不需要把数据从内存刷新到磁盘!
命名管道的原理其实和匿名管道的原理是一样的,只不过是在打开文件的形式是有差别的,所以我们该如何理解管道文件呢?
举个例子,我们可以在Shell中看到:
- d开头是目录文件
- -开头是普通文件
来区分文件类型。
我们如果要在Linux当中,创建一个管道文件,而且该管道文件要在磁盘上存在,但是该文件特性和普通文件不一样,不需要刷盘,我们可以使用:
mkfifo
命令用于在文件系统中创建一个命名管道(FIFO)。它会在磁盘上创建一个特殊类型的文件,但该文件的内容不会像普通文件那样直接存储在磁盘上,而是通过内存缓冲区进行通信。
mkfifo /path/to/your/fifo
-
/path/to/your/fifo
是管道文件的路径。 -
创建后,这个文件会在文件系统中显示为一个特殊文件(类型为
p
),但其内容不会直接写入磁盘。
我们"ll"发现,管道文件类型是以p开头。
删除命名管道可以rm,也可以使用unlink。
上面是我们文件级的操作,接下来,我们以代码级别来实现命名管道的进程间通信的代码:
我们创建管道文件,除了使用上面的指令级,还有系统级的函数调用:
在 C 语言中,mkfifo()
的函数原型如下:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
pathname
指定要创建的 FIFO 文件的路径名。该路径必须是有效的文件路径,并且不能指向一个已经存在的文件。(不带路径,默认在当前路径下创建管道文件)
mode
指定 FIFO 文件的权限模式,与 open()
和 creat()
系统调用中的模式类似。它通常使用八进制表示,例如:
权限模式还会受到进程的 umask
值的影响。最终的权限是 mode
与 ~umask
的结果。
-
0666
:表示文件所有者、组用户和其他用户都可以读写该 FIFO 文件。 -
0644
:表示文件所有者可以读写,组用户和其他用户只能读。
我们创建两个代码,为后来的两个毫不相干的进程作准备(分别形成各自对应的可执行程序)
lfz@HUAWEI:~/lesson/lesson25$ touch client.cc
lfz@HUAWEI:~/lesson/lesson25$ touch server.cc
lfz@HUAWEI:~/lesson/lesson25$ touch common.hpp
访问相同文件:commom.hpp:
#pragma once
#define FIFO_FILE "fifo"
server(服务器):
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "common.hpp"
int main()
{
umask(0);
// 新建一个管道文件
int n = mkfifo(FIFO_FILE, 0666);
if (n < 0)
{
// 创建管道文件失败
std::cerr << "mkfifo error" << std::endl;
return 1;
}
// 创建管道文件成功
std::cout << "mkfifo sucess" << std::endl;
// ………………………………………………………………………………………………………………
// 让server端进行read操作
// 打开文件
int fd = open(FIFO_FILE, O_RDONLY);
if (fd < 0)
{
std::cerr << "open fifo error" << std::endl;
return 2;
}
std::cout << "open fifo sucess" << std::endl;
// 正常read
while (true)
{
char buffer[1024];
int number = read(fd, buffer, sizeof(buffer) - 1);
if (number > 0)
{
buffer[number] = 0;
std::cout << "Server: Client say: " << buffer << std::endl;
}
else
{
// TODO
}
}
close(fd);
// ………………………………………………………………………………………………………………
// 删除管道文件
n = unlink(FIFO_FILE);
if (n == 0)
{
std::cout << "remove fifo sucess" << std::endl;
}
else
{
std::cout << "remove fifo failed" << std::endl;
}
return 0;
}
client(客户):
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "common.hpp"
int main()
{
// 不需要再去新建和删除管道文件了,只要打开该管道文件,和文件操作一模一样
// client作为写端,向管道文件写入数据,以便目标进程通过fifo管道文件读取数据
int fd = open(FIFO_FILE, O_WRONLY);
if (fd < 0)
{
std::cerr << "open fifo error" << std::endl;
}
std::cout << "open fifo sucess" << std::endl;
// ………………………………………………………………………………………………………………………………
// 写入操作
std::string message;
int cnt = 1;
pid_t id = getpid();
while (true)
{
std::cout << "Client: Please Enter# ";
std::getline(std::cin, message); // 从标准输入中获取内容,放入message当中
message += (", message number: " + std::to_string(cnt++) + " , [" + std::to_string(id) + "]");
write(fd, message.c_str(), message.size());
}
// ………………………………………………………………………………………………………………………………
close(fd);
return 0;
}
我们编译运行两个进程:
我们先让server(服务器)跑起来:./server
这样,我们可以看到:服务器就进行阻塞等待了:
可是服务器只显示了:"mkfifo sucess",open file的信息并没有打印,当我们一旦运行client(客户端),那么服务器就打印了"open file"的信息:
所以,我们在正式通信之前,就可以得出一个结论:
命名管道(FIFO)是一种用于进程间通信的机制,它通过文件系统中的一个特殊文件(FIFO文件)来实现。其核心特性是阻塞式通信:当读取方尝试打开管道文件时,如果写入方尚未打开管道,读取方的open
操作会阻塞,直到有写入方打开管道文件。反之,如果写入方打开管道文件,但读取方尚未打开,写入方的open
操作也会阻塞。这种机制确保了通信双方的同步,避免了数据传输的混乱。
例如,假设有一个生产者进程(写入方)和一个消费者进程(读取方)。消费者进程先运行,尝试通过open("/tmp/fifo", O_RDONLY)
打开管道文件。此时,由于生产者进程尚未打开管道,消费者进程的open
操作会被阻塞。当生产者进程运行并执行open("/tmp/fifo", O_WRONLY)
打开管道时,消费者进程的open
操作才会解除阻塞并成功返回。此时,双方都已准备好,数据就可以从生产者进程写入管道,消费者进程从管道读取数据,完成通信。
那么,接下来,我们就可以实现服务端和客户端的通信了:
我们先将客户端关闭后,发现服务器没有任何反应,其实并不是他没有反应。
我们上面代码是客户端作为的是写端,写端的进程直接关掉了,那么写端曾经打开的管道文件也会被直接关掉,因为管道的生命周期是随着进程的。我们之前认识到,如果管道的写端关闭了,那么读端的返回值就是0,但是:
我们上面代码else中//TODO
所以我们应该丰富一下我们服务器的代码:
if (number > 0)
{
buffer[number] = 0;
std::cout << "Server: Client say: " << buffer << std::endl;
}
else if (number == 0)
{
std::cout << "client quit! me too!" << std::endl;
break;
}
else
{
std::cerr << "read error" << std::endl;
break;
}
现在我们的代码基本就可以按照我们预想的结果进行开始和退出了。
下面,我们将代码进行优化:
common.hpp
#pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define PATH "." // 定义管道文件所在的目录
#define FILENAME "fifo" // 定义管道文件的名称
/**
* @brief 命名管道类,负责创建和销毁管道文件
*/
class NamedFifo
{
public:
// 构造函数:创建管道文件
NamedFifo(const std::string &path, const std::string &name)
: _path(path), _name(name)
{
umask(0); // 设置文件权限掩码
_fifoname = _path + "/" + _name; // 构造完整的管道文件路径
// 创建管道文件
int n = mkfifo(_fifoname.c_str(), 0666);
if (n < 0)
{
std::cerr << "Error: Failed to create FIFO file (" << _fifoname << ")" << std::endl;
}
else
{
std::cout << "Success: FIFO file created at " << _fifoname << std::endl;
}
}
// 析构函数:删除管道文件
~NamedFifo()
{
// 删除管道文件
int n = unlink(_fifoname.c_str());
if (n == 0)
{
std::cout << "Success: FIFO file removed (" << _fifoname << ")" << std::endl;
}
else
{
std::cerr << "Error: Failed to remove FIFO file (" << _fifoname << ")" << std::endl;
}
}
private:
std::string _path; // 管道文件所在路径
std::string _name; // 管道文件名称
std::string _fifoname; // 完整的管道文件路径
};
/**
* @brief 文件操作类,负责读写操作
*/
class FileOper
{
public:
// 构造函数:初始化文件路径和文件描述符
FileOper(const std::string &path, const std::string &name)
: _path(path), _name(name), _fd(-1)
{
_fifoname = _path + "/" + _name; // 构造完整的管道文件路径
}
// 打开管道文件(读模式)
void OpenForRead()
{
// 打开管道文件,阻塞等待写端打开
_fd = open(_fifoname.c_str(), O_RDONLY);
if (_fd < 0)
{
std::cerr << "Error: Failed to open FIFO for reading (" << _fifoname << ")" << std::endl;
return;
}
std::cout << "Success: FIFO opened for reading (" << _fifoname << ")" << std::endl;
}
// 打开管道文件(写模式)
void OpenForWrite()
{
// 打开管道文件,阻塞等待读端打开
_fd = open(_fifoname.c_str(), O_WRONLY);
if (_fd < 0)
{
std::cerr << "Error: Failed to open FIFO for writing (" << _fifoname << ")" << std::endl;
return;
}
std::cout << "Success: FIFO opened for writing (" << _fifoname << ")" << std::endl;
}
// 从管道文件读取数据
void Read()
{
char buffer[1024]; // 缓冲区
while (true)
{
int number = read(_fd, buffer, sizeof(buffer) - 1); // 读取数据
if (number > 0)
{
buffer[number] = '\0'; // 添加字符串结束符
std::cout << "Server: Client says: " << buffer << std::endl;
}
else if (number == 0)
{
std::cout << "Client quit! Exiting..." << std::endl;
break; // 客户端退出,服务器也退出
}
else
{
std::cerr << "Error: Failed to read from FIFO" << std::endl;
break;
}
}
}
// 向管道文件写入数据
void Write()
{
std::string message;
int cnt = 1; // 消息计数器
pid_t id = getpid(); // 获取当前进程ID
while (true)
{
std::cout << "Client: Please Enter# ";
std::getline(std::cin, message); // 从标准输入获取消息
message += (", message number: " + std::to_string(cnt++) + " , [" + std::to_string(id) + "]");
write(_fd, message.c_str(), message.size()); // 写入管道
}
}
// 关闭管道文件
void Close()
{
if (_fd > 0)
{
close(_fd); // 关闭文件描述符
std::cout << "FIFO closed." << std::endl;
}
}
private:
std::string _path; // 管道文件所在路径
std::string _name; // 管道文件名称
std::string _fifoname; // 完整的管道文件路径
int _fd; // 文件描述符
};
server.cc
#include "common.hpp"
int main()
{
// 创建管道文件(仅在服务器端创建)
NamedFifo fifo(PATH, FILENAME);
// 打开管道文件(读模式)
FileOper readerfile(PATH, FILENAME);
readerfile.OpenForRead();
readerfile.Read(); // 开始读取数据
readerfile.Close(); // 关闭管道文件
return 0;
}
client.cc
#include "common.hpp"
int main()
{
// 打开管道文件(写模式)
FileOper writerfile(PATH, FILENAME);
writerfile.OpenForWrite();
writerfile.Write(); // 开始写入数据
writerfile.Close(); // 关闭管道文件
return 0;
}
优化的体现:
-
代码结构清晰化:
-
将管道文件的创建和销毁封装到
NamedFifo
类中,利用构造函数和析构函数的特性,确保管道文件的生命周期管理更加安全和自动化。 -
将文件操作(读写)封装到
FileOper
类中,使代码更加模块化,便于维护和扩展。
-
-
面向对象化:
-
使用类封装功能,避免了全局变量的使用,提高了代码的可读性和可维护性。
-
-
错误处理增强:
-
在管道文件的创建、打开和读写操作中增加了详细的错误处理和提示信息,方便调试和排查问题。
-
-
资源管理安全化:
-
确保管道文件在程序退出时被正确删除,避免在文件系统中遗留无用文件。
-
在文件操作完成后,确保文件描述符被正确关闭,避免资源泄漏。
-
-
代码复用性:
-
FileOper
类可以复用于其他需要管道通信的场景,无需重复编写读写逻辑。
-
这个面向对象化的实现不是很好,我们可以将文件操作直接合并到命名管道对象中,但是没有合并进来的主要原因是因为我只想让server看到命名管道,因为如果将来合并了,就也需要为client定义一个命名管道,而我们今天就是要利用构造和析构的特性,让他构造时创建管道文件,析构时删除管道文件。
匿名管道与命名管道的区别
特性 | 匿名管道 (pipe) | 命名管道 (FIFO) |
---|---|---|
创建方式 | 使用 pipe() 函数创建,返回一对文件描述符(读端和写端)。 | 使用 mkfifo() 函数创建,生成一个特殊文件,通过文件路径访问。 |
打开方式 | 直接通过 pipe() 创建后使用,无需额外打开。 | 需要使用 open() 函数,通过文件路径打开。 |
语义 | 仅在创建它的进程及其子进程中有效,常用于父子进程间的通信。 | 可以跨多个进程通信,通信双方通过文件路径访问同一个管道文件。 |
是否需要文件系统支持 | 不需要,完全在内存中实现。 | 需要,因为管道文件会出现在文件系统中。 |
阻塞行为 | 默认阻塞,写端写满时阻塞,读端读空时阻塞。 | 根据打开模式(阻塞或非阻塞)决定,具体见下文。 |
总结:匿名管道和命名管道的主要区别在于它们的创建和打开方式,以及匿名管道通常用于父子进程间通信,而命名管道用于任意进程间通信。一旦创建和打开完成,它们在通信语义上是相同的。
命名管道的打开规则(到了网络再好好看看)
1. 打开为读模式 (O_RDONLY)
阻塞模式 (O_NONBLOCK disable):
-
如果没有进程以写模式打开该 FIFO,则打开操作会阻塞,直到有进程以写模式打开该 FIFO。
-
一旦有写端打开,读端的打开操作成功返回。
非阻塞模式 (O_NONBLOCK enable):
-
打开操作会立即返回,无论是否有写端打开。
-
如果没有写端打开,打开操作会成功返回,但后续的
read()
操作可能会返回 0 或阻塞。
2. 打开为写模式 (O_WRONLY)
阻塞模式 (O_NONBLOCK disable):
-
如果没有进程以读模式打开该 FIFO,则打开操作会阻塞,直到有进程以读模式打开该 FIFO。
-
一旦有读端打开,写端的打开操作成功返回。
非阻塞模式 (O_NONBLOCK enable):
-
打开操作会立即返回失败,错误码为
ENXIO
(表示没有对应的读端)。 -
如果有读端已经打开,则打开操作成功返回。
-
命名管道的阻塞行为取决于打开模式(阻塞或非阻塞)以及当前是否有对应的读端或写端。
-
在阻塞模式下,读端会等待写端,写端会等待读端,从而确保通信的同步性。
-
在非阻塞模式下,读端和写端的打开行为会立即返回,但可能需要额外的逻辑来处理没有对应端的情况。
基于管道实现的两个实例
用命名管道实现文件拷贝
用命名管道来实现文件拷贝,就相当于一个进程读这个文件,然后把这个文件内容交给另一个进程,另一个进程再在指定目录下,把对应的文件新建出来,并且把读到的内容写到文件里,这样就完成了文件的拷贝。
第一部分:写入管道
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main(int argc, char *argv[])
{
mkfifo("tp", 0644); // 创建命名管道
int infd;
infd = open("abc", O_RDONLY); // 打开文件 abc 用于读取
if (infd == -1) ERR_EXIT("open");
int outfd;
outfd = open("tp", O_WRONLY); // 打开命名管道 tp 用于写入
if (outfd == -1) ERR_EXIT("open");
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0) // 从文件 abc 中读取数据
{
write(outfd, buf, n); // 将数据写入命名管道 tp
}
close(infd); // 关闭文件描述符
close(outfd); // 关闭文件描述符
return 0;
}
命名管道的创建:mkfifo("tp", 0644)
创建了一个名为 tp
的命名管道。如果管道已存在,程序不会报错,但也不会重新创建。如果管道已存在但权限不足,可能会导致后续写入失败。
文件打开操作:open("abc", O_RDONLY)
打开文件 abc
用于读取。如果文件不存在,程序会调用 ERR_EXIT
宏报错并退出。open("tp", O_WRONLY)
打开命名管道 tp
用于写入。如果管道不存在,程序也会报错。
数据传输:程序从文件 abc
中读取数据,并将其写入命名管道 tp
。数据传输是阻塞式的,写入进程会等待读取进程读取数据。
错误处理:使用了 ERR_EXIT
宏来处理错误,会打印错误信息并退出程序。
第二部分:读取管道并写入文件
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main(int argc, char *argv[])
{
int outfd;
outfd = open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644); // 打开文件 abc.bak 用于写入
if (outfd == -1) ERR_EXIT("open");
int infd;
infd = open("tp", O_RDONLY); // 打开命名管道 tp 用于读取
if (outfd == -1) ERR_EXIT("open");
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0) // 从命名管道 tp 中读取数据
{
write(outfd, buf, n); // 将数据写入文件 abc.bak
}
close(infd); // 关闭文件描述符
close(outfd); // 关闭文件描述符
unlink("tp"); // 删除命名管道
return 0;
}
文件创建与打开:open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644)
打开文件 abc.bak
用于写入。如果文件不存在,会创建一个新文件;如果文件已存在,会清空文件内容。
管道读取:open("tp", O_RDONLY)
打开命名管道 tp
用于读取。如果管道不存在,程序会报错。读取操作是阻塞式的,读取进程会等待写入进程写入数据。
数据传输:程序从命名管道 tp
中读取数据,并将其写入文件 abc.bak
。
管道删除:程序执行完成后,调用 unlink("tp")
删除命名管道 tp
。
错误处理:同样使用了 ERR_EXIT
宏来处理错误。
从代码来看,这是一个简单的管道通信程序,用于在两个进程之间传输数据。程序分为两部分:第一部分创建一个命名管道(FIFO),并从文件 abc
中读取数据,然后写入命名管道 tp
;第二部分从命名管道 tp
中读取数据,并将其写入文件 abc.bak
。
其实上面这个示例含金量并不高,我们要注意的是宏:
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
#define ERR_EXIT(m)
:这是一个宏定义,ERR_EXIT
是宏的名称,m
是宏的参数。宏定义的作用是将一段代码替换为一个简单的标识符,方便重复使用。
do { ... } while(0)
:这是一个完整的语句块,使用 do-while
循环包裹起来。while(0)
确保循环只执行一次,这样可以保证宏的行为类似于一个函数调用。它的优点是可以安全地在宏中使用复杂的逻辑,而不会因为缺少括号等问题导致意外的行为。
perror(m)
:perror
是一个标准库函数,用于打印错误信息。它会输出一条错误消息,包括:
宏参数 m
(通常是错误描述字符串)。
当前全局变量 errno
的值对应的错误信息(errno
是系统调用或库函数失败时设置的错误码)。
例如,如果 open
函数失败,errno
可能被设置为 ENOENT
(文件不存在),perror
会输出类似以下内容:
open: No such file or directory
exit(EXIT_FAILURE)
:exit
是一个标准库函数,用于终止程序。EXIT_FAILURE
是一个宏,通常定义为 1
,表示程序执行失败。调用 exit
时,程序会清理资源(如关闭文件描述符、释放动态分配的内存等),然后退出。
这里的斜杠就是续行的意思,不让这个宏定义要一直写在同一行代码上!!!
⽤命名管道实现server&client通信
第二个实例就是我们刚刚上面的客户端和服务端的进程间通信。 适当使用上面的宏就可以了。