进程间通信
1、前置知识
进程间通信是什么?——两个或者多个进程实现数据层面的交互。因为进程独立性的存在,所以进程间通信的成本会比较高。
为什么要进行进程间通信?——因为进程之间可能需要:1、发送基本数据。2、发送命令。3、某种协同。4、通知另一个进程。…
怎么办?
a、进程间通信的本质:让不同进程看到同一份资源。
b、资源是什么?——特定形式的内存空间。
c、这个资源谁提供?——一般是操作系统。为什么不是两个进程中的一个呢?——如果某一个进程提供,那么这个资源属于谁?本质上属于该进程,破坏进程的独立性。所以需要第三方空间。
d、进程访问这个空间进行通信,本质就是访问操作系统!进程代表的是用户,所以这个空间从创建、使用、释放需要通过系统调用来完成。
所以从底层、接口设计都要由操作系统独立设计。所以一般操作系统有一个通信模块,这个通信模块隶属于文件系统——IPC通信模块。
另外进行进程间通信的方式很多,所以还需要定制标准,有两套标准:system V和posix。system V用于本机内部通信,posix用于网络通信。
System V IPC包括:System V消息队列、System V共享内存、System V信号量。
Posix IPC包括:消息队列、共享内存、信号量、互斥量、条件变量、读写锁。
e、基于文件级别的通信方式——管道。
2、匿名管道
2.1、管道的原理

进程在操作系统中有一个task_struct对象,这个对象里面有struct files_struct* flies的指针,指向了struct files_struct,而在struct files_struct里面有文件描述符表-fd_array。fd_array是struct file*的指针数组,指向一个一个的struct file对象。进程默认会打开三个文件,0对应键盘文件,1、2对应显示器文件。
过去我们打开磁盘中的一个文件,系统会创建一个struct file对象,根据文件描述符的分配规则,寻找最小的没有被使用的数组下标,所以fd_array下标为3的指针指向了我们打开的文件。struct file里面有指向inode对象的指针,inode里面保存了文件的属性信息。也有指向file_operators的指针,file_operators里面是函数指针,对应文件的读写方法,实现一切皆文件。并且还有一个文件的页缓冲区。
那么其实我们可以让两个文件都打开磁盘中的同一个文件,然后一个进行写入,另一个进行读取,从技术的角度来说是可以做到的。但是这样要跟外设(磁盘)打交道,那么效率就会比较低。所以我们是不是可以直接在系统中创建一个struct file对象,这个对象也有对应的读写方法、属性、缓冲区。但是这个文件并不是磁盘上的文件,而是内存级别的文件。我们进程向这个文件写入数据,写入到缓冲区中,然后其他进程就通过缓冲区读取数据。
创建子进程后:

父进程以r方式打开这个文件,然后创建子进程,子进程会以父进程pcb为模板初始化自己的pcb。现在问题是struct files_struct这个对象会拷贝吗?答案是会的,直接继承父进程的,并且它们指向的struct file对象也是一样的。
通信本质需要先让不同的进程看到同一份资源!管道就是文件!
现在父子进程不就看到同一份资源了吗?父子进程看到同一个struct file对象,那么就可以让一个进程往里面写,另一个进程从里面读。但是现在问题是:父进程以读方式打开,子进程继承后也是只读的,如何实现通信呢?所以父进程就不能这么草率的打开文件了。

现在父进程打开两个文件,一个struct file用来读取数据,另一个用来写入数据。对于父进程来说,返回的fd=3就是读,4就是写。然后创建子进程后,子进程继承,所以子进程文件描述符3也是读,4也是写。
但是,管道只能进行单向通信。
所以我们需要根据情况让父子进程关闭读写文件。例如我想让子进程写父进程读,那么对于父进程来说就需要close(4),对于子进程就需要close(3)。虽然这不是强制规定的,但是我们建议这么做。
那么为什么不直接用一个struct file同时表示读写呢,因为读写文件会有偏移量的概念,假设现在向文件写入一部分数据,那么当前就会偏移到写入的位置,但是你读取要从头开始读入,所以你就读不到数据。
关闭后:

如图,这样就实现了单向通信。
如果我想进行双向通信呢?——多个管道。
如果两个进程没有任何关系,能否用上面的原理进行通信?——不能,必须是父子关系、兄弟关系,爷孙关系…。使用管道进行通信两个进程之间需要具有血缘关系,常用于父子。
这个文件有名字吗?没有,所以是匿名管道。
至此,通信了吗?——没有,这只是建立了通信信道,为什么如此费劲?——因为进程具有独立性,通信是有成本的。
2.2、编码实现


使用系统调用接口pipe创建管道,pipe有个参数为pipefd的数组。这个pipefd是一个输出型参数,它会将文件描述符数字带出来,让用户使用。pipefd[0]:读下标。pipefd[1]:写下表。
pipe成功返回0,失败返回-1并且错误码被设置。
编码实现进程间通信:我们让子进程进行写入,父进程进行读取。
#include <iostream>
#include <string>
#include <cstring>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
#define SIZE 1024
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid();
int number = 0;
char buff[SIZE] = {0};
while (true)
{
buff[0] = 0;
snprintf(buff, sizeof(buff), "%s-%d-%d", s.c_str(), self, number++);
write(wfd, buff, strlen(buff));
sleep(1);
}
}
void Reader(int rfd)
{
char buff[SIZE] = {0};
while (true)
{
buff[0] = 0;
ssize_t n = read(rfd, buff, sizeof(buff)-1);
if (n > 0)
{
buff[n] = 0;
cout << "father get a message[" << getpid() << "]# " << buff << endl;
}
}
}
int main()
{
int pipefd[2] = {0};
int n = pipe(pipefd);
if (n < 0) return 1;
pid_t id = fork();
if (id < 0) return 2;
if (id == 0)
{
close(pipefd[0]);
Writer(pipefd[1]);
close(pipefd[1]);
exit(0);
}
close(pipefd[1]);
Reader(pipefd[0]);
pid_t rid = waitpid(id, NULL, 0);
if (rid < 0) return 3;
close(pipefd[0]);
return 0;
}

注意到,我们代码中让子进程每隔一秒写入,父进程并没有休眠,但是父进程并不会读到垃圾数据。
结论1:读写端正常,管道如果为空,读端就要阻塞。
修改代码,让父进程每次都先休眠3秒再读取数据,而子进程一直向管道写入数据。通过子进程打印的number我们就可以观察子进程正在写入数据。
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid();
int number = 0;
char buff[SIZE] = {0};
while (true)
{
buff[0] = 0;
snprintf(buff, sizeof(buff), "%s-%d-%d", s.c_str(), self, number++);
write(wfd, buff, strlen(buff));
cout << number << endl;
//sleep(1);
}
}
void Reader(int rfd)
{
char buff[SIZE] = {0};
while (true)
{
sleep(3);
buff[0] = 0;
ssize_t n = read(rfd, buff, sizeof(buff)-1);
if (n > 0)
{
buff[n] = 0;
cout << "father get a message[" << getpid() << "]# " << buff << endl;
}
}
}

结果为:子进程一下子写入了很多数据,然后就停止了写入,父进程一次读取buff大小的数据。那么说明管道是有固定大小的,我们通过修改代码来看看管道有多大


我们让子进程每次只写入一个字符,然后让number++,并且输出。最后运行结果发现管道的大小为65536字节,也就是64KB。
结论2:读写端正常,管道如果被写满了,写端就要阻塞。
通过结论1、2我们知道,管道是自带同步与互斥机制的,保护了管道文件的数据安全。
另外我们可以看到管道是面向字节流的。
修改代码,子进程写入5次后break退出循环,关闭写端文件。父进程读取数据,并不断打印read返回值。
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid();
int number = 0;
char buff[SIZE] = {0};
while (true)
{
buff[0] = 0;
snprintf(buff, sizeof(buff), "%s-%d-%d", s.c_str(), self, number++);
write(wfd, buff, strlen(buff));
// cout << number << endl;
if (number >= 5) break;
sleep(1);
}
}
void Reader(int rfd)
{
char buff[SIZE] = {0};
while (true)
{
//sleep(3);
buff[0] = 0;
ssize_t n = read(rfd, buff, sizeof(buff)-1);
if (n > 0)
{
buff[n] = 0;
//cout << "father get a message[" << getpid() << "]# " << buff << endl;
}
cout << n << endl;
}
}

结果:前五次父进程都读到长度为25的字符串,子进程退出后,read返回值为0,并且不会阻塞。

read函数返回值:如果成功,返回读取字符的个数。返回0表示读取到文件的结尾。如果失败返回-1,并且错误码被设置。
所以在父进程的代码中,需要对read的返回值进行判断。
void Reader(int rfd)
{
char buff[SIZE] = {0};
while (true)
{
//sleep(3);
buff[0] = 0;
ssize_t n = read(rfd, buff, sizeof(buff)-1);
if (n > 0)
{
buff[n] = 0;
cout << "father get a message[" << getpid() << "]# " << buff << endl;
}
else if (n == 0)
{
cout << "father read file done!" << endl;
break;
}
else break;
}
}

结论3:读端正常读,写端关闭,读端就会读到0,表明读到了文件(pipe)结尾,不会被阻塞。
那么如果子进程break之后,不手动close(pipefd[1]),如果子进程没有手动关闭写端,那子进程退出的时候对应的文件也会被关闭,操作系统会释放。
所以管道是基于文件的,文件的生命周期是随进程的。
如果读端关闭了,那么对于写端来说,再写入就没有什么意义,因为写入也没有进程读取。 对于操作系统来说,操作系统是不会做低效、浪费等类似工作的,所以操作系统要杀掉正在写入的进程,如何杀掉呢?——通过信号杀掉。
下面修改代码让子进程不断写入,父进程读取5次后关闭读端,然后操作系统会发送信号杀掉子进程,父进程等待子进程并提取子进程的status信息,这样我们就能看到子进程是被哪个信号所杀。
#include <iostream>
#include <string>
#include <cstring>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
#define SIZE 1024
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid();
int number = 0;
char buff[SIZE] = {0};
while (true)
{
buff[0] = 0;
snprintf(buff, sizeof(buff), "%s-%d-%d", s.c_str(), self, number++);
write(wfd, buff, strlen(buff));
// cout << number << endl;
// if (number >= 5) break;
sleep(1);
}
}
void Reader(int rfd)
{
char buff[SIZE] = {0};
int cnt = 5;
while (cnt--)
{
//sleep(3);
buff[0] = 0;
ssize_t n = read(rfd, buff, sizeof(buff)-1);
if (n > 0)
{
buff[n] = 0;
cout << "father get a message[" << getpid() << "]# " << buff << endl;
}
else if (n == 0)
{
cout << "father read file done!" << endl;
break;
}
else break;
}
}
int main()
{
int pipefd[2] = {0};
int n = pipe(pipefd);
if (n < 0) return 1;
pid_t id = fork();
if (id < 0) return 2;
if (id == 0)
{
close(pipefd[0]);
Writer(pipefd[1]);
close(pipefd[1]);
exit(0);
}
close(pipefd[1]);
Reader(pipefd[0]);
close(pipefd[0]); // 父进程关闭读端
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid < 0) return 3;
printf("father wait %d success, exit code: %d, exit signal: %d\n", rid, (status>>8)&0xff, status&0x7f);
close(pipefd[0]);
return 0;
}


从结果来看,子进程会受到13号信号,使用kill- l查看所有信号,13号信号对应的是SIGPIPE。
结论4:写端正常写,读端关闭,操作系统就要杀掉正在写入的进程,通过13号信号SIGPIPE杀掉正在写入的进程。
2.3、管道的特征和四种情况
管道的特征:
1、具有血缘关系的进程进行进程间通信。
2、管道只能单向通信。
3、父子进程是会进程协同,同步与互斥的。——保护管道文件的数据安全
4、管道是面向字节流的。
5、管道是基于文件的,而文件的生命周期是随进程的。
管道的4种情况:
1、读写端正常,管道如果为空,读端就要阻塞。
2、读写端正常,管道如果被写满了,写端就要阻塞。
3、读端正常读,写端关闭,读端就会读到0,表明读到了文件(pipe)结尾,不会被阻塞。
4、写端正常写,读端关闭,操作系统就要杀掉正在写入的进程,通过13号信号SIGPIPE杀掉正在写入的进程。
2.4、匿名管道的应用场景
2.4.1、匿名管道和命令行的联系

我们在命令行使用管道执行三个指令,所以bash需要创建三个进程来执行,并且我们发现执行这三个指令的进程具有血缘关系,因为使用管道必须是具有血缘关系的两个进程。
那么我们之前使用cat命令用过管道,如:cat file.txt | head -10 | tail -5。这里的管道就是匿名管道。
2.4.2、实现简易进程池
使用匿名管道实现一个简易版本的进程池。
我们可以创建一批进程出来,这样当有任务需要执行时,直接在这批进程中挑选一个进程去完成任务。因为创建一个进程需要创建PCB、进程地址空间、页表,并且还需要建立映射,这是一个很重的过程。所以我们可以实现一个进程池,提高效率。

如图:主进程master创建出一批子进程slavor,同时创建一批匿名管道,父进程向管道里面写,子进程从管道里面获取任务,然后子进程执行任务。
我们规定,父子进程写入和读取都是4个字节,父进程通过给子进程写入任务码,然后子进程读取任务码然后执行对应的任务。
// Task.hpp
#pragma once
#include <iostream>
#include <vector>
#include <functional>
void task1()
{
std::cout << "lol刷新日志" << std::endl;
}
void task2()
{
std::cout << "lol刷新野区,野区更新野怪。" << std::endl;
}
void task3()
{
std::cout << "lol检测是否需要更新" << std::endl;
}
void task4()
{
std::cout << "lol释放技能,更新用户蓝条和血条。" << std::endl;
}
void InitTasks(std::vector<std::function<void()>>* tasks)
{
tasks->push_back(task1);
tasks->push_back(task2);
tasks->push_back(task3);
tasks->push_back(task4);
}
// ProcessPool.cc
#include <iostream>
#include <string>
#include <vector>
#include <cassert>
#include <ctime>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include "Task.hpp"
#define N 5
std::vector<std::function<void()>> tasks;
class channel
{
public:
channel(int cmdfd, int slavorid, std::string name)
:_cmdfd(cmdfd)
,_slavorid(slavorid)
,_name(name)
{}
public:
int _cmdfd; // 发送任务的文件描述符
pid_t _slavorid; // 子进程PID
std::string _name; // 子进程名称
};
void slavor()
{
while (true)
{
int cmdcode = 0;
int n = read(0, &cmdcode, sizeof(int));
if (n == sizeof(int))
{
//printf("child process receive, cmdcode: %d, pid: %d\n", cmdcode, getpid());
if (cmdcode >= 0 && cmdcode < tasks.size()) tasks[cmdcode]();
}
else if (n == 0) break;
// sleep(1);
}
}
void InitProcessPool(std::vector<channel>* channels)
{
std::vector<int> oldfds;
for (int i = 0; i < N; i++)
{
int pipefd[2];
int n = pipe(pipefd); // 创建匿名管道
assert(!n);
(void)n;
pid_t id = fork();
if (id == 0)
{
for (auto& e : oldfds)
{
close(e);
}
dup2(pipefd[0], 0); // 重定向,子进程从0读取。
close(pipefd[0]);
close(pipefd[1]);
slavor();
exit(0);
}
close(pipefd[0]);
std::string name = "process-" + std::to_string(i + 1);
channels->push_back(channel(pipefd[1], id, name));
oldfds.push_back(pipefd[1]);
}
}
void Debug(const std::vector<channel>& channels)
{
for (const auto& iter : channels)
{
printf("create a process, cmdfd: %d, slavorid: %d, name: %s\n",
iter._cmdfd, iter._slavorid, iter._name.c_str());
}
}
void Menu()
{
std::cout << "*******************************************************" << std::endl;
std::cout << "****************1.刷新日志 2.刷新野怪*****************" << std::endl;
std::cout << "****************3.检测更新 4.释放技能*****************" << std::endl;
std::cout << "*********************** 0.退出 ************************" << std::endl;
std::cout << "*******************************************************" << std::endl;
}
void CtrlProcess(const std::vector<channel>& channels)
{
int num = 0;
while (true)
{
Menu();
int select = 0;
std::cout << "Please Enter@ ";
std::cin >> select;
if (select <= 0 || select > tasks.size()) break;
int cmdcode = select - 1;
//int cmdcode = rand() % 20;
// int cmdcode = rand() % tasks.size();
//int n = rand() % 5;
int n = num;
num++;
num %= channels.size();
write(channels[n]._cmdfd, &cmdcode, sizeof(int));
//printf("father process send, cmdcode: %d, slavorid: %d, name: %s\n",
//cmdcode, channels[n]._slavorid, channels[n]._name.c_str());
// sleep(1);
}
}
void QuitProcess(const std::vector<channel>& channels)
{
for (const auto& e : channels)
{
close(e._cmdfd);
waitpid(e._slavorid, nullptr, 0);
}
// for (const auto& e : channels)
// close(e._cmdfd);
// for (const auto& e : channels)
// {
// waitpid(e._slavorid, nullptr, 0);
// }
}
int main()
{
srand(time(nullptr)^getpid()^1023); // 种一颗随机数种子
std::vector<channel> channels;
// 初始化任务
InitTasks(&tasks);
// 创建一批子进程
InitProcessPool(&channels);
//Debug(channels);
// 选择一个子进程发送任务
CtrlProcess(channels);
// 收尾工作
QuitProcess(channels);
return 0;
}
1、父进程给子进程发送任务,我们可以使用rand()函数随机选取一个任务码,也可以让用户手动输入。
2、父进程选择子进程我们可以使用rand()函数随机选取一个子进程,或者采用轮转的方式,让它们负载均衡。
3、每个子进程都会继承父进程之前打开的管道写端,所以在进程中我们要把这些继承下来的写端都关掉。在代码中我们用一个oldfds来保存之前打开的写端,然后每次创建子进程让子进程继承下去,每次循环oldfds都会多一个,并且父子进程具有独立性不会相互影响。
3、命名管道
3.1、命名管道的理解
上面我们讲的都是两个具有血缘关系的进程进行进程间通信,那如果毫不相关的进程进行进程间通信呢?那就需要命名管道了。
可以使用mkfifo命令创建命名管道:

如图,我们创建了myfifo管道文件,并且发现它的文件标识为p,标识的就是命名管道。然后我们输出重定向到命名管道中,发现阻塞住了。

我们在右边再次查看myfifo文件信息,发现文件大小为0,所以这是一个内存级文件,然后我们输入重定向后左边就不再阻塞。
如果两个不同的进程打开同一个文件,在内核中,操作系统会打开几个文件?
操作系统会创建两个struct file对象,但是文件的inode、operator方法、文件缓冲区是只有一个的。如下图:

进程间通信的前提:让不同的进程看到同一份资源。只不过过去我们打开的是磁盘上的文件,现在这个是内存级文件,不需要进行刷盘。所以命名管道原理和匿名管道是一样的,也只能进行单向通信,只不过命名管道是有名字的。
那么怎么知道两个进程打开的是同一个文件呢?——同路径下同一个文件名=路径+文件名。


系统调用mkfifo用于创建命名管道,pathname表示路径+文件名,mode就是创建管道的权限。
调用成功返回0,失败返回-1错误码被设置。


删除命名管道我们可以使用unlink函数。
3.2、编码实现
// comm.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstdio>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define FIFO_FILE "./myfifo"
#define MODE 0664
#define SIZE 1024
enum{
FIFO_CREAT_ERR = 1,
FIFO_DELETE_ERR,
FIFO_OPEN_ERR,
};
class Init
{
public:
Init()
{
int n = mkfifo(FIFO_FILE, MODE);
if (n < 0)
{
perror("mkfifo");
exit(FIFO_CREAT_ERR);
}
}
~Init()
{
int n = unlink(FIFO_FILE);
if (n < 0)
{
perror("unlink");
exit(FIFO_DELETE_ERR);
}
}
};
// server.cc
#include "comm.hpp"
using namespace std;
int main()
{
Init init;
int fd = open(FIFO_FILE, O_RDONLY);
if (fd < 0)
{
perror("open");
exit(FIFO_OPEN_ERR);
}
cout << "server open file done!\n";
char buffer[SIZE];
while (true)
{
ssize_t s = read(fd, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = 0;
printf("client say# %s\n", buffer);
}
else if (s == 0)
{
printf("client quit, me too!\n");
break;
}
else break;
}
close(fd);
return 0;
}
// client.cc
#include "comm.hpp"
using namespace std;
int main()
{
int fd = open(FIFO_FILE, O_WRONLY);
if (fd < 0)
{
perror("open");
exit(FIFO_OPEN_ERR);
}
cout << "clinet open file done!\n";
std::string str;
while (true)
{
printf("Please Enter@ ");
getline(cin, str);
write(fd, str.c_str(), str.size());
}
close(fd);
return 0;
}


刚开始server先启动,server创建管道文件,然后open打开管道文件的时候阻塞住了,等待client也打开管道文件,两边才会继续往下走。
3.3、实现日志组件
实现一个日志组件,可以帮助我们打印输出日志信息。
一般要有:日志时间、日志的等级、日志内容、文件的名称和行号。
日志等级我们分为: Info:常规消息,Warning:报警信息,Error:比较严重可能需要立即处理,Fatal:致命的,Debug:调试信息。
3.1、可变参数相关知识


我们知道函数参数压栈是从右向左压栈的,并且栈帧是向下增长的,所以sum函数最底下的参数就是n,va_list相当于定义一个char*的指针,然后va_start(s,n)相当于让s指向第一个可变参数。va_arg(s,int)相当于把s指向的地址当做int类型数据获取,va_end相当于把s置空。
3.2、获取时间

使用time函数获取时间,time函数会返回time_t类型的一个时间戳。

使用localtime函数获取一个struct tm*的对象,传入参数为const time_t*,这个结构体里面有年月日时分秒等信息。注意年份是从1900年开始算的,所以最后获取年份需要加上1900,月份是0-11所以获取月份要加上1。
3.3、具体实现
#pragma once
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <time.h>
#include <stdarg.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define SIZE 1024
#define Info 0
#define Debug 1
#define Warning 2
#define Error 3
#define Fatal 4
#define Screen 0
#define Onefile 1
#define Classfile 2
#define LogFile "log.txt"
class Log
{
public:
Log()
{
printMethod = 0; // 默认向显示器打印
path = "./log/";
}
void Enable(int method)
{
printMethod = method;
}
std::string levelToString(int level)
{
switch(level)
{
case Info:
return "Info";
case Debug:
return "Debug";
case Warning:
return "Warning";
case Error:
return "Error";
case Fatal:
return "Fatal";
default:
return "None";
}
}
// void logmessage(int level, const char* format, ...)
// {
// time_t t = time(nullptr);
// struct tm* ctime = localtime(&t);
// char leftbuffer[SIZE];
// snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%02d-%02d %02d:%02d:%02d]",
// levelToString(level).c_str(), ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,
// ctime->tm_hour, ctime->tm_min, ctime->tm_sec);
// va_list s;
// va_start(s, format);
// char rightbuffer[SIZE];
// vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
// va_end(s);
// char logtxt[SIZE*2];
// snprintf(logtxt, sizeof(logtxt), "%s %s\n", leftbuffer, rightbuffer);
// // std::cout << logtxt;
// printLog(level, logtxt);
// }
void operator()(int level, const char* format, ...)
{
time_t t = time(nullptr);
struct tm* ctime = localtime(&t);
char leftbuffer[SIZE];
snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%02d-%02d %02d:%02d:%02d]",
levelToString(level).c_str(), ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,
ctime->tm_hour, ctime->tm_min, ctime->tm_sec);
va_list s;
va_start(s, format);
char rightbuffer[SIZE];
vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
va_end(s);
char logtxt[SIZE*2];
snprintf(logtxt, sizeof(logtxt), "%s %s\n", leftbuffer, rightbuffer);
// std::cout << logtxt;
printLog(level, logtxt);
}
void printLog(int level, const std::string& logtxt)
{
switch(printMethod)
{
case Screen:
std::cout << logtxt;
break;
case Onefile:
printOneFile(LogFile, logtxt);
break;
case Classfile:
printClassFile(level, logtxt);
break;
}
}
void printOneFile(const std::string& logname, const std::string& logtxt)
{
std::string _logname = path + logname;
int fd = open(_logname.c_str(), O_CREAT|O_WRONLY|O_APPEND, 0666);
if (fd < 0) return;
write(fd, logtxt.c_str(), logtxt.size());
close(fd);
}
void printClassFile(int level, const std::string& logtxt)
{
std::string logname = LogFile;
logname += ".";
logname += levelToString(level);
printOneFile(logname, logtxt);
}
private:
int printMethod;
std::string path;
};
// int sum(int n, ...)
// {
// va_list s; // 定义一个char*指针
// va_start(s, n); // 让s指向第一个可变参数
// int num = 0;
// while (n--)
// {
// num += va_arg(s, int); // 获取s指向的数据
// }
// va_end(s); // 将s置空
// return num;
// }
4、共享内存
4.1、共享内存的原理
进程间通信的本质:让不同的进程看到同一份资源。

如图:首先在物理内存开辟一块空间,接着通过页表映射到进程地址空间中的共享区,然后虚拟地址空间的首地址返回,对于另一个进程也是这样,那么这两个进程就能看到物理内存中的同一块空间,以这块空间来实现进程间通信。
所以使用共享内存需要:1、申请内存。2、挂接到进程地址空间。
如果释放共享内存:1、去关联。2、释放共享内存。
这个内存能由进程申请吗?肯定是不行的,如果是进程申请的,那就属于申请的进程,而且进程间具有独立性,所以这块空间必须由操作系统申请,那么挂接操作也需要由操作系统来完成,所以必定是通过系统调用来实现的。
操作系统中可不只有这两个进程,其他进程之间也可能进行通信,所以可能会创建很多块共享内存。那么操作系统要不要管理共享内存呢?要,如何管理?——先描述,再组织。所以操作系统内必定要有描述共享内存的struct XXX结构体,里面包含共享内存的大小、唯一标识、谁创建的、哪些进程挂接了等等。
4.2、编写代码


shmget函数用来创建共享内存,size表示创建的共享内存空间大小,shmflg表示创建的选项。
shmflg我们关注两个:IPC_CREAT和IPC_EXCL
IPC_CREAT单独使用:如果申请的共享内存不存在就创建,存在就获取返回。
IPC_CREAT|IPC_EXCL:如果申请的共享内存不存在就创建,存在就出错返回。这两个搭配一起使用是为了保证创建出来的共享内存一定是新的。IPC_EXCL不单独使用。
shmget返回值为共享内存标识符,创建成功返回共享内存标识符,创建失败返回-1并且错误码被设置。
那么我们如何知道这个共享内存是否存在呢?这个问题实际上等同于如何让两个进程看到同一份资源。
这是通过key来实现的,下面再谈谈key。
key:
1、key是一个数字,这个数字是几不重要,关键在于它必须在内核中具有唯一性,能够让不同的进程进行唯一性标识。
2、第一个进程可以拿着key创建共享内存,第二个之后的进程,只要拿着相同的key就可以获取第一个进程创建的共享内存,这样它们就能看到同一份共享内存了。
3、操作系统也要管理共享内存,所以也要描述共享内存,对于一个已经创建好的共享内存,它的key在哪里呢?必定是在描述共享内存的结构体里面。
4、第一创建的时候需要有key,这个key怎么来呢?——通过ftok函数。


fotk函数参数有路径pathname和项目id。返回值是一个key_t的类型,也就是一个整数。这个函数是一套算法,通过pathname和proj_id进行数值计算,然后将结果返回。那么pathname和proj_id就由用户指定。
ftok成功返回计算数值,失败返回-1并且错误码被设置。
5、key类似创建命名管道那里传的路径,它们都具有唯一性。
ftok计算出的key有没有可能冲突呢?当然是有可能的,但是系统的算法计算出来的冲突会尽可能小,但是也并不代表不会出现冲突,那如果出现冲突怎么办?修改pathname和proj_id。
为什么操作系统不直接生成一个然后返回给进程呢?理论上这是可以做到的,但是如果是操作系统生成返回的key,那么你这个进程可以用来创建共享内存,问题是其他进程如何知道你这个key,然后通过这个key去获取共享内存。其他进程无法得知这个key,所以不能这么做。有人会说那就用管道,利用管道把key传过去,那这样共享内存就不是一个独立的通信模块了,所以这样是行不通的。
先编码实现创建共享内存:
// comm.hpp
#ifndef __COMM_HPP__
#define __COMM_HPP__
#include <iostream>
#include <cstdio>
#include <cstring>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include "log.hpp"
using namespace std;
Log log;
const int size = 4096;
const char* pathname = "/home/zzy";
const int proj_id = 0x6666;
key_t GetKey()
{
key_t k = ftok(pathname, proj_id);
if (k < 0)
{
log(Fatal, "ftok error: %s", strerror(errno));
exit(1);
}
return k;
}
int GetShmHelper(int flag)
{
key_t k = GetKey();
int shmid = shmget(k, size, flag);
if (shmid < 0)
{
log(Fatal, "create share memory error: %s", strerror(errno));
exit(2);
}
log(Info, "create share memory success, shmid: %d", shmid);
return shmid;
}
int CreateShm()
{
return GetShmHelper(IPC_CREAT|IPC_EXCL);
}
int GetShm()
{
return GetShmHelper(IPC_CREAT);
}
#endif
#include "comm.hpp"
int main()
{
int shmid = CreateShm();
sleep(10);
return 0;
}

左侧运行程序后创建共享内存成功,打印出shmid——共享内存标识符为2。
右侧我们使用命令:ipcs -m查看创建的所有共享内存。
我们发现有个key,这个key是十六进制的,也就是之前调用shmget传入的key。shmid为2,跟左侧打印信息一致。并且左边进程退出后,右侧查看共享内存发现共享内存还是存在的,所以共享内存需要我们手动释放。owner表示作者,perms表示权限,因为我们创建没有设置权限,所以默认权限为0,然后bytes表示共享内存的大小,nattch表示有多少个进程挂接到共享内存中。
共享内存的生命周期是随内核的!用户不主动关闭,共享内存就会一直存在。除非内核重启或用户释放。
使用ipcrm -m shmid可以删除共享内存:

key VS shmid,key用于操作系统内标定唯一性。shmid在你的进程内表示资源的唯一性。
所以创建共享内存,我们还需要传入权限:

另外共享内存创建的大小最好为4096的整数倍,因为page页的大小为4KB,操作系统会以页为单位给你。假设你创建大小为4097,实际上操作系统会给你两页,但是你还是只能使用4097,所以就会造成空间浪费。


shmat函数用来挂接共享内存,shmid就是创建的或获取的共享内存标识符,shmaddr表示你要挂接到进程地址空间共享区的哪个位置,但是一般我们也不知道,所以直接设置为nullptr即可,shmflg可以设置只读等权限,我们直接设置为0表示默认创建的权限即可。挂接成功返回进程地址空间的首地址,挂接失败返回-1这个无类型地址,并设置错误码。
shmdt函数用来取消挂接,参数为挂接返回的地址。成功返回0,失败返回-1错误码被设置。
添加代码挂接和去挂接:
#include "comm.hpp"
int main()
{
int shmid = CreateShm();
sleep(2);
char* shmaddr = (char*)shmat(shmid, nullptr, 0);
sleep(2);
shmdt(shmaddr);
sleep(2);
return 0;
}

刚开始创建了共享内存后还没有进程挂接,nattch为0,创建两秒后进程挂接了,所以nattch为1,然后两秒后进程去挂接了,所以naatch又变为0,最后进程退出。

shmctl用来删除共享内存或获取共享内存信息。第一个参数是共享内存标识符,第二个参数是选项。第二个参数我们关注两个:IPC_RMID表示删除共享内存。IPC_STAT表示获取共享内存的信息。
第三个参数是用户级的共享内存对象信息,右侧是它的成员变量。如果使用IPC_RMID,那么第三个参数设置为nullptr即可。如果使用IPC_STAT获取信息,需要传入struct shmid_ds的指针,这是给输出型参数,会将共享内存信息带出。
观察shmid_ds结构,里面有shm_segsz:共享内存的大小。shm_atime最近挂接时间。shm_dtime最近去挂接时间。shm_cpid:创建进程的PID。shm_nattch:挂接进程数量。而shmid_ds里面还有个结构struct ipc_perm,struct ipc_perm里面就存储了key和mode。
下面实现两个进程间通信:
// processa.cc
#include "comm.hpp"
int main()
{
int shmid = CreateShm();
char* shmaddr = (char*)shmat(shmid, nullptr, 0);
struct shmid_ds shmds;
while (true)
{
cout << "processb say# " << shmaddr << endl;
shmctl(shmid, IPC_STAT, &shmds);
cout << "size: " << shmds.shm_segsz << endl;
cout << "nattch: " << shmds.shm_nattch << endl;
printf("key: 0x%x\n", shmds.shm_perm.__key);
cout << "mode: " << shmds.shm_perm.mode << endl << endl;
sleep(1);
}
shmdt(shmaddr);
shmctl(shmid, IPC_RMID, nullptr);
return 0;
}
// processb.cc
#include "comm.hpp"
int main()
{
int shmid = GetShm();
char* shmaddr = (char*)shmat(shmid, nullptr, 0);
while (true)
{
cout << "Please Enter@ ";
fgets(shmaddr, 4096, stdin);
}
shmdt(shmaddr);
return 0;
}

可以看到我们通过shmctl中cmd设置为IPC_STAT可以获取共享内存的信息。
4.3、共享内存的特性
1、共享内存没有同步互斥之类的保护机制。
2、共享内存是所有进程间通信中速度最快的。为什么?——拷贝少。共享内存只需要拷贝一次,将数据拷贝到内存中,另一个进程直接从内存中读取即可。使用管道需要发生两次拷贝:调用write时将数据拷贝到文件缓冲区,调用read时将数据从文件缓冲区拷贝到用户层。
3、共享内存内部的数据由用户自己维护。
下面使用管道实现共享内存的同步机制:
// comm.hpp
#ifndef __COMM_HPP__
#define __COMM_HPP__
#include <iostream>
#include <cstdio>
#include <cstring>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include "log.hpp"
using namespace std;
Log log;
const int size = 4096;
const char* pathname = "/home/zzy";
const int proj_id = 0x6666;
key_t GetKey()
{
key_t k = ftok(pathname, proj_id);
if (k < 0)
{
log(Fatal, "ftok error: %s", strerror(errno));
exit(1);
}
return k;
}
int GetShmHelper(int flag)
{
key_t k = GetKey();
int shmid = shmget(k, size, flag);
if (shmid < 0)
{
log(Fatal, "create share memory error: %s", strerror(errno));
exit(2);
}
log(Info, "create share memory success, shmid: %d", shmid);
return shmid;
}
int CreateShm()
{
return GetShmHelper(IPC_CREAT|IPC_EXCL|0666);
}
int GetShm()
{
return GetShmHelper(IPC_CREAT);
}
#define FIFO_FILE "./myfifo"
#define MODE 0664
enum{
FIFO_CREAT_ERR = 1,
FIFO_DELETE_ERR,
FIFO_OPEN_ERR,
};
class Init
{
public:
Init()
{
int n = mkfifo(FIFO_FILE, MODE);
if (n < 0)
{
log(Fatal, "mkfifo error: %s", strerror(errno));
exit(FIFO_CREAT_ERR);
}
}
~Init()
{
int n = unlink(FIFO_FILE);
if (n < 0)
{
log(Fatal, "unlink error: %s", strerror(errno));
exit(FIFO_DELETE_ERR);
}
}
};
#endif
// processa.cc
#include "comm.hpp"
int main()
{
Init init;
int shmid = CreateShm();
char* shmaddr = (char*)shmat(shmid, nullptr, 0);
// 打开管道文件
int fd = open(FIFO_FILE, O_RDONLY);
if (fd < 0)
{
log(Fatal, "open error: %s", strerror(errno));
exit(FIFO_OPEN_ERR);
}
struct shmid_ds shmds;
while (true)
{
char ch;
ssize_t n = read(fd, &ch, sizeof(ch));
if (n <= 0) break;
cout << "processb say# " << shmaddr;
// shmctl(shmid, IPC_STAT, &shmds);
// cout << "size: " << shmds.shm_segsz << endl;
// cout << "nattch: " << shmds.shm_nattch << endl;
// printf("key: 0x%x\n", shmds.shm_perm.__key);
// cout << "mode: " << shmds.shm_perm.mode << endl << endl;
sleep(1);
}
shmdt(shmaddr);
shmctl(shmid, IPC_RMID, nullptr);
close(fd);
return 0;
}
// processb.cc
#include "comm.hpp"
int main()
{
int shmid = GetShm();
char* shmaddr = (char*)shmat(shmid, nullptr, 0);
int fd = open(FIFO_FILE, O_WRONLY|O_APPEND);
if (fd < 0)
{
log(Fatal, "open error: %s", strerror(errno));
exit(FIFO_OPEN_ERR);
}
while (true)
{
cout << "Please Enter@ ";
fgets(shmaddr, 4096, stdin);
write(fd, "c", 1);
}
shmdt(shmaddr);
close(fd);
return 0;
}

当进程a运行起来后,创建了管道文件,但是会在open处阻塞,然后进程b起来后调用open,然后双方一起往后走,进程a会阻塞在read函数处等待进程b发送消息。当进程b输入数据后调用fgets读取到共享内存中,然后调用write函数向管道写入一个字符,这时候进程a就会读到管道中的数据,然后获取到共享内存的数据并输出,下次循环再次阻塞在read函数处等待进程b发送消息。
当进程b退出时,写进程退出,读进程就会读到0,我们进行判断直接break。然后进程a释放共享内存退出。
5、消息队列
5.1、消息队列原理

进程间通信的本质是:让不同的进程看到同一份资源。
消息队列的原理就是先在操作系统创建一个message queue,然后对于进程A和进程B都要看到同一个队列。然后进程AB要进行通信,可以把带有类型的数据块发送到内核中,在队列中链接起来。
那么进程从消息队列获取数据如何获取呢?因为进程A、B发送的数据块都会在队列中链接起来,所以发送的是带有类型的数据块,通过类型来判断。
5.2、消息队列接口介绍


msgget函数用来获取消息队列,key跟共享内存一样用来在操作系统内表示消息队列的唯一性,可以通过ftok函数来获取。msgflg有两个选项:IPC_CREAT 和 IPC_EXCL,使用同共享内存,IPC_CREAT单独使用如果消息队列不存在就创建,如果存在就获取返回。IPC_CREAT | IPC_EXCL一起使用如果消息队列不存在就创建,存在就出错返回,保证创建的消息队列一定是新的。
返回值:成功返回消息队列标识符,失败返回-1,错误码被设置。

msgctl用俩获取消息队列信息或删除消息队列,msgid表示消息队列标识符,cmd可以传:IPC_RMID表示删除消息队列,IPC_STAT表示获取消息队列信息。

注意到struct msgid_ds里面的有消息队列的数量、大小、时间信息等。但是msgid_ds和shmid_ds第一个成员都是struct ipc_perm,它们有共同的成员。

msgsnd用来发送数据块,msgp表示数据块的地址,msgsz表示数据块的大小,msgflg设置为0表示阻塞发送。
msgrcv用来获取数据块。

数据块类似这样的结构,第一个字段表示数据块的类型,然后第二个表示数据。
5.3、IPC在内核中的数据结构设计

我们把共享内存、消息队列、信号量的接口都截出来,发现它们对应的ctl函数,都有对应的结构。共享内存:shmid_ds,消息队列:msgid_ds,信号量:semid_ds,里面保存了它们的属性,并且XXXid_ds里面的第一个成员变量都是struct ipc_perm。然后ipc_perm里面有key和mode,所以对于共享内存、消息队列、信号量都可以实现让不同进程看到同一份资源。
在操作系统中,所有的IPC资源都是整合进操作系统的IPC模块的。

操作系统内有一个struct ipc_perm*的指针数组,里面存储的都是ipc_perm对象的地址。当我们创建了共享内存/消息队列/信号量,由于XXXid_ds都有struct ipc_perm对象,所以也会创建一个strcut ipc_perm对象,那么就会把这个对象的地址填到struct ipc_perm* array[]数组中,那么这个数据里面的元素就指向了一个一个的struct ipc_perm对象。而我们获取资源标识符shmid、msgid、semid就是这个数组的下标。这个数组是一个变长数组,然后我们每次获取的id值都会++,所以它是线性递增的,但是最后它会回绕然后从头开始。
那么我们如果获取XXXid_ds对象呢,因为每个XXXid_ds第一个对象都是ipc_perm,所以我们只要将对应的ipc_perm地址进行强转即可。比如数组中的地址addr,强转成struct XXXid_ds的指针,那么就可以获取所有数据了。那么如何知道要强转成哪个类型呢,你这不是有三个类型吗,在ipc_perm内部可以标记类型。总之,操作系统能区分指针指向的对象的类型。
6、信号量
6.1、信号量原理
对于共享内存来说,操作系统在物理内存开辟一块空间,然后两个进程挂接到共享内存,将其映射在虚拟地址空间的首地址返回,实现了两个进程间通信。
现在如果进程A正在写入,假设进程A要写入100个字节的数据,这100字节数据是作为一个整体要发送给进程B的。进程B读取数据,但是进程B在进程A写入了50个字节的时候就把这50字节的数据读走了,这就导致双方发和收的数据不完整,我们称为数据不一致问题。
1、进程AB看到的同一份资源,我们称之为共享资源。共享资源如果不加以保护,就会导致数据不一致问题。
2、所以需要加锁,加锁就是互斥访问。保证在任何时刻,只允许一个执行流访问共享资源,我们称之为互斥。
3、共享的,任何时刻只允许一个执行流访问的资源(本质就是执行访问代码),我们称之为临界资源,一般是内存空间。
4、程序有100行代码,并不是所有代码都在访问临界资源,可能只有5-10行才会访问临界资源。我们把访问临界资源的代码称之为临界区。
我们上面学习的共享内存是没有任何保护机制的,而管道是有同步互斥保护机制的。
为什么多进程、多线程打印,显示器上显示的消息是混乱的?因为多进程、多线程打印就是访问显示器文件资源,显示器文件是共享资源,如果不加以保护就会出现这种情况。
理解信号量:
信号量/信号灯本质是一把计数器,类似但不敢等同:int cnt = n。它用来描述临界资源中资源数量的多少。
假设你去看电影,放映厅中有100个座位,那就有100张票,维护一个票数计数器cnt=100。当我们准备看电影的时候,我们还没去看电影,而是先买票,买票的本质就是对资源的预定机制。当我买好票选好了位置了,这个位置就被我预定了,在特定的时间内这个位置就是我的,别人不能坐,哪怕我没去。每卖一张票,票数的计数器就要减1,放映厅里面的资源就少了一个。当票数计数器减到0,资源就被申请完毕了。
对于临界资源来说,我们可以把它划分成很多份,假设划分成15份。我们最担心的就是多个执行流访问同一个资源,比如15份资源,但是进来了16个执行流,那么一定会有两个执行流访问同一份资源,这样就会出问题。所以我们引入一个计数器,int cnt = 5,每个执行流访问临界资源先申请计数器:int number = cnt–,number就是你这个执行流分配到的资源,然后让计数器减1,那么当计数器减到0就说明临界资源已经被申请完了,后面再有执行流来申请也不给你了。
1、申请计数器成功就表示我具有访问资源的权限。
2、申请了计数器资源,我访问了我要的资源吗?没有,申请计数器资源是对资源的预定机制。
3、计数器可以有效保证进入共享资源的执行流的数量。
4、所以每个执行流要访问共享资源中的一部分,不是直接访问,要先申请计数器资源。如看电影的先买票。
程序员把这个"计数器"叫做信号量。
如果电影院只有一个座位,超级VVVIP电影院。那么我们只需要一个值为1的计数器。
只有一个人可以抢到这个资源,只有一个人能进放映厅看电影,看电影期间只有一个执行流在访问临界资源。这就是互斥。
我们把值只能为1、0两态的计数器叫做二元信号量——本质就是一个锁。
为什么让计数器为1?计数器为1的本质就是:不再将临界资源分成很多块,而是当作一个整体。整体申请,整体释放。
思考:要访问临界资源,要先申请信号量计数器资源,申请信号量资源要对计数器进行--。那么信号量计数器不也是共享资源吗?要保护别人的安全你得先保证自己的安全。
申请信号量,本质是对计数器--,称之为P操作。
释放资源,释放信号量,本质是对计数器进行++,称之为V操作。
申请和释放统称为PV操作。它们是原子的。
什么叫做原子的?一件事要么不做,要么做完——两态的,没有正在做的概念。
在C语言中,cnt--\是一条语句,编程汇编,一般是三条语句。
1、将cnt变量的值放入CPU寄存器中。
2、CPU内进行--操作。
3、将计算结果写回cnt变量的内存位置。
我们认为一条汇编语句是原子的。但是cnt--被转换成了三条汇编,而多线程、多进程在执行这三条汇编的时候都可能被切换。所以是有问题的,具体我们到多线程部分再做讲解。
总结:信号量本质是一把计数器,PV操作是原子的。
执行流申请资源,必须先申请信号量资源,得到信号量之后才能访问临界资源。
信号量值为1、0两态的称为二元信号量,就是互斥功能。
申请信号量的本质:是对临界资源的预定机制。
信号量凭什么是进程间通信的一种?
1、通信不仅仅是通信数据,互相协同也是。
2、要协同,本质就是通信,信号量首先要被所有的通信进程看到。
1万+

被折叠的 条评论
为什么被折叠?



