目录
1.进程通信的介绍
进程是操作系统分配系统资源的单位,每个进程都有独立的内存地址空间。为了确保进程之间是相互独立的,进程间的数据不会冲突,操作系统限制了进程间的访问权限——一个进程不能直接访问另一个进程的数据。但在实际应用中,进程之间往往需要进行信息交换或相互协作,比如以下四种情况,
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程并获取其状态。
这时就需要进行进程通信。
1.1 进程通信的发展
早期进程通信的方式
- 文件方式:一个进程通过将数据写入文件,另一个进程从该文件中读取数据,从而实现进程间的通信。方式简单但效率低下。
- 剪切板:在Windows操作系统中,剪贴板首先解决了不同程序间的通信问题。它作为数据交换中心,允许应用程序之间进行复制和粘贴操作。然而,剪贴板传递的都是“死”数据,需要应用程序开发者自行编写、解析数据格式的代码。
低级进程通信方式
随着操作系统的发展,出现了能够传递状态和整数值的低级进程通信方式。这种方式虽然简单,但通信效率较低,且无法满足大量数据传输和复杂通信需求。
高级进程通信方式
为了克服低级通信方式的局限性,高级进程通信方式应运而生。高级通信方式能够传递大量数据,提高通信效率,并减轻程序编制的复杂度。高级进程通信主要分为以下几种方式:
共享内存模式
共享内存模式允许两个或多个进程共享同一块内存区域。通过这块共享内存,进程可以方便地实现数据交换和通信。这种方式具有通信速度快、效率高的优点,但需要额外的同步机制来保证数据的一致性和完整性。消息传递模式
消息传递模式通过发送和接收消息来实现进程间的通信。每个消息都包含一个数据段和一个类型标识符,接收进程可以根据类型标识符来识别和处理不同的消息。这种方式具有灵活性高、可靠性好的优点,但通信效率可能受到消息大小和传输延迟的影响。共享文件模式
共享文件模式类似于早期的文件通信方式,但进行了改进和优化。进程可以通过访问共享文件来实现数据交换和通信。为了提高效率和可靠性,共享文件模式通常采用文件锁和缓存机制来避免数据冲突和丢失。
1.2 进程通信的分类
管道(文件级别的通信方式):匿名管道pipe,命名管道。
System V IPC:System V IPC(Inter-Process Communication,进程间通信)是在Unix操作系统上实现进程间通信的一种机制,它引入了一大类进程间通信方法。主要包括System V 消息队列,System V 共享内存,System V 信号量。
POSIX IPC:POSIX IPC(POSIX Inter-Process Communication)是一组在POSIX标准中定义的进程间通信(IPC)机制。主要通过消息队列,共享内存,信号量,互斥量,条件变量,读写锁等方式使进程进行通信。
2. 管道
管道是一种在两个进程之间实现单向通信的机制,它允许一个进程(称为写进程)将数据传输给另一个进程(称为读进程)。
2.1 管道的原理
管道的原理,本质就是让两个进程可以“看到”同一份资源,并进行单项操作。我们如何让两个进程访问同一份资源呢?只要这两个进程是父子关系,就可以做到。
简单的说,管道就是利用子进程继承父进程的文件描述符表(文件描述符所引用的文件表项和i-node是共享的),子进程可以访问父进程打开的文件,也就是说子父进程可以访问同一块文件(注意,该文件是内存级文件,不映射在磁盘中),然后让子父进程对该文件进行单项操作。
图1(这是父进程写子进程读):
图2(父进程读子进程写):
问题1:如果要进行双向通信,该怎么办?
答:采用两个管道可以实现双向通信。
问题2:必须是父子进程才可以进行通信吗?
答:不一定,兄弟进程继承同一个父进程的文件描述符表,也可以访问同一个文件。所以兄弟进程也可以实现“管道”。同理,兄弟进程又可以作为父进程创建子进程,而这些进程的文件描述符表本质上都继承于最初的父进程,都可以访问同一块文件。所以,任何具有“血缘”关系的进程都可以利用管道的原理进行通信。
问题3:作为进程通信的内存文件,有没有路径和文件名?inode是多少?
答:这些都没有。该文件只存在于内存中,是通过继承父进程而来,不需要通过路径或者文件名去标定或者识别它,更不需要inode保存该文件的属性和所在磁盘位置。而这种没有名字的管道,我们称为匿名管道。
问题4:为什么管道实现的原理中进程不以读写方式打开同一个文件,而是以两种方式分别打开同一个文件?不麻烦吗?
答:如果以读写的方式打开文件,文件描述符只有一个,不能通过关闭文件描述符实现单向通信。
以上,我们只是为进程通信建立起“管道”,进程并没有真实的进行通信。为什么要弄得这么“复杂呢”?因为进程之间具有独立性,通信是具有成本的。
2.2 管道的接口
Linux Manpages Online - man.cx manual pages
管道创建成功就返回0,失败就返回-1。
pipefd[2]是一个输出型参数,传出两个值,其中pipefd[0]的值表示以“读方式”打开文件的文件描述符,pipefd[1]的值表示以“写方式”打开文件的文件描述符。(一般默认传出的文件描述符是fd[3],fd[4])
我们可以编写一个makefile文件和testPipe.cc文件测试一下,
紧接着我们创建一个子进程,并建立“单向信道“(子进程写,父进程读)。
2.3 用pipe实现通信
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define N 2
#define NUM 1024
using namespace std;
//子进程向管道写入
void Writer(int wfd)
{
string s = "hello,I am child";
pid_t self = getpid();
int number = 0;
char buffer[NUM];
while(true)
{
//构建待发送的字符串
buffer[0] = 0;//字符串第一个元素为“\0”,相当于间接清空字符串,
snprintf(buffer,sizeof(buffer),"%s-%d-%d",s.c_str(),self,number++);//将要传输的内容转换成字符串,并输出到buffer中
// cout << buffer << endl;
//发送给父进程
write(wfd,buffer,strlen(buffer));//字符串写入管道
}
}
//父进程从管道读取
void Read(int rfd)
{
//同样,我们也需要创建一个buffer,来接收管道中的数据,并将其转换成字符串格式(数据后加0)
char buffer[NUM];
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd,buffer,sizeof(buffer));//
if(n > 0)
{
//在C和C++中,字符常量0与字符'\0'是等价的,都代表空字符(null character)。
buffer[n] = 0;
cout << "father get a message[" << getpid() <<"]#" << buffer << endl;
}
//ToDo
}
}
int main()
{
// 1.父进程创建管道
int pipefd[N];
int n = pipe(pipefd);//unistd.h
if(n < 0)
return 1;
// cout << "pipefd[0]:" << pipefd[0] << " , pipefd[1]:" << pipefd[1] << endl;
// 2.父进程创建子进程
pid_t id = fork();//unistd.h
if(id < 0) return 1;
if(id == 0)
{
//子进程执行任务(写)
close(pipefd[0]);//unistd.h,建立单向信道
//IPCcode
Writer(pipefd[1]);
//任务执行完毕,可以关掉子进程的另一个文件描述符
close(pipefd[1]);
//子进程退出
exit(0);//cstdlib
}
//父进程执行任务(读)
close(pipefd[1]);//建立单向通道
//IPCcode
Read(pipefd[0]);
//任务执行完毕,可以关掉父进程的另一个文件描述符
close(pipefd[1]);
//父进程需要获取子进程的退出状态
pid_t rid = waitpid(id,nullptr,0);
if(rid < 0)
return 3;
return 0;
}
2.4 管道的五大特征
- 通过管道进行通信的进程必须具有“血缘关系”。
- 管道只能单向通信。
- 管道是基于文件,文件的生命周期与打开文件的进程有关, 当打开文件的所有进程退出时,文件所占资源就会被操作系统回收,即当通过某个管道进行通信的所有进程关闭时,管道会被回收。
- 管道是面向字节流的。
- 父子进程是协同的,同步与互斥的——保护管道文件的数据安全。
管道是有大小的。
我们通过代码测试一下管道的大小,我们修改子进程的读取,利用while循环让子进程一直玩管道写入字符'c',然后让父进程的读取延缓50秒,这样子进程就有足够的时间写满管道。
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define N 2
#define NUM 1024
using namespace std;
//子进程向管道写入
void Writer(int wfd)
{
string s = "hello,I am child";
pid_t self = getpid();
int number = 0;
char buffer[NUM];
while(true)
{
// //构建待发送的字符串
// buffer[0] = 0;//字符串第一个元素为“\0”,相当于间接清空字符串,
// snprintf(buffer,sizeof(buffer),"%s-%d-%d",s.c_str(),self,number++);//将要传输的内容转换成字符串,并输出到buffer中
// // cout << buffer << endl;
// //发送给父进程
// write(wfd,buffer,strlen(buffer));//字符串写入管道
//利用while循环将管道写满
char c = 'c';
write(wfd,&c,1);
number++;
cout << number <<endl;
}
}
//父进程从管道读取
void Read(int rfd)
{
sleep(50);//延缓父进程的读取,让子进程写满管道
//同样,我们也需要创建一个buffer,来接收管道中的数据,并将其转换成字符串格式(数据后加0)
char buffer[NUM];
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd,buffer,sizeof(buffer));//
if(n > 0)
{
//在C和C++中,字符常量0与字符'\0'是等价的,都代表空字符(null character)。
buffer[n] = 0;
cout << "father get a message[" << getpid() <<"]#" << buffer << endl;
}
//ToDo
}
}
int main()
{
// 1.父进程创建管道
int pipefd[N];
int n = pipe(pipefd);//unistd.h
if(n < 0)
return 1;
// cout << "pipefd[0]:" << pipefd[0] << " , pipefd[1]:" << pipefd[1] << endl;
// 2.父进程创建子进程
pid_t id = fork();//unistd.h
if(id < 0) return 1;
if(id == 0)
{
//子进程执行任务(写)
close(pipefd[0]);//unistd.h,建立单向信道
//IPCcode
Writer(pipefd[1]);
//任务执行完毕,可以关掉子进程的另一个文件描述符
close(pipefd[1]);
//子进程退出
exit(0);//cstdlib
}
//父进程执行任务(读)
close(pipefd[1]);//建立单向通道
//IPCcode
Read(pipefd[0]);
//任务执行完毕,可以关掉父进程的另一个文件描述符
close(pipefd[1]);
//父进程需要获取子进程的退出状态
pid_t rid = waitpid(id,nullptr,0);
if(rid < 0)
return 3;
return 0;
}
运行,大概疫苗后,终端输出的最后一行停在65536,说明管道中写满了65536个字节,也就是64KB,大于通过命令“ulimit -a”看到pipe size。
其实不同的内核版本,管道的大小可能不同,
我们当前使用的版本是CentOS7.6,其管道大小是64KB。输入命令"man 7 pipe",
pipe size可以看作是PIPE_BUF的大小,atomic是原子的,表示一个操作和行为是不可分割的,也就是说当写入的数据小于PIPE_BUF时,会将数据当作一个整体写入管道中。
2.5 管道通信的四种情况
- 读写端正常。管道为空,读端阻塞。
- 读写端正常。管道已满,写端阻塞。
- 读端正常读,写端关闭,读端读到pipe结尾后,“向管道读去”的进程不会被阻塞。我们实验一下,子进程先写入五个字节,写入后子进程退出,看看父进程会怎么样
//子进程向管道写入
void Writer(int wfd)
{
string s = "hello,I am child";
pid_t self = getpid();
int number = 0;
char buffer[NUM];
while(true)
{
sleep(1);
char c = 'c';
write(wfd,&c,1);
number++;
cout << number <<endl;
if(number >= 5) break;//先写五个字节,写完退出
}
}
//父进程从管道读取
void Read(int rfd)
{
//同样,我们也需要创建一个buffer,来接收管道中的数据,并将其转换成字符串格式(数据后加0)
char buffer[NUM];
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd,buffer,sizeof(buffer));//
if(n > 0)
{
//在C和C++中,字符常量0与字符'\0'是等价的,都代表空字符(null character)。
buffer[n] = 0;
cout << "father get a message[" << getpid() <<"]#" << buffer << endl;
}
//ToDo
cout << "读取的字节数n:" << n << endl;
}
}
可以看到,当子进程写完五个字节后,父进程就读到管道结尾,但还是一直打印“读取的字节数n”,我们修改一下父进程读取的代码,
//父进程从管道读取
void Read(int rfd)
{
//同样,我们也需要创建一个buffer,来接收管道中的数据,并将其转换成字符串格式(数据后加0)
char buffer[NUM];
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd,buffer,sizeof(buffer));//
if(n > 0)
{
//在C和C++中,字符常量0与字符'\0'是等价的,都代表空字符(null character)。
buffer[n] = 0;
cout << "father get a message[" << getpid() <<"]#" << buffer << endl;
}
else if(n == 0)
{
printf("father read file done!\n");
break;
}
else break;
}
}
这样父进程读到管道结尾,就会回收子进程并退出。
4.写端正常写,读端关闭,操作系统会通过信号杀死正在写入的进程。为什么要这样呢?我们可以这样理解,“因为操作系统不做低效、浪费资源的工作”。
我们编写代码测试一下,子进程一直写,父进程读3秒后关闭读端的文件描述符,然后观察子进程的状态变化。
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define N 2
#define NUM 1024
using namespace std;
//子进程向管道写入
void Writer(int wfd)
{
string s = "hello,I am child";
pid_t self = getpid();
int number = 0;
char buffer[NUM];
while(true)
{
sleep(1);
buffer[0] = 0;
snprintf(buffer,sizeof(buffer),"%s-%d-%d",s.c_str(),self,number++);
write(wfd,buffer,strlen(buffer));//字符串写入管道
}
}
//父进程从管道读取
void Read(int rfd)
{
char buffer[NUM];
int cnt = 0;
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd,buffer,sizeof(buffer));//
if(n > 0)
{
buffer[n] = 0;
cout << "father get a message[" << getpid() <<"]#" << buffer << endl;
}
else if(n == 0)
{
printf("father read file done!\n");
break;
}
else break;
cnt++;
if(cnt == 3) break;
}
}
int main()
{
// 1.父进程创建管道
int pipefd[N];
int n = pipe(pipefd);//unistd.h
if(n < 0)
return 1;
// 2.父进程创建子进程
pid_t id = fork();//unistd.h
if(id < 0) return 1;
if(id == 0)
{
//子进程执行任务(写)
close(pipefd[0]);//unistd.h,建立单向信道
Writer(pipefd[1]);
close(pipefd[1]);
exit(0);//cstdlib
}
//父进程执行任务(读)
close(pipefd[1]);//建立单向通道
//IPCcode
Read(pipefd[0]);//读5秒
close(pipefd[0]);//关闭读端的文件描述符
cout << "father close read fd:" << pipefd[0] << endl;
sleep(3);//延迟父进程回收,观察子进程的状态
//父进程需要获取子进程的退出状态
int status = 0;
pid_t rid = waitpid(id,&status,0);
if(rid < 0)
return 3;
cout << "exit code:" << ((status>>8)&0xFF) << "exit signal:" << (status&0x7F) << endl;
sleep(3);
cout << "father exit" << endl;
return 0;
}
运行结果图下,
3. 管道的应用
bash子进程间的管道通信