Linux进程通信 —— 管道
进程间通信介绍
进程间通信的概念
在 Linux 中,进程间通信(IPC,Inter-Process Communication)是指 不同进程之间交换数据和信息的一种机制。 这种通信可以是在同一台计算机上的不同进程之间,也可以是在不同计算机之间的进程之间。
进程间通信的目的
进程间通信(IPC) 的主要目的是实现不同进程之间的数据交换和协作,从而实现更复杂的任务和功能。以下是进程间通信的几个主要目的:
-
数据交换:进程间通信允许不同进程之间交换数据和信息。这些数据可以是简单的消息、文件、共享内存中的数据等。通过数据交换,不同进程可以共享信息,协作完成复杂的任务。
-
协作:进程间通信使得不同进程能够协同工作,共同完成某些任务。例如,一个进程负责生成数据,另一个进程负责处理数据,它们之间通过通信来协调工作。
-
资源共享:进程间通信可以实现共享资源,如共享内存、文件、设备等。多个进程可以同时访问和操作共享资源,从而提高系统的利用率和效率。
-
进程同步:进程间通信可以实现进程之间的同步操作,确保它们按照一定的顺序执行。例如,使用信号量来控制对共享资源的访问,或者使用消息队列来实现进程间的同步消息传递。
-
并发控制:进程间通信可以实现对并发访问的控制,避免竞态条件和数据不一致性。例如,通过信号量或互斥锁来控制对共享资源的访问,以确保数据的一致性和可靠性。
总的来说,进程间通信的目的是实现不同进程之间的数据交换、协作和同步,从而实现更复杂、更高效的系统功能和任务。它是操作系统中的重要概念,对于实现多任务处理、并发编程和分布式系统等方面具有重要意义。
进程间通信的本质
进程间通信的本质就是,让不同的进程看到同一份资源。
在一个正在运行的操作系统中,存在许多相互独立的进程,它们需要相互协作以确保操作系统的正常运行。为了实现这种协作,这些进程通过进程间通信来共享资源,即让不同的进程能够访问并操作同一份共享资源,从而实现数据共享和协作。
一个简单的例子是父子进程间的通信。假设父进程需要向子进程发送一个命令,子进程收到命令后执行相应的操作,并将结果返回给父进程。这里的通信可以通过管道、消息队列或共享内存来实现。父进程向管道写入命令,子进程从管道中读取命令并执行,然后将结果写回管道,父进程再从管道中读取结果。这样,父子进程之间就实现了简单的数据交换和通信。
进程间通信的分类
管道
- 匿名管道pipe
- 命名管道
System V IPC
- System V消息队列
- SystemV 共享内存
- SystemV信号量
POSIXIPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
管道
什么是管道
管道(Pipe) 是一种用于进程间通信的机制,允许一个进程的输出直接成为另一个进程的输入。它主要用于在父进程和子进程之间或者在同时运行的两个进程之间进行通信。管道可以分为匿名管道(Anonymous Pipe) 和 命名管道(Named Pipe) 两种类型。
匿名管道
匿名管道的原理
匿名管道用于进程间通信,且仅限于本地父子进程之间的通信。
匿名管道是一种单向通信管道,只能在相关的父子进程间使用。它是通过调用pipe()
系统调用创建的,具有读端 和 写端 。匿名管道的数据流向是单向的,即数据只能从写端流入到读端。
注意:操作系统维护父子进程共享的文件资源时,并不会在父子进程间进行数据的写时拷贝。管道使用文件的概念,但操作系统并不会将进程通信的数据刷新到磁盘上,因为这样做既会涉及到IO操作从而降低效率,也是没有必要的。换言之,这些文件通常只存在于内存中,而不会被写入到磁盘上。
pipe
pipe
函数是Unix/Linux操作系统提供的一个系统调用,用于创建一个匿名管道。它的原型如下:
#include <unistd.h>
int pipe(int pipefd[2]);
-
参数pipefd是一个包含两个整数元素的数组,用于返回新创建的管道的文件描述符。pipefd[0]用于从管道中读取数据,pipefd[1]用于向管道中写入数据。
-
调用 成功 时,返回值为 0 ;调用 失败 时,返回值为 -1 ,并设置全局变量errno来指示错误类型。
用fork来共享管道原理
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
1、父进程调用pipe函数创建管道。
2、父进程创建子进程
3、父进程关闭写端,子进程关闭读端。
- 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
- 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。
站在文件描述符角度-深度理解管道
站在内核角度-管道本质
管道读写规则
pipe2函数与pipe函数类似,同样用于创建一个匿名管道。它的原型如下:
#include <unistd.h>
int pipe2(int pipefd[2], int flags);
与 pipe
函数不同的是,pipe2
函数允许通过参数 flags
设置一些附加的选项,以控制管道的行为。常用的选项包括:
O_CLOEXEC
:在父进程执行fork
创建子进程时,子进程会自动关闭父进程中不需要的文件描述符。这可以通过设置O_CLOEXEC
标志来实现,以确保在子进程中关闭管道的文件描述符。O_NONBLOCK
:设置管道的读取和写入操作为非阻塞模式。在非阻塞模式下,读取和写入操作会立即返回,不会等待直到管道中有数据可读或有空间可写。
这两个选项可以通过按位或运算组合使用。例如,要创建一个非阻塞的管道并在父进程中关闭不需要的文件描述符,可以将 flags
设置为 O_NONBLOCK | O_CLOEXEC
。
pipe2
函数的返回值和 pipe
函数类似,成功时返回0,失败时返回-1,并设置全局变量 errno
来指示错误类型。
管道的特点
-
单向性:管道是单向的,只能用于单向数据流的传输。通常有两种类型的管道:单向管道和双向管道。单向管道只能实现单向数据流的传输,而双向管道则可以实现双向数据流的传输。
-
半双工:管道是半双工的,即同一时间只能有一个方向的数据流动。在一个管道中,数据只能单向流动,要么从父进程流向子进程,要么从子进程流向父进程。
-
适用于有亲缘关系的进程:管道通常用于具有亲缘关系的进程之间进行通信,例如父子进程之间。因为管道是通过
fork
系统调用创建的,只有具有亲缘关系的进程才能共享同一个管道。 -
有限缓冲区:管道具有有限的缓冲区,因此在读取端没有读取数据时,写入端会被阻塞。当管道的缓冲区已满时,写入端也会被阻塞,直到缓冲区有足够的空间来容纳写入的数据。
-
不支持随机访问:管道是顺序访问的,不支持随机访问。也就是说,只能按照数据写入的顺序依次读取数据,不能直接定位到某个位置读取数据。
-
自动关闭:在进程终止时,管道会自动关闭。当所有指向管道的文件描述符都被关闭时,管道将被系统自动释放。
管道的四种特殊情况
管道的四种特殊情况:
-
写端进程不写,读端进程一直读:
- 情况描述:写端进程不向管道写入数据,但读端进程一直尝试读取数据。
- 表现:读端进程会被挂起,直到管道中有数据可读。
- 解释:读端进程在读取数据时,如果管道中没有数据可读,则会被挂起,直到有数据可供读取。
-
读端进程不读,写端进程一直写:
- 情况描述:写端进程不断向管道写入数据,但读端进程没有读取数据。
- 表现:当管道被写满后,写端进程会被挂起,直到管道中的数据被读取后才会继续写入。
- 解释:当写端不断写入数据,而读端没有读取时,管道会被写满。此时写端进程会被挂起,直到读端读取数据释放空间。
-
写端进程写完后关闭写端:
- 情况描述:写端进程将数据写入管道后关闭了写端。
- 表现:读端进程将管道中的数据读完后,继续执行后续代码,而不会被挂起。
- 解释:写端进程关闭写端后,读端进程读取完管道中的数据后会收到EOF,继续执行后续代码。
-
读端进程关闭读端:
- 情况描述:读端进程关闭了读端,但写端进程仍在向管道写入数据。
- 表现:操作系统会将写端进程杀掉。
- 解释:当读端进程关闭读端后,写端继续向管道写入数据,此时操作系统会向写端进程发送信号,通知其管道已关闭,然后将写端进程杀掉。
示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
int pipefd[2];
pid_t pid;
char buffer[10];
// 创建管道
if (pipe(pipefd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
// 创建子进程
pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) { // 子进程
// 关闭写端,子进程从管道中读取数据
close(pipefd[1]);
// 读取数据
read(pipefd[0], buffer, sizeof(buffer));
printf("Child Process: Data read from pipe: %s\n", buffer);
// 关闭读端
close(pipefd[0]);
exit(EXIT_SUCCESS);
} else { // 父进程
// 关闭读端,父进程向管道中写入数据
close(pipefd[0]);
// 写入数据
write(pipefd[1], "Hello", 5);
printf("Parent Process: Data written to pipe: Hello\n");
// 关闭写端
close(pipefd[1]);
// 等待子进程结束
wait(NULL);
exit(EXIT_SUCCESS);
}
return 0;
}
在这个示例中,父进程创建了一个管道并生成了一个子进程。父进程通过管道将字符串"Hello"写入管道,而子进程则从管道中读取数据并打印到控制台上。在这个过程中,我们可以观察到以下特殊情况:
- 写端进程不写,读端进程一直读:在这个示例中,如果父进程不写入数据,子进程会一直阻塞在读取管道的操作,直到有数据可读。
- 读端进程不读,写端进程一直写:如果子进程不读取管道中的数据,而父进程持续写入数据,那么管道会被写满,父进程的写操作会阻塞,直到有空间可写。
- 写端进程写完后关闭写端:在父进程写入数据后,关闭了写端。子进程读取完数据后,管道的读端会返回EOF,子进程继续执行后续代码。
- 读端进程关闭读端:在子进程读取完数据后,关闭了读端。如果父进程继续写入数据,操作系统会向父进程发送信号,告知读端已关闭,然后父进程被杀死。
管道的大小
我们可以利用管道四种特殊情况中的第二种 读端进程不读,写端进程一直写 来验证:
让读端不读,写端进程一直写,直到写端被写满无法继续写时,则可以得到管道具体的大小。
#include <iostream>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string>
const int size = 1024;
//写
void SubProcessWrite(int wfd)
{
int pipesize = 0;
std::string message = "father, i am your son process!";
while(true)
{
char c = 'A';
write(wfd,&c,1);
std::cout << "pipesize = " << ++pipesize << std::endl;
}
}
//读
void FatherProcessRead(int rfd)
{
// sleep(500);
char inbuffer[size]; //用于储存读到的信息的缓冲区
while(true)
{
// sleep(4);
ssize_t n = read(rfd, inbuffer, sizeof(inbuffer) - 1);
if(n > 0)
{
inbuffer[n] = 0;
std::cout << "father get message" << inbuffer << std::endl;
}
}
}
int main()
{
//创建管道
int pipefd[2];
int n = pipe(pipefd); //输出型参数
if(n != 0)
{
std::cerr << "errno: " << errno << ":" << "errstring: " << strerror(errno) << std::endl;
return 1;
}
//pipefd[0] ->0 -r 嘴巴 读 pipefd[1] ->1 -w 🖊 写
std::cout << "pipefd[0]: " << pipefd[0] << " " << "pipefd[1]: "<< pipefd[1] << std::endl;
//创建子进程
pid_t id = fork();
if(id == 0)
{
std::cout << "子进程已经关闭了读fd,保留写fd,准备开始写消息了。" << std::endl;
//关闭不必要的fd
//子进程
//write
close(pipefd[0]);
SubProcessWrite(pipefd[1]);
close(pipefd[1]);
exit(0);
}
//父进程
//read
std::cout << "父进程已经关闭了写fd,保留读fd,准备开始读消息了。" << std::endl;
close(pipefd[1]);
// FatherProcessRead(pipefd[0]);
// close(pipefd[0]);
int status = 0;
pid_t rid = waitpid(id,&status,0);
if(rid > 0)
{
std::cout << "wait child process done, exit sig:" << (status&0x7f) << std::endl;
std::cout << "wait chile process done, exit code(ign)" << ((status>>8)&0xff) << std::endl;
}
return 0;
}
可知:我当前Linux版本中管道的最大容量是65536字节。
命名管道
命名管道的原理
命名管道的原理
命名管道是一种特殊类型的文件系统对象,它允许不同进程通过文件来进行通信。其原理基于文件系统的特性,它实际上是一个由操作系统维护的特殊文件,具有磁盘上的路径名,可以在文件系统中找到。与匿名管道不同,命名管道可以通过文件系统中的路径名进行访问,从而允许不同进程在不同的时间段内进行通信。
创建一个命名管道
我们可以使用mkfifo
命令创建一个命名管道。
qq@iZ0jl65jmm6w9evbwz2zuoZ:~/bt111/Linux/5_09/test$ mkfifo fifo
可以看到,创建出来的文件的类型是p,代表该文件是命名管道文件。
我们这里通过shell简单的进行两个进程的通信,这里可以看到左边的进程使用while done 来向fifo
写入数据,右边进程来读取fifo
的信息。
我们也可以使用mkfifo
函数来创建命名管道
mkfifo
函数
mkfifo函数是一个用于创建命名管道的Unix/Linux函数。他的原型如下:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
参数:
- pathname:指定要创建的命名管道的路径名。
- mode:指定创建的管道的权限模式(权限位)。三位八进制数字。具体的权限可查看这篇文章Linux当中的权限问题
-
创建命名管道: 首先,需要使用特定的系统调用(如mkfifo函数)在文件系统中创建一个命名管道。这个系统调用将在文件系统中创建一个特殊类型的文件,其类型为FIFO(先进先出),并分配一个唯一的路径名。
-
进程打开管道: 创建命名管道后,进程可以通过打开文件系统中的路径名来访问该管道。进程可以像打开普通文件一样打开命名管道,并且可以使用文件描述符来进行读取和写入操作。
-
进程读写管道: 一旦管道被打开,进程就可以使用相应的文件描述符进行读取和写入操作。写入到管道的数据会按照先进先出的顺序被读取出来。
-
进程关闭管道: 当进程不再需要使用管道时,应该关闭相应的文件描述符,以释放系统资源。
总的来说,命名管道的原理是利用文件系统的特性创建一个特殊类型的文件对象,允许不同进程通过文件进行通信。这种通信方式具有持久性和可靠性,不同进程可以在不同的时间段内进行通信,而不受进程生命周期的限制。
命名管道的打开规则
- 如果当前打开操作是为读而打开FIFO时
-
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
-
- O_NONBLOCK enable:立刻返回成功
- 如果当前打开操作是为写而打开FIFO时
-
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
-
- O_NONBLOCK enable:立刻返回失败,错误码为ENXIO
用命名管道实现 serve&client 通信
我们先来写一个管道文件,如何在serve 和 client 端分别打开这个管道文件。
在这个管道文件中分别封装 读操作接口 和 写操作接口 以供使用。
namePipe.hpp
#pragma once
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
const std::string comm_path = "./myfifo";
#define DefaultFd -1
#define Creater 1
#define User 2
#define Read O_RDONLY
#define Write O_WRONLY
#define BassSize 4096
class NamePiped
{
private:
bool OpenNamePipd(int mode) //打开对应管道 mode决定打开方式
{
_fd = open(_fifo_path.c_str(),mode);
if(_fd < 0)
{
return false;
}
return true;
}
public:
NamePiped(const std::string &path, int who)
:_fifo_path(path)
,_id(who)
{
int res = mkfifo(_fifo_path.c_str(),0666); //创建命名管道 名字是myfifo
if(res != 0)
{
//创建失败
perror("mkfifo");
}
std::cout << "Creater create named pipe" << std::endl;
}
//创建打开接口
//读
bool OpenForRead()
{
return OpenNamePipd(Read);
}
//写
bool OpenForWrite()
{
return OpenNamePipd(Write);
}
//write 输入项参数
int WriteNamePipe(const std::string &in)
{
return write(_fd,in.c_str(),in.size());
}
//read 输出型参数
int ReadNamePipe(std::string *out)
{
char buffer[BassSize];
int n = read(_fd,buffer,sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;
*out = buffer;
}
return n;
}
~NamePiped()
{
if(_id == Creater)
{
int res = unlink(_fifo_path.c_str());
if(res != 0)
{
perror("unlink");
}
std::cout << "creater free named pipe" << std::endl;
}
if(_fd != DefaultFd) close(_fd);
}
private:
const std::string _fifo_path; //命名管道的地址
int _fd; //fd 文件描述符
int _id; //id 用于判断Creater | User
};
client.cc
#include "namePipe.hpp"
//write 写
int main()
{
NamePiped fifo(comm_path,User);
if(fifo.OpenForWrite())
{
std::cout << "client open named pipe done" << std::endl;
while(true)
{
std::cout << "Pleanse Enter>";
std::string message;
std::getline(std::cin,message);
fifo.WriteNamePipe(message);
}
}
return 0;
}
server.cc
#include "namePipe.hpp"
//read 读
int main()
{
NamePiped fifo(comm_path,Creater);
if(fifo.OpenForRead())
{
std::cout << "server open named pipe done" << std::endl;
while(true)
{
std::string message;
int n = fifo.ReadNamePipe(&message);
if(n > 0)
{
std::cout << "Client say > " << message << std::endl;
}
else if(n == 0)
{
std::cout << "Client quit, Server Tool" << std::endl;
}
else
{
std::cout << "fifo.ReadNamedPipe Error" << std::endl;
break;
}
}
}
return 0;
}
演示:
命名管道和匿名管道的区别
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,由open函数打开。
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。