目录
写在前面的话
什么是进程间通信
为什么要进行进程间通信
进程间通信的本质理解
进程间通信的方式
管道
System V IPC
POSIX IPC
管道
什么是管道
匿名管道
什么是匿名管道
匿名管道通信的原理
pipe()的使用
匿名管道通信的特点
拓展代码
命名管道
什么是命名管道
命名管道通信的原理
mkfifo的使用
代码模拟命名管道通信过程
写在前面的话
本章是首次提出进程间通信的概念,所以会先介绍进程间通信的相关概念,以及整体的框架结构。
而本文重点是先介绍进程间通信的基本概念,然后重点介绍进程间通信的第一种方式:管道。
什么是进程间通信
进程间通信(Inter-Process Communication,IPC)是指操作系统或计算机系统中,不同进程之间进行数据交换和通信的机制或技术。由于进程是操作系统中独立运行的程序实例,而进程间通信允许这些独立的进程之间相互协作、共享资源和进行数据交换。
为什么要进行进程间通信
根据我们前面讲的,进程间是相互独立的,进程具有独立性啊,那通信不就不独立了吗?
答案是正确的,进程通信的确会破坏进程的完全独立性,因为进程通信的目的是为了实现进程之间的数据共享、同步和协作。通过进程通信,各个进程可以相互交互和共享资源,这意味着它们不再完全独立,而是具有一定的相互依赖性和关联性。
尽管进程通信破坏了进程的完全独立性,但这种破坏是有意义且必要的。在实际的计算机系统和操作系统中,进程往往需要协同工作、共享资源和交换数据才能完成复杂的任务。进程通信提供了一种机制,使得不同进程之间可以进行必要的协作和交流,并提供了相应的同步和保护机制来确保数据的正确性和一致性。
所以这是一种权衡和折中的方案,但大部分情况下进程是相互独立的。
综上,进程间通信主要是为了完成下面这些作用:
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 资源共享:多个进程之间共享同样的资源(包括本地共享和远程资源共享)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程间通信的本质理解
1.我们知道进程具有独立性,是通过虚拟地址空间 + 页表映射的方式来保持独立性的,所以通信起来成本会比较高。
2.既然通信,那么前提是一定要让不同的进程看到同一块“内存”(特定的结构组织),这块"内存"不能隶属于任何一个进程,而更应该强调共享。
进程间通信不是目的,而是手段!
进程间通信的方式
大体上可以分为3种通信方式:
-
管道
- 匿名管道pipe
- 命名管道
-
System V IPC
- System V消息队列
- System V共享内存
- System V信号量
System V只能用于单机通信(本地通信).
-
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
POSIX IPC可以在单机通信的基础上,进行网络通信(远程资源共享)。
以上所提到的方式,我会在后面的章节逐一讲解,这个是进程间通信的方式.
今天我们将首要讲解进程间通信方式(一)——管道.
管道
什么是管道
管道(Pipe)是一种进程间通信机制,用于在相关进程之间传输数据。它是一种特殊的文件描述符,它可以连接一个进程的输出(写入端)到另一个进程的输入(读取端),从而使得这两个进程可以通过管道进行数据传输。
也就是说管道是单向传输的!现实生活中,我们所看听到的天然气管道、石油管道基本上都是单向传输的.
匿名管道
什么是匿名管道
匿名管道(Anonymous Pipe)是进程间通信的一种机制,用于在具有亲缘关系(例如父子进程)或共享同一终端的兄弟进程之间传输数据。
匿名管道是一种单向的数据流通道,它可以用于在进程之间传递数据。通常,一个进程作为管道的写入端(称为管道写入端),将数据写入管道;另一个进程作为管道的读取端(称为管道读取端),从管道中读取数据。
匿名管道的创建是通过系统调用 pipe()
来完成的。pipe的使用后面会讲。
匿名管道通信的原理
管道通信的背后是进程之间通过管道进行通信。
我们知道一个进程要运行,首先要加载到内存,然后创建一个task_struct结构体,里面会有一个files_struct结构体,然后这个结构体里又有一个fd_array[]数组,每个元素指向对应的文件struct_file,里面包含了文件内容等.
此时我们fork之后的子进程会重新创建一份task_struct,内容继承父进程的,此时fd_array[]里的内容也被子进程继承,即父进程打开的文件 子进程也继承了下来。它们指向的文件是 相同的.
假设父进程3号文件描述符是读取文件的,4号文件描述符也用来写入文件的,子进程继承以后,fd=3也是用来读取文件的,fd=4也是用来写入文件的
此时我们想让父进程进行写入fd=4,子进程进行读取fd=3,所以父进程就要关闭读端fd=3,子进程关闭写端fd=4。
这样我们就做到了让不同的进程看到了同一份资源(通过fork子进程),而且通过文件描述符的方式完成了进程间的单向通信。
综上,管道内部本质大体是如下流程:
1.父进程分别以读写方式打开一个文件
2.fork()创建子进程
3.双方各自关闭不需要的文件描述符
整体图如下:
pipe()的使用
既然我们知道了思路,那我们可以用代码来使用一下管道。
首先,父进程如何使用读和写方式分别打开文件呢?
这里使用到了pipe函数,函数用法及原型如下:
参数pipefd为输出型参数,我们提前在外部定义好数组,然后传入,结果就会保存在这个数组中,分别为pipefd[0],代表的是读端,pipefd[1],代表的是写端.
第二步我们利用fork创建子进程。
最后,子进程用来读取文件的内容,并关闭写端pipefd[1],父进程用来写入内容,同时关闭读端。
匿名管道通信的特点
一个小demo如下:
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <string.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
// 1.创建管道
int pipefd[2] = {0}; // pipefd[0] :读端, pipefd[1] :写端
int n = pipe(pipefd);
assert(n != -1);
#ifdef DEBUG
#endif
// cout << pipefd[0] << " " << pipefd[1] << endl;
// 2.创建子进程
pid_t id = fork();
assert(id != -1);
if (id == 0)
{
// 子进程
// 3.构建单向通信的信道,父进程写入,子进程读取
// 3.1关闭子进程不需要的fd
close(pipefd[1]);
char buffer[1024];
while (true)
{
ssize_t s = read(pipefd[0], buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = 0;
cout << "child get a message [" << getpid() << "] Father# " << buffer << endl;
}
}
exit(0);
}
// 父进程
// 构建单向通信的信道
// 3.1 关闭父进程不需要的fd
close(pipefd[0]);
string message = "我是父进程,我正在给你发消息";
int count = 0;
char send_buffer[1024];
while (true)
{
// 3.2 构建一个变化的字符串
snprintf(send_buffer, sizeof(send_buffer), "%s[%d] : %d", message.c_str(), getpid(), count++);
// 3.3 写入
write(pipefd[1], send_buffer, strlen(send_buffer));
// 3.4 sleep
sleep(1);
}
pid_t ret = waitpid(id, nullptr, 0);
assert(ret > 0);
close(pipefd[1]);
return 0;
}
然后此时我们编译运行 :
可以看到,父进程写入的内容,子进程全部读到了。
这里是静态图,看不出效果,这些信息其实是时隔1秒打印一条的.
我们看只有子进程在读取,可子进程我们并没有加任何的sleep啊,理论上应该子进程一直打印才对啊。
显示器是一个文件,而管道也是一个文件,父子进程同时向显示器 写入时,没有说一个进程会等另一个进程,而是各自打印各自的消息,互相干扰,这是缺乏访问控制。而管道文件提供了访问控制,使得子进程读取完得等待父进程写入才能读取。
这样,如果我们让子进程读取先sleep 10秒,期间父进程每隔1秒写入,等10秒过后,子进程开始读取,但会把 父进程写入10次的文件的内容全部一下读出来。这说明写入次数和读取次数没有直接关系。即管道是面向字节流的,具体怎么读需要定制协议,后面会说,
这里就针对与管道的特点做一些总结:
- 管道是用来进行具有血缘关系的进程进行进程间通信 --- 常用于父子间通信
- 管道具有通过让进程间协同,提供了访问控制
- 管道提供的是面向字节流式的通信服务 --- 面向字节流 --- 通过定制协议实现
- 管道是基于文件的,文件的生命周期是随进程的,即管道的生命周期也随进程的!
- 管道是单向通信的,就是半双工通信的一种特殊方式.
上面最后一条提到了半双工概念,这里来解释一下:
半双工:通信的双方只能在同一时间点单向的传输数据,即两个参与者不能同时发送和接收数据。在半双工通信中,通信双方必须交替使用共享的通信信道。例如,当一个人在对讲机上说话时,另一个人必须停止接收,然后才能回应。典型的半双工通信方式包括对讲机和卫星电台。
全双工:全双工通信允许在同一时间点双向地传输数据。这意味着通信的两个参与者能够同时发送和接收数据,而不需要交替使用通信信道。在全双工通信中,通信双方可以同时进行发送和接收操作,彼此之间的数据传输互不干扰。例如,电话通话是一个典型的全双工通信场景,双方可以同时说话和倾听对方的声音。
顺带总结一下管道的几种情况:
a.写快,读慢,写满就不能再写了
b.写慢,读快,管道没有数据时,读必须等待
这两种是由访问控制提供的.
c.写关,读继续读,会标识读到了文件结尾
d.写继续写,读关,OS会终止写进程
拓展代码
利用匿名管道的方式,创建多个子进程,然后父进程分别派发随机的任务,
总代码流程是:父进程首先load()加载方法,然后for循环创建多个进程,每次创建完成后,该进程都要与父进程(pipefd[1])建立关联,以方便父进程管理这些子进程。
其中每个子进程调用 waitCommand函数,会阻塞在read,等待着父进程的写入,然后父进程开始分发任务,当是对应的子进程时,子进程会执行对应的任务,然后继续while循环等待。
共两个文件,第一个文件ProcessPool.cc文件
#include <iostream> #include <vector> #include <ctime> #include <unistd.h> #include <stdlib.h> #include <assert.h> #include <sys/types.h> #include <sys/wait.h> #include "Task.hpp" using namespace std; #define PROCESS_NUM 5 int waitCommand(int waitFd, bool quit) // 如果对方不发任务就阻塞 { uint32_t command = 0; ssize_t s = read(waitFd, &command, sizeof(command)); if (s == 0) { quit = true; return -1; } assert(s == sizeof(command)); return command; } void sendAndWakeup(pid_t who, int fd, uint32_t command) { write(fd, &command, sizeof(command)); cout << "main process: call process " << who << " execute " << desc[command] << " through " << fd << endl; } int main() { load(); // pid : pipefd vector<pair<pid_t, int>> slots; // create multiple child process for (int i = 0; i < PROCESS_NUM; i++) { // 创建管道 int pipefd[2] = {0}; int n = pipe(pipefd); assert(n == 0); pid_t id = fork(); assert(id != -1); // 让子进程读取 if (id == 0) { // 关闭写端 close(pipefd[1]); // child process while (true) { // 等命令 bool quit = false; int command = waitCommand(pipefd[0], quit); // 如果对方不发任务就阻塞 if (quit) break; // 执行对应的命令 if (command >= 0 && command < handlerSize()) { callbacks[command](); } else { cout << "非法 command" << endl; } } exit(1); } // father process close(pipefd[0]); slots.push_back(pair<pid_t, int>(id, pipefd[1])); } // 父进程派发任务 srand((unsigned long)time(nullptr) ^ getpid() ^ 2311156L); while (true) { //选择一个任务 int command = rand() % handlerSize(); //选择一个进程,采用随机数的方式,选择进程来完成任务,随机数的方式负载均衡 int choice = rand() % slots.size(); // 把任务给指定的进程 sendAndWakeup(slots[choice].first, slots[choice].second, command); sleep(1); int select; //以下是手动派发任务 // int command = 0; // cout << "######################################" << endl; // cout << "1.show functions 2.send command" << endl; // cout << "######################################" << endl; // cout << "Please Select > "; // cin >> select; // if (select == 1) // showHandler(); // else if (select == 2) // { // cout << "Enter your Command > "; // // 选择任务 // cin >> command; // // 选择进程 // int choice = rand() % slots.size(); // // 把任务给指定的进程 // sendAndWakeup(slots[choice].first, slots[choice].second, command); // } // else // { // } } // 关闭fd,结束所有进程 for (auto &slot : slots) { close(slot.second); } // 回收所有子进程 for (auto &slot : slots) { waitpid(slot.first, nullptr, 0); } return 0; }
第二个文件为Task.hpp文件,主要是包含了任务的加载及任务的执行方法。
#pragma once #include<iostream> #include<vector> #include<string> #include<unordered_map> #include<unistd.h> #include<functional> using namespace std; typedef function<void()> func; vector<func> callbacks; unordered_map<int,string> desc; void readMySQL() { cout << "sub process[" << getpid() << "] 执行数据库被访问的任务\n" << endl; } void executeURL() { cout << "sub process[" << getpid() << "] 执行url解析任务\n" << endl; } void cal() { cout << "sub process[" << getpid() << "] 执行加密任务\n" << endl; } void save() { cout << "sub process[" << getpid() << "] 执行数据持久化\n" << endl; } void load() { desc.insert({callbacks.size(),"readMySQWL:读取数据库"}); callbacks.push_back(readMySQL); desc.insert({callbacks.size(),"executeURL:解析URL"}); callbacks.push_back(executeURL); desc.insert({callbacks.size(),"cal:进行加密计算"}); callbacks.push_back(cal); desc.insert({callbacks.size(),"save:进行数据的文件保存"}); callbacks.push_back(save); } void showHandler() { for(auto& iter: desc) { cout << iter.first << "\t" << iter.second << endl; } } int handlerSize() { return callbacks.size(); }
这样每次父进程都会随机给子进程派发随机的任务:
命名管道
与匿名管道不同,命名管道不需要亲缘关系的进程之间,也不需要共享同一终端。任意进程可以通过打开命名管道的读取端和写入端来与其进行通信。
什么是命名管道
命名管道(Named Pipe)是一种独立进程之间通信的机制,用于在无关的进程之间进行数据传输。
命名管道通过在文件系统中创建一个特殊的文件来实现通信。这个特殊的文件被称为FIFO(First-in, First-out)或命名管道。
命名管道通信的原理
和匿名管道一样,想让双方通信,必须先让双方看到同一份资源!它和匿名管道本质是一样的,只是看到资源的方式不同。
匿名管道是通过父子进程继承来看到同一份资源的,也叫做管道文件,这个文件是纯内存级的,所以没有名字,叫做匿名管道。
而命名管道是在磁盘上有一个特殊的文件,这个文件可以被打开,但是打开后不会将内存中的数据刷新到磁盘。在磁盘上就有了路径,而路径是唯一的,所以双方就可以通过文件的路径 来看到同一份资源,即管道文件。
这是命名管道的流程:
创建命名管道:通过调用系统调用
mkfifo()
在文件系统中创建一个特殊的文件,这个文件就是命名管道。创建命名管道时,需要指定管道的名称和所需的权限。打开命名管道:进程通过调用系统调用
open()
来打开命名管道,得到一个文件描述符。进程可以通过打开具有相同名称的文件来打开命名管道的读取端和写入端。进程通信:一旦命名管道被打开,进程就可以使用文件描述符进行通信。每个进程可以选择读取端或写入端与命名管道进行交互。
数据传输:进程在读取端通过调用
read()
系统调用从命名管道中读取数据,而在写入端通过调用write()
系统调用将数据写入命名管道中。读取端和写入端可以通过文件描述符进行数据的发送和接收。关闭命名管道:进程完成通信后,可以通过调用
close()
关闭命名管道的文件描述符来释放资源。当所有对命名管道的引用都被关闭时,管道的文件系统条目将被删除。
mkfifo的使用
上面提到了需要使用mkfifo来创建这个特殊的文件,来让独立的进程之间通过它进行通信,下面来看一下它的用法。
mkfifo [选项] 文件名
非常简单的使用方法,选项我们一般不用,所以直接mkfifo + 文件名即可
我们在当前路径下创建一个name_pipe的文件.
注意权限的最前面是p,代表是管道文件。
我们此时echo一句消息到这个管道文件中:
我们发现这里阻塞住了,这是因为一方向管道文件里写入了,但是另外一方还没有读,所以此时我们新建一个窗口,然后读取name_pipe里的内容:
这样信息便成功的被读取出来了,这就是mkfifo的简单使用。
代码模拟命名管道通信过程
其实过程和匿名管道类似,只是看到同一资源的手段不一样。
上面讲的mkfifo是指令创建,但是如果我想用代码该如何实现呢?这里有一个mkfifo函数:
第一个参数是创建的管道文件的路径,第二个是权限。
当创建成功时,mkfifo返回0,否则返回-1.
整体的流程是是这样的:
我们首先可以分成 服务端 和 客户端,服务端负责
1.创建管道文件并打开
2.进行与客户端正常的通信
3.最后关闭并删除管道文件
而客户端
1.首先要打开管道文件
2.然后进行与服务端正常的通信流程即可
这里为了方便,我们加入了日志,可以看到每一步的动作。
所以一共四个文件,分别为comm.hpp,client.cc,server.cc,Log.hpp.
comm.hpp
#pragma once #include<iostream> #include<cstdio> #include<cstring> #include<unistd.h> #include<vector> #include<string> #include<sys/types.h> #include<sys/stat.h> #include<fcntl.h> using namespace std; #define MODE 0666 #define SIZE 128 string ipcPath = "./fifo.ipc";
client.cc
#include "comm.hpp" int main() { //1.获取管道文件 int fd = open(ipcPath.c_str(),O_WRONLY); if(fd < 0) { perror("open"); exit(1); } //2.通信过程 string buffer; while(true) { cout << "please Enter Message Line :> "; getline(cin,buffer); write(fd,buffer.c_str(),buffer.size()); } //3.关闭文件 return 0; }
server.cc
#include"comm.hpp" #include"Log.hpp" int main() { //1.创建管道文件 if(mkfifo(ipcPath.c_str(),MODE) < 0) { perror("mkfifo"); exit(1); } Log("创建管道文件成功",Debug) << "step 1" << endl; //2.正常的文件操作 int fd = open(ipcPath.c_str(),O_RDONLY); if(fd < 0) { perror("open"); exit(2); } Log("打开管道文件成功",Debug) << "step 2" << endl; //3.编写正常的通信代码 char buffer[SIZE]; while(true) { memset(buffer,'\0',sizeof(buffer)); ssize_t s = read(fd,buffer,sizeof(buffer)-1); if(s > 0) { cout << "client say> " << buffer << endl; } else if(s == 0) { //end of file cerr << "read emd of file, client quit, server quit too!" << endl; break; } else { //read error perror("read"); } } //4.关闭文件 close(fd); Log("关闭管道文件成功",Debug) << "step 3" << endl; unlink(ipcPath.c_str());//通信完毕就删除文件 Log("删除管道文件成功",Debug) << "step 4" << endl; return 0; }
Log.hpp
#pragma once #include <iostream> #include <ctime> #include<string> using namespace std; #define Debug 0 #define Notice 1 #define Warning 2 #define Error 3 string msg[] = { "Debug ", "Notice", "Warning", "Error" }; ostream& Log(string message,int level) { cout << " | " << (unsigned)time(NULL) << " | " << msg[level] << " | " << message; return cout; }
然后我们再编译运行,可以再创建一个Makefile文件,直接编译好所有的文件,内容如下:
.PHONY:all all:client server client:client.cc g++ -o $@ $^ -std=c++11 server:server.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -rf client server
此时我们直接make即可。然后会得到两个可执行文件client和server.
我们打开两个窗口,首先运行server.
第一步创建管道完成,然后我们在另一个窗口运行客户端.
运行起来后,显示打开文件也成功了,这个时候,我们在客户端输入,服务端都能读取到:
然后我们ctrl + c 退出客户端,此时服务端也会break跳出循环,然后结束.
这样,利用命名管道通信的代码流程也就完成了.