一 进程间通信介绍
为什么进程间通信
操作系统的特点之一进程具有独立性, 以实现多任务并发处理.
既然进程具有独立性, 那通信一定会让二者产生联系, 那又何谈独立性呢?
进程的独立性是指不同进程的资源都是彼此独立的, 但进程不是孤立的. 进程与进程间是需要信息交互与状态传递等的通信. 比如说, 父进程等待子进程查看子进程状态, 在子进程完成任务时回收子进程信息, 而且有些程序的运行也需要多个进程进行配合才能完成。
比如说这个指令:cat file | grep 'hello'
它可以查看一个文件中的 "hello", 这个时候就需要一个进程专门负责打印文件, 另一进程负责进行字符过滤, 这种情况就需要多进程协同去完成了.
进程间通信目的
数据传输: 一个进程需要将它的数据发送给另一个进程
资源共享: 多个进程之间共享同样的资源
通知事件: 一个进程需要向另一个或一组进程发送消息, 通知它(它们)发生了某种事件(如进程终止时要通知父进程)
进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程), 此时控制进程希望能够拦截另一个进程的所有陷入和异常, 并能够及时知道它的状态改变。
进程之间如何通信
在进程具有独立性, 每个进程的资源都各自独立的前提下, 我们想要让进程之间进行通信, 就必须让两个进程看到同一份内容, 操作系统为两个进程提供一个共享的资源, 将数据放在这个资源内, 多个进程都可以读取.
所以进程间通信的本质是: 让不同的进程看到同一份资源.
进程间通信的方式有三种: 管道、System V和POSIX IPC。
管道和System V一般用于一台主机内的进程通信, POSIX IPC一般用于跨主机通信.
二 管道
管道是Unix中最古老的进程间通信的形式, 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”. 管道的本质是一个文件, 进程A把数据写入到文件中, 进程B把数据从文件中读取获取数据, 而且这两个进程一般是父子进程.
匿名管道
先来简单谈一谈匿名管道的原理, 一个进程被创建, 要有对应的tast_struct, task_struct内有文件描述表来管理打开的若干个文件, struct file存放着文件相关的信息来管理内存文件对象, 如指向inode的指针,方法集,文件缓冲区等.
现在父进程创建了一个子进程, task_struct会被拷贝一份, 对应也继承了一份独立的文件描述符表也指向父进程打开的文件, 但是文件在操作系统中是独立于进程的资源, 而是由文件系统管理的, 这些文件描述符仅仅是指向相同的文件资源, 而不是创建文件的新拷贝, 这就意味着子进程和父进程可以共享同一个文件, 它们对文件的操作会相互影响, 这就联系到了之前进程间通信的本质: 不同的进程看到同一份资源. 所以父进程向文件缓冲区写入的内容, 可以被子进程读取, 完成进程间通信.
如果父进程打开的是一个普通文件, 需要把数据刷新到磁盘上, 但是如果为了通信, 每次传输数据都要将数据刷新到磁盘上, 效率太低, 所以需要这个管道文件是一个内存级别的文件, 所以它不需要名字(路径), 所以这种文件叫匿名管道.
匿名管道的特点是: 只允许单向通信. 为什么?
因为这样实现方便, 将读与写区分开, 不会造成缓冲区内既有同一个进程需要读和需要写的部分, 很难区分. 所以设计成一个进程负责写, 另一个进程负责读. 所以双向通信只需要建立两个管道文件就可以了
管道文件被打开时, 会产生两个 struct file
结构体, 分别用于描述管道的读取和写入操作, 它们在内核中相互独立, 但都指向同一份资源. (其中pos是读/写的位置, cnt是引用计数)
现在想实现子进程写, 父进程读, 怎么办?
struct file内有引用计数, 用来表示当前struct file被多少个文件描述符所指向,所以将子进程的读端关闭, 父进程的写端关闭, 对应的引用计数会减1, 但引用计数不为0, 文件并不会被关闭. 这样就分别形成了一条单向的信道.
注意: 硬链接是磁盘中用来统计有多少文件名和inode产生映射关系的, 而struct file里的引用计数是用来表示当前struct file被多少个文件描述符所指向的.
双进程匿名管道通信
实现匿名管道通信需要通过父进程使用管道特定的系统调用, 两个进程都以可读可写的方式打开一个内存级文件, 并通过fork创建子进程继承原来的文件标识符, 根据传输数据的方向父子进程各自关闭自己的读端或者写端进从而形成一条单向的通信通道。由于这个管道文件是没有名字的,所以就叫做匿名管道.
创建匿名管道
头文件: #include <unistd.h>
原型: int pipe(int fd[2])
参数: fd文件描述符数组, 其中fd[0]表示读端, fd[1]表示写端.
返回值: 成功返回0, 失败返回-1, 并设置错误码功能: 创建一个内存级管道文件, 调用该函数的进程可以对该文件进行读写操作, 创建成功返回0, 失败则返回对应的错误码.
实现父进程不断读取, 子进程每隔1秒写入信息的匿名管道通信:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cassert>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define SIZE 1024
using namespace std;
int main()
{
//第一步, 创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n == 0);
(void)n; //release模式下assert被忽略, n不会被使用, 把n强转为void, 防止编译器报警
printf("pipefd[0]:%d,pipefd[1]:%d\n",pipefd[0],pipefd[1]);
//第二步, 创建子进程
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
//子进程写, 父进程读
//第三步,关闭不需要的读写端, 形成单向通信的信道
else if(id == 0)
{
//child
close(pipefd[0]);//子进程写,关闭读
//w - 只向管道写端里写,不打印
int cnt = 10;
while(cnt)
{
char message[SIZE];
snprintf(message,sizeof(message),"hello father, I am child: %d,cnt:%d",getpid(),cnt);//snprintf不管写入数据大小超不超出第二个参数,最后都会添加一个'\0'
cnt--;
write(pipefd[1],message,strlen(message));//strlen(message)即可, 不需要+1去读'\0', 因为只需要把有效字符读进文件
sleep(1);
}
close(pipefd[1]);//写不写都可以,进程退出直接会关
exit(0);
}
//父进程
else
{
//father
close(pipefd[1]);//父进程读, 关闭写
//r - 从管道读端读取
char buffer[SIZE];
while(true)
{
ssize_t n = read(pipefd[0],buffer,sizeof(buffer)-1);//保留一个\0的位置,因为要读到buffer中
buffer[n] = '\0';
cout << "father pid:" << getpid() << ", " << "child said:" << " \"" << buffer << "\" " << "to me!" <<endl;
}
pid_t rid = waitpid(id,nullptr,0);
if(rid == id)
cout << "wait success\n"<<endl;
close(pipefd[0]);//写不写都可以,进程退出直接会关
}
return 0;
}
疑问: 父子进程fork后会共享代码与数据, 为什么还要通过管道的方式去通信呢?
1. 因为继承而来的数据只是创建进程时的数据, 动态变化的数据却无法传递, 而且这种传递只能是父->子
2. 子进程的数据不可能通过这种父子共享的方式传递给父进程, 所以这里的场景故意设置为子写父读
管道的4种情况, 5种特性
特性1: 匿名管道可以允许具有血缘关系的进程之间进行进程间通信, 只要能把文件描述符表继承下去即可, 比如父子, 兄弟, 爷孙, 常用于父子, 仅限于此, 毫不相关的进程无法通过匿名管道通信.
上面的代码稍作修改, 在子进程中再创建一个子进程, 实现爷孙进程通信:
特性2: 匿名管道默认给读写端要提供同步机制, 从情况 1 和 2 都可以看出
特性3: 管道是面向字节流的, 从情况1.2可以看出
情况1.1: 如果管道没有数据, 读端必须等待, 直到有数据为止(写端写入数据).
把子进程的写入设置为100秒, 写入很慢, 父进程就必须等待子进程写入:
情况1.2: 读端并不会管写端一次写了多少, 按什么方式写, 它读取一次只会按照设置的固定大小去尽可能的读取.
让父进程每隔2秒读一次, 读的比较慢, 子进程写的比较快:
可以看到读端并不会管写端一次写了多少, 按什么方式写, 它读取一次只会按照设置的固定大小 (这里是sizeof(buffer)-1) 去尽可能的读取, 所以读一次可能就会把曾经写入的几十上百次数据全读出来
情况2: 如果管道被写满了, 写端必须等待, 直到有空间为止(读端读取数据).
计算一下管道容量65536字节--64KB :
特性4: 管道的生命周期是跟随进程的, 管道也是文件, 一个进程打开一个文件, 就算没有关闭文件, 等到进程退出时也会自动释放掉.
情况3: 写端关闭, 读端一直读取, 读端read返回0, 表示读到了文件尾.
子进程写4条数据后就退出, 父进程一直读取:
else if(id == 0)
{
int cnt = 0;
while(true)
{
char message[SIZE];
snprintf(message,sizeof(message),"hello father, I am child: %d,cnt:%d",getpid(),cnt);//snprintf不管写入数据大小超不超出第二个参数,最后都会添加一个'\0'
cnt++;
write(pipefd[1],message,strlen(message));//strlen(message)即可, 不需要+1去读'\0', 因为只需要把有效字符读进文件
if(cnt>3)
break;
sleep(1);
}
close(pipefd[1]);//写不写都可以,进程退出直接会关
exit(0);
}
else
{
//father
close(pipefd[1]);//父进程读, 关闭写
//r - 从管道读端读取
char buffer[SIZE];
while(true)
{
ssize_t n = read(pipefd[0],buffer,sizeof(buffer)-1);//保留一个\0的位置,因为要读到buffer中
if(n > 0)
{
buffer[n] = '\0';
cout << "father pid:" << getpid() << ", " << "child said:" << " \"" << buffer << "\" " << "to me!" <<endl;
}
else if(n == 0)
{
cout << "child quit, me too!" << endl;
break;
}
cout << "father return val(n)" << n << endl;
sleep(1);
}
pid_t rid = waitpid(id,nullptr,0);
if(rid == id)
cout << "wait success\n"<<endl;
close(pipefd[0]);//写不写都可以,进程退出直接会关
}
此场景中, 当父子进程都退出了, 管道读写端的struct file都被释放, 管道就会被系统自动释放掉.
有一个疑问, 为什么这里和情况1不同, 情况1中写入的速度太慢导致管道没有数据, 写端会一直等待, 而这里管道也没有数据, 却会直接读取文件尾返回0?
情况1虽然读取很慢, 但是管道写端的struct file还在, 还有可能继续写, 因此要等待, 而情况3这里写端都被关闭了, 对应的写端struct file被释放, 表示不会再写入数据了.
情况4: 读端关闭, 写端一直写入, OS会直接杀掉写端进程, 通过向目标进程发送SIGPIPIE(13)号信号, 终止目标进程.
//子进程写, 父进程读
//第三步,关闭不需要的读写端, 形成单向通信的信道
else if(id == 0)
{
//child
int cnt = 0;
while(true)
{
char message[SIZE];
snprintf(message,sizeof(message),"hello father, I am child: %d,cnt:%d",getpid(),cnt);//snprintf不管写入数据大小超不超出第二个参数,最后都会添加一个'\0'
cnt++;
write(pipefd[1],message,strlen(message));//strlen(message)即可, 不需要+1去读'\0', 因为只需要把有效字符读进文件
sleep(1);
}
close(pipefd[1]);//写不写都可以,进程退出直接会关
exit(0);
}
//父进程
else
{
//father
close(pipefd[1]);//父进程读, 关闭写
//r - 从管道读端读取
char buffer[SIZE];
while(true)
{
ssize_t n = read(pipefd[0],buffer,sizeof(buffer)-1);//保留一个\0的位置,因为要读到buffer中
if(n > 0)
{
buffer[n] = '\0';
cout << "father pid:" << getpid() << ", " << "child said:" << " \"" << buffer << "\" " << "to me!" <<endl;
}
else if(n == 0)
{
cout << "child quit, me too!" << endl;
break;
}
cout << "father return val(n)" << n << endl;
sleep(1);
break;//读一次直接退出
}
cout << "read close"<<endl;
close(pipefd[0]);
sleep(5);
int status = 0;
pid_t rid = waitpid(id,&status,0);
if(rid == id)
cout << "wait success, child process exit signal: "<< (status&0x7F) << endl;
}
可以看到读端关闭后, 写端变成僵尸进程, 等待父进程回收.
特性5: 管道是单向通信的, 半双工通信的一种特殊情况
进程池
myprocess.hpp
#include <iostream>
#include <functional>
#include <vector>
#include <ctime>
typedef std::function<void()> task_t;
void downLoad()
{
std::cout << "我是一个下载任务" << std::endl;
}
void musicPlay()
{
std::cout << "我是一个视频播放任务" << std::endl;
}
void printlog()
{
std::cout << "我是一个打印日志任务" << std::endl;
}
class Init
{
public:
const int downLoad_task = 0;
const int musicPlay_task = 1;
const int printLog_task = 2;
//任务集合
std::vector<task_t> tasks;
public:
Init()
{
tasks.push_back(downLoad);
tasks.push_back(musicPlay);
tasks.push_back(printlog);
srand(time(nullptr)^getpid());
}
bool checkCode(int code)
{
return code >= 0 && code < tasks.size();
}
void runTask(int code)
{
return tasks[code]();
}
int selectTask()
{
return rand() % tasks.size();
}
std::string toDesc(int command)
{
switch(command)
{
case 0:
return "downLoad";
break;
case 1:
return "musicPlay";
break;
case 2:
return "printlog";
break;
default:
return "unknown";
break;
}
}
};
Init init;
myprocess.cc
#include <iostream>
#include <unistd.h>
#include <cassert>
#include <string>
#include <vector>
#include <sys/wait.h>
#include <sys/types.h>
#include "myprocess.hpp"
using namespace std;
const int num = 5;//信道总数量
class channel
{
public:
channel(int fd, pid_t id) : ctrlfd(fd), workerid(id)
{
name = "channel-" + std::to_string(number++);
}
public:
int ctrlfd; // 管道写端
pid_t workerid; // 工作进程
std::string name; //管道名称
static int number;//信道当前数量
};
int channel::number = 1;
void work()
{
while (true)
{
int code;
ssize_t n = read(0, &code, sizeof(code));
//正常读取
if(n == sizeof(code))
{
if (!init.checkCode(code))
continue;
init.runTask(code);
}
//写端关闭
else if(n == 0)
{
break;
}
else
{
perror("read fail:");
}
}
}
// 传参形式:
// 1. 输入参数:const &
// 2. 输出参数:*
// 3. 输入输出参数:&
void createChannels(vector<channel> *c)
{
for (int i = 0; i < num; i++)
{
// 1.定义并创建管道
int pipefd[2];
int n = pipe(pipefd);
assert(n == 0);
(void)n;
// 2.创建进程
pid_t id = fork();
assert(id != -1);
// 3.构建单向通信的信道
if (id == 0)
{
close(pipefd[1]);
dup2(pipefd[0], 0);
work();
exit(0);
}
else
{
close(pipefd[0]);
c->push_back(channel(pipefd[1], id));
}
}
}
void sendCommand(const vector<channel>& channels, int cnt = -1)
{
int pos = 0;
//开始发送任务, 不指定发送任务的数量, cnt默认为-1, 会一直发送任务
while (cnt == -1 || cnt--)
{
// 1.选择任务
int command = init.selectTask();
// 2. 选择信道,轮询
const auto &c = channels[pos++];
pos %= channels.size();
// debug
std::cout << "send command " << init.toDesc(command) << "[" << command << "]"
<< " in "
<< c.name << " worker is : " << c.workerid << std::endl;
// 3. 发送任务
write(c.ctrlfd, &command, sizeof(command));
//sleep(1);
}
std::cout << "Sendcommand done.." <<endl;
}
void releaseChannels(const vector<channel>& channels)
{
for(const auto& channel: channels)
{
close(channel.ctrlfd);
}
for(const auto& channel:channels)
{
pid_t rid = waitpid(channel.workerid, nullptr, 0);
if(rid == channel.workerid)
{
std::cout << "wait success:" << channel.workerid << endl;
}
}
}
void Printdebug(const std::vector<channel> &c)
{
for (const auto &channel : c)
cout << channel.name << ":" << channel.ctrlfd << " " << channel.workerid << endl;
}
int main()
{
std::vector<channel> channels;
//创建信道
createChannels(&channels);
//发送任务
sendCommand(channels,100);
//回收资源, 想让子进程退出, 并释放管道, 只需要将写端关闭, 让子进程读到0
releaseChannels(channels);
//Printdebug(channels);
return 0;
}
父进程回收阶段有一个bug, 上述代码中回收是先把管道写端全部关闭, 然后再依次回收, 如果想关闭一个信道就回收一个子进程, 正常来说是可以的, 因为是单向通信的信道, 一个管道只有一个读端和写端, 读端关闭写端正常应该读取到0, 判断读取到0后子进程退出.
void releaseChannels(const vector<channel>& channels)
{
for(const auto& channel: channels)
{
close(channel.ctrlfd);
pid_t rid = waitpid(channel.workerid, nullptr, 0);
if(rid == channel.workerid)
{
std::cout << "wait success:" << channel.workerid << endl;
}
}
}
但是这样运行会直接卡死:
如图, 在创建第一个管道的时候还没出现什么问题, 一个写端对应一个读端:
当创建了第二个管道的时候, 子进程会继承父进程的task_struct, 也就会继承父进程的文件描述符表, 父进程的读端就全部被继承了, 造成一个管道有多个写端, 从而只关闭一个父进程的读端时管道的读端没有全部关闭, 写端也就读不到0, 子进程一直不退出.
解决的方法是: 创建子进程时把会从父进程继承到的管道写端全部关闭:
// 传参形式:
// 1. 输入参数:const &
// 2. 输出参数:*
// 3. 输入输出参数:&
void createChannels(vector<channel> *c)
{
vector<int> old;
for (int i = 0; i < num; i++)
{
// 1.定义并创建管道
int pipefd[2];
int n = pipe(pipefd);
assert(n == 0);
(void)n;
// 2.创建进程
pid_t id = fork();
assert(id != -1);
// 3.构建单向通信的信道
if (id == 0)
{
//把继承的写端全部关闭
if(!old.empty())
{
for(const auto& fd : old)
close(fd);
}
close(pipefd[1]);
dup2(pipefd[0], 0);
work();
exit(0);
}
else
{
close(pipefd[0]);
c->push_back(channel(pipefd[1], id));
old.push_back(pipefd[1]);
}
}
}
命名管道
命名管道与匿名管道的原理一致, 只不过我们会直接建立一个管道文件, 通过这个管道文件进行进程间通信.
- 匿名管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
- 如果我们想在不相关的进程之间交换数据, 可以使用FIFO文件来做这项工作, 它经常被称为命名管道
- 命名管道是一种特殊类型的文件
指令中:
首先创建一个命名管道文件:
向管道一端输入数据, 另一端读取数据:
不管写入多少内容, fifo文件的字节依然是0, 因为管道是用来进程间通信的, 不需要刷新缓冲区内容到磁盘:
代码中:
int mkfifo(const char *filename, mode_t mode);
在sys/types.h和sys/stat.h中定义, 创建一个文件名为filename, 初始权限为mode的管道文件.
实现一个程序使server可以接收并打印client发来的信息
client.cc
#include<iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#define FILENAME ".fifo"
int main()
{
int wfd = open(FILENAME, O_WRONLY | O_TRUNC);
if(wfd < 0)
{
std::cerr << "errno:" << errno << ", errno string:" << strerror(errno)<<std::endl;
return 1;
}
std::cout << "open fifo success... write" << std::endl;
std::string s;
while(true)
{
std::cout << "Please Enter:";
std::cin >> s;
ssize_t n = write(wfd, s.c_str(), s.size());
if(n < 0)
{
std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
break;
}
}
close(wfd);
std::cout << "close fifo success..." << std::endl;
return 0;
}
server.cc
#include<iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#define FILENAME ".fifo"
bool MakeFifo()
{
int n = mkfifo(FILENAME, 0666);
if( n < 0)
{
std::cerr << "errno:" << errno << ", errno string:" << strerror(errno)<<std::endl;
return false;
}
std::cout << "Create fifo success"<<std::endl;
return true;
}
int main()
{
Start:
int rfd = open(FILENAME,O_RDONLY);
if(rfd < 0)
{
std::cerr << "errno:" << errno << ", errno string:" << strerror(errno)<<std::endl;
if(MakeFifo())
goto Start;
else
return 1;
}
char buffer[1024];
while(true)
{
ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
if(n < 0)
{
std::cerr << "errno:" << errno << ", errno string:" << strerror(errno)<<std::endl;
return 2;
}
else if(n > 0)
{
buffer[n] = 0;
std::cout << "Client says# " << buffer << std::endl;
}
else
{
std::cout << "Server close"<<std::endl;
break;
}
}
close(rfd);
std::cout << "close fifo success..." << std::endl;
return 0;
}