目录
1. 进程间通信
1.1 为何需要
进程间通信(Inter-Coroutine Communication, IPC)的目的是让不同进程之间协作和共享资源。通常有以下场景:
- 数据传输:一个进程需要将它的数据发送给另一个进程,实现信息的传递。
- 资源共享:多个进程之间共享同样的资源,可以减少资源浪费。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知他们发生了某种事件。当一个进程发生错误,可以由另一个进程结束该进程。
- 进程控制:有些进程希望完全控制另一个进程的执行,如Debug进程,以便于跟踪和修复错误。
1.2 核心原理
进程的独立性是操作系统设计中的一个基本原则,它通过为每个进程创建独立的task_struct结构体、页表和代码数据等内核数据结构来体现。这种独立性保证了进程的稳定运行,但也加大了进程间通信的难度。
那该如何实现进程间通信呢?前提是先让不同的进程,看到同一份资源!
- 程序加载到内存中,操作系统为其创建各种数据结构,才成为进程,所以同一份资源指的是某种形式的内存空间。
- 该资源不能由通信进程任意一方创建,不管哪个进程创建,另外一个进程都看不到,所以该资源必须由操作系统提供。
如下图,进程A可以向公共资源写入内容,而进程B可以向公共资源读取信息,反过来进程A也可以读,进程B也可以写。
操作系统已不同形式提供公共资源,就有不同的方式实现进程通信。
-
管道(Pipe):如果公共资源以文件的形式提供,我们称之为管道。管道允许进程A向其中写入数据,而进程B可以从管道中读取这些数据。这种通信方式是单向的,但也可以通过双向管道实现双向通信。
-
共享内存(Shared Memory):当公共资源以内存块的形式提供时,我们称之为共享内存。这种方式允许多个进程直接访问同一块内存区域,从而实现高效的数据交换。
-
消息队列(Message Queue):如果公共资源以队列的形式提供,其中包含一个个数据块,我们称之为消息队列。进程可以通过消息队列发送和接收数据块,实现进程间的数据传递。
-
信号量(Semaphore):以计数器方式提供的公共资源被称为信号量。信号量主要用于同步,确保多个进程可以安全地访问共享资源,避免竞态条件。
1.3 种类
进程间通信分为本地通信和网络通信。
- 本地通信:同一台主机,同一个操作系统,不同进程之间的通信。
- 网络通信:不同主机,不同操作系统,不同进程间的通信
本文主要讲解本地通信,网络通信属于计算机网络部分内容。
其中进程间通信有几大标准。其中之一就是管道通信,还有SystemV标准和POSIX标准。
2. 匿名管道
管道这一概念最早在1970年代由Douglas McIlroy在贝尔实验室开发Unix操作系统时提出。其中Unix算是Linux的前身。McIlroy的管道设计允许用户将多个程序通过管道符号“|”连接起来,从而实现程序间的数据流传输。
2.1 指令
管道在命令行中以“|”表示,who指令用于显示当前登录的会话信息,wc可用计数,加上-l选表示对内容做行计数。使用管道,将who指令进程显示信息传递过去,交由wc指令进程进行行计数,这就实现了进程间通信。
sleep指令的作用是使当前shell命令行暂停指定秒数,一般作用于前端会话,此时无法输入命令进行命令行操作。不过在该组指令后面加上“&”,可以让该组命令在后端执行,不会影响前端命令行操作。当使用ps命令查看sleep指令相关进程信息,会发现两个sleep进程的ppid值相同,即有相同的父进程,并且pid值是相邻的,说明是兄弟进程。
2.2 原理
磁盘上的文件由其内容和一系列属性组成。当父进程打开一个磁盘文件时,该文件的数据将被加载到内存中。为了有效管理这些数据,操作系统会为该文件创建一个file结构体对象。在此结构体中,包含了一个指向文件inode值的字段,该inode值用于在磁盘上索引文件的属性。同时,文件的内容被拷贝到内存中的一个内核级缓冲区,而file结构体的一个字段则索引到这块内存区域。
- task_struct结构体中包含一个file_struct结构体,该结构体中有一个file结构体指针数组,该数组称之为文件描述符表。这个表的前三个位置通常被标准输入、标准输出和标准错误流所占据。
- 当父进程调用fork函数创建子进程,子进程的task_struct结构体对象会拷贝大部分父进程结构体的内容,则子进程的文件描述表也会拷贝父进程的。
- 但子进程的文件描述表不会指向同一个file结构体,因为file结构体中包含一个字段整数pos,表示读取到该文件的哪个位置,所以操作系统会为子进程再创建一个file结构体对象,内部字段大部分跟原来的结构体相同。
- 通常情况下,当进程修改文件时,这些更改会定期同步到磁盘上。然而,如果文件仅用于进程间的通信,那么就没有必要将数据刷新到磁盘。在这种情况下,可以创建一个仅存在于内存中的文件,即匿名管道。匿名管道的核心原理就是利用这种纯内存级的文件来实现高效的进程间通信,而无需涉及磁盘I/O操作。
一开始父进程以只读和只写打开管道,会使用3和4号文件描述表位置。
当父进程创建子进程时,子进程也会继承父进程文件描述符表的内容,也打开管道的读写端。
父进程关闭管道读端,子进程关闭管道写端。此时,父子进程就可以通过管道进行单向通信。
管道必须由父进程创建,再由子进程继承。
2.3 系统调用
pipe是系统级调用函数,用来创建管道。其参数是一个包含两个元素的整型数组。该数组是一个输出型参数,pipe函数会返回两个文件描述符,第一个元素接收读端描述符,第二个元素接收写端描述符。
我们可以写段代码验证管道的作用。首先,创建父进程创建管道。其次,创建子进程,父子进程关闭不需要的文件描述符。最后,子进程向管道写入信息,父进程读取管道内容,打印出来。
#include <iostream>
#include <string>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
// father -> read
// child -> write
int main()
{
// 1. 创建管道
int fds[2] = {0};
int n = pipe(fds); // fds是输出型参数
if (n != 0)
{
std::cerr << "pipe failed" << std::endl;
return 1;
}
// 2.创建子进程
pid_t id = fork();
if (id < 0)
{
std::cerr << "fork failed" << std::endl;
return 2;
}
else if (id == 0)
{
// 子进程读
// 3.关闭不需要的fd,关闭读端
close(fds[0]);
int cnt = 0;
while(true)
{
std::string message = "hello child, hello ";
message += std::to_string(getpid());
message += ", ";
message += std::to_string(cnt);
::write(fds[1], message.c_str(), message.size());
cnt++;
sleep(1);
}
exit(0);
}
else
{
// 父进程
// 3.关闭不需要的fd,关闭写端
close(fds[1]);
char buffer[1024];
while(true)
{
ssize_t n = ::read(fds[0], buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << "child->father, message: " << buffer << std::endl;
}
}
pid_t rid = waitpid(id, nullptr, 0);
std::cout << "farther wait child success " << rid <<std::endl;
}
return 0;
}
子进程向管道写内容,父进程读取内容并打印出来。这就完成了进程通信。
mypipe进程创建了一个子进程。
2.4 使用情况
else if (id == 0)
{
// 子进程读
// 3.关闭不需要的fd,关闭读端
close(fds[0]);
int cnt = 0;
while(true)
{
std::string message = "hello child, hello ";
message += std::to_string(getpid());
message += ", ";
message += std::to_string(cnt);
// 加上一个if语句判断
if(cnt < 5)
::write(fds[1], message.c_str(), message.size());
cnt++;
sleep(1);
}
exit(0);
}
将2.3代码中else if部分的write函数前加上if判断语句,只写入五次语句。当cnt大于4时,写端不再向管道写入信息,该进程就会阻塞在这里。得出结论:
- 当管道正常运行并且管道为空的情况下,读取管道内容会阻塞。
else if (id == 0)
{
// 子进程读
// 3.关闭不需要的fd,关闭读端
close(fds[0]);
int total = 0;
while(true)
{
std::string message = "h";
total += ::write(fds[1], message.c_str(), message.size());
std::cout << "total: " << total << std::endl;
}
exit(0);
}
还是修改2.3代码中else if部分,向管道中一次写入一个字节内容,使用total变量记录写入字节数。父进程的读端不打印出接受到的内容。当total累加到65537时,进程发生阻塞,表示管道被写满了,匿名管道空间大小大约是64KB左右。
- 当管道正常运行并且管道为满的情况下,向管道写入内容会阻塞。
else if (id == 0)
{
// 子进程读
// 3.关闭不需要的fd,关闭读端
close(fds[0]);
int cnt = 0;
while(true)
{
std::string message = "hello child, hello ";
message += std::to_string(getpid());
message += ", ";
message += std::to_string(cnt);
::write(fds[1], message.c_str(), message.size());
cnt++;
sleep(1);
break;
}
exit(0);
}
else
{
// ...
while(true)
{
ssize_t n = ::read(fds[0], buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
//std::cout << "child->father, message: " << buffer << std::endl;
}
else if (n == 0)
{
std::cout << "child quit? me too!" << std::endl;
break;
}
}
// ...
}
修改2.3中代码,在第二个while循环中,加上n等于0的情况,并且子进程写入一次内容,就退出。代码运行结果如下。
- 当管道写端关闭,读端正常的情况下,读端读到0,表示读到文件末尾。
else
{
// 父进程
// 3.关闭不需要的fd,关闭写端
close(fds[1]);
char buffer[1024];
while(true)
{
ssize_t n = ::read(fds[0], buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << "child->father, message: " << buffer << std::endl;
}
else if (n == 0)
{
std::cout << "child quit? me too!" << std::endl;
break;
}
close(fds[0]);
sleep(1);
break;
}
int status;
pid_t rid = waitpid(id, &status, 0);
std::cout << "farther wait child success: " << rid << ",exit code: "
<< WEXITSTATUS(status) << ",signal: " << WTERMSIG(status) <<std::endl;
子进程正常写入信息,父进程读取一次就退出。这时操作系统发现管道没人读取信息,会发送13号信号给写端进程,杀掉该进程。
- 当管道读端关闭,写端正常的情况下,操作系统会发送信号来终止读端进程。
2.5 特性
else if (id == 0)
{
// 子进程读
// 3.关闭不需要的fd,关闭读端
close(fds[0]);
int cnt = 0;
while(true)
{
std::string message = "hello child, hello ";
message += std::to_string(getpid());
message += ", ";
message += std::to_string(cnt);
::write(fds[1], message.c_str(), message.size());
cnt++;
sleep(100);
}
exit(0);
}
else
{
// 父进程
// 3.关闭不需要的fd,关闭写端
close(fds[1]);
char buffer[5];
while(true)
{
ssize_t n = ::read(fds[0], buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
std::cout << "child->father, message: " << buffer << std::endl;
}
else if (n == 0)
{
std::cout << "child quit? me too!" << std::endl;
break;
}
}
}
修改2.3中的代码,子进程正常写入一个字符串后,休眠100秒,并且让父进程一次只读5个字节。现象是父进程会读好几次。这个过程演示了管道的读写操作并不关心每次写入或读取的具体数据量,这些细节是由应用层根据需求来决定的。
- 管道是面向字节流的
其它特性如下
- 匿名管道用于具有血缘关系的进程进行通信,常用于父子进程。
- 文件的生命周期随进程,匿名管道的生命周期也随进程。因为当父子进程都退出后,管道采用引用计数得知没有进程打开它,就会被操作系统释放。
- 匿名管道只能实现单向通信。
- 管道自带同步互斥保护机制,这一特性在匿名管道的使用中可以窥见一二。当父子进程同时写或者同时读管道时,若不加以保护控制,会造成管道内数据混乱。管道的设计巧妙地避免了这种情况,它在管道为空时,会阻塞读操作,直到有数据可读;而在管道满载时,则会阻塞写操作,直到管道中有空间可用。这种阻塞行为实际上是一种极端的互斥机制,确保了数据的一致性和管道操作的有序性。
3. 命名管道
当两个进程既没有父子关系,也没有兄弟关系,无法使用匿名管道通信。若想要任意两个无关系进程实现通信,可以使用命名管道。命名管道(Named Pipe)也称为FIFO(First In, First Out),是一种特殊类型的文件,它可以在进程间进行通信。
3.1 指令
使用mkfifo指令,后面加上文件名,即可创建一个命名管道。
当我想命名管道写入字符串,它会阻塞在这个指令中。用新会话查看fifo管道,会发现此时文件大小还是0,说明命名管道虽然有文件属性,但是写入的内容不会刷新到磁盘中,只用于进程通信。
当新会话中使用cat指令,读取fifo管道内容,echo指令内容才不阻塞。
3.2 原理
命名管道的原理是让不同的进程使用同一个文件系统路径标志同一份资源。文件路径具有唯一性,使得不同进程看到同一份资源。
并且命名管道虽然有文件名,并且保存在磁盘上,但是加载到内存时,会有一块内存缓冲区,可供不同进程通信。这部分跟匿名管道原理相同。
命名管道和匿名管道的区别,是两个进程看到统一资源方式不同,前者是通过文件路径唯一性标识资源,后者是通过继承父进程文件描述符表获得资源。
3.3 函数接口
在代码中,可以使用C标准库函数mkfifo来创建一个命名管道。mkfifo函数第一个参数是文件路径,第二个参数是改管道文件的权限。一般设置成0600,表示文件拥有者可读可写,其他人无权访问,更无权修改。
3.4 CS模式通信
我们可以封装一套代码,采用命名管道机制来实现客户端写入信息与服务端读取信息的通信模式,这构成了经典的客户端-服务器(CS)交互架构。
这份代码由五个文件构成:
- Common.hpp:定义通用变量,提供公共接口。
- Server.hpp:描述服务端,封装成类。
- Client.hpp:描述客户端,封装成类。
- Server.cc:使用Server.hpp中的类,实现服务端功能
- Client.cc:使用Server.hpp中的类,实现服务端功能
3.4.1 公共端 服务端 客户端
下面是Common.hpp文件代码内容,包含open、mkfifo函数所需头文件。
- 一般Server.hpp和Client.hpp文件代码类似部分,可以实现一个通用接口。通用接口OpenPipe作用是根据传入参数以不同方式打开管道。ClosePipeHelper函数用于关闭管道文件。
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
const std::string gpipefile = "./fifo";
const mode_t gmode = 0600;
const int gdefaultfd = -1;
const int gsize = 1024;
const int gForRead = O_RDONLY;
const int gForWrite = O_WRONLY;
int OpenPipe(int flag)
{
//如果读端打开文件时,写端还没打开,读端对应的open就会阻塞
int fd = ::open(gpipefile.c_str(), flag);
if (fd < 0)
{
std::cerr << "open error" << std::endl;
}
return fd;
}
void ClosePipeHelper(int fd)
{
if (fd >= 0)
::close(fd);
}
下面是Server.hpp文件代码内容,包含InitPipe类和Server类。
- InitPipe类主要是创建命名管道,其构造函数创建管道,析构函数删除管道文件。在该文件中,创建一个InitPipe类的对象,当进程结束时,InitPipe类对象自然就销毁,管道文件就会被删除,做到进程启动时再创建。
- Server类用于实现服务端进程读取客户端进程发来的消息。OpenPipeForRead函数用于以只读方式打开管道。RecvPipe函数用于读取管道内容。
#pragma once
#include <iostream>
#include "Common.hpp"
class InitPipe
{
public:
InitPipe()
{
//创建命名管道
umask(0);
int n = ::mkfifo(gpipefile.c_str(), gmode);
if (n < 0)
{
std::cerr << "mkfifo error" << std::endl;
return;
}
std::cout << "mkfifo success" << std::endl;
}
~InitPipe()
{
int n = ::unlink(gpipefile.c_str());
if (n < 0)
{
std::cerr << "unlink error" << std::endl;
return;
}
std::cout << "unlink success" << std::endl;
}
};
InitPipe initpipe;
class Server
{
public:
Server()
:_fd(gdefaultfd)
{}
bool OpenPipeForRead()
{
_fd = OpenPipe(gForRead);
if (_fd < 0)
return false;
return true;
}
// std::string *:输出型参数
// const std::string &: 输入型参数
// std::string &:输入输出型参数
int RecvPipe(std::string *out)
{
char buffer[gsize];
ssize_t n = ::read(_fd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
*out = buffer;
}
return n;
}
void ClosePipe()
{
ClosePipeHelper(_fd);
}
private:
int _fd;
};
下面是Client.hpp文件内容,包含Client类。
- Client类功能是作为客户端进程,服务端进程发送消息。OpenPipeForWrite函数用于以只写方式打开管道。SendPipe函数用于向管道内写入信息。
#pragma once
#include <iostream>
#include "Common.hpp"
class Client
{
public:
Client()
:_fd(gdefaultfd)
{}
bool OpenPipeForWrite()
{
_fd = OpenPipe(gForWrite);
if (_fd < 0)
return false;
return true;
}
int SendPipe(const std::string &in)
{
return ::write(_fd, in.c_str(), in.size());
}
void ClosePipe()
{
ClosePipeHelper(_fd);
}
private:
int _fd;
};
3.4.2 服务端与客户端主函数
下面是Server.cc文件内容,初始化Server类对象,就会创建管道,再调用类内函数打开管道。
- RecvPipe函数返回读取管道文件数据的字节数,如果返回值大于0,表示读取成功;返回值等于0,表示读到文件末尾;小于0,表示读取失败。
#include "Server.hpp"
#include <iostream>
int main()
{
Server server;
server.OpenPipeForRead();
std::string message;
while(true)
{
if (server.RecvPipe(&message) > 0)
{
std::cout << "client Say# " << message << std::endl;
}
else
{
break;
}
}
std::cout <<"client quit, so I quit..." << std::endl;
server.ClosePipe();
return 0;
}
下面是Client.cc文件内容,初始化CLient类对象。
使用getline函数获取屏幕中输入的内容,再发送给管道。
#include "Client.hpp"
#include <iostream>
int main()
{
Client client;
client.OpenPipeForWrite();
std::string message;
while(true)
{
std::cout << "Please Enter# ";
std::getline(std::cin, message);
client.SendPipe(message);
}
client.ClosePipe();
return 0;
}
3.4.3 进程通信演示
先启动server进程,再启动client进程。
当客户端进程退出时,服务端进程也退出。这是因为在Server.cc文件中,while循环逻辑里,只要n小于等于0,就退出。如果写端进程退出,则读端进场读到EOF,即n等于0,表示读到文件末尾。
创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!