当前位置: 首页 > news >正文

进程间通信——IPC(Linux)

进程间通信

  • 前言
  • 一、管道
    • 1. 管道原理
    • 2. 匿名管道
      • ①理解匿名管道
      • ②创建匿名管道——pipe
      • ③模拟实现进程池——管道
    • 3. 命名管道
      • ①理解命名管道
      • ②使用命名管道——mkfifo
        • 拓展 —— 日志
        • 俩无关进程通信
    • 3. 小结
      • ①管道总结
      • ②拓展命令和接口
  • 二、System V
    • 1. 共享内存
      • ①原理
      • ②使用共享内存
        • 接口介绍
        • 使用
      • ③小结
    • 2. 消息队列
    • 3. 信号量
      • ①引入
      • ②原理
    • 4. 总结

前言

1. 概念

进程间通信(IPC):不同进程之间进行数据交换和信息传递的过程。

  • 因为进程独立性的原因,所以进程之间通信是有成本的。
    成本:需要通过特定的机制来实现,这个过程会增加额外的开销和复杂性。还有数据传输、同步、互斥等方面的开销。

2. 目的

其实概念就是IPC存在的目的。
进一步解释:

  • 数据传输:一个进程需要把数据传输到另一个进程。
  • 资源共享:多个进程共享同一个资源
  • 通知:一个进程通知另一个(不限于一个)发生了某些事件。(eg:子进程退出,要通知父进程)
  • 进程控制:控制进程希望拦截被控制进程的异常,并及时知道它的状态改变
  • 协同:… (eg:信号量)

3. 怎么做

  • 让不同的进程看到同一份“资源” —— 特定形式的内存空间
  • “资源”一般是由OS提供。因为如果是通信双方的进程提供,那这一份资源本质就是这个进程独有的,会破坏进程间的独立性
  • 进程通信本质就是访问OS,进程代表的就是用户。 “资源”的 创建 -> 使用 -> 释放 ——>都离不开system call (因为群众有坏人,OS不放心把底层暴露出来)。

4. 分类

  • system call -> 从底层设计,接口设计,都要由OS设计。一般OS会有独立的通信模块(IPC通信模块)——> 隶属文件系统
  • 既然出现通信,就要制定标准,进程间通信的标准:
    • System V(本机通信)
    • POSIX (网络方便)

本文先介绍的是System V和基于文件级别的通信方式——管道
System V IPC:

  • System V 共享内存(主讲)
  • System V 消息队列(介绍原理)
  • System V 信号量(介绍原理)

一、管道

因为设计是数据从一端进去从另一端出去,是一种单向的通信方式,所以命名为管道

1. 管道原理

管道:基于文件级别的通信方式
原理图:介绍如何基于文件
基于文件的进程间通信

再叙述一下:

  1. 文件本身是不会继承下去的,被子进程继承的是文件描述符,所以通过文件描述符父子(具有血缘关系的)进程就可以对同一份资源进行访问。这就是管道的前提条件
  2. 因为进程间通信的数据基本不需要落盘,所以内存级文件通信提高了效率,减少拷贝和与外设的交互

注:因为和文件有关,这里涉及文件的知识就不再介绍,想要了解可以看另一篇文章IO Linux

2. 匿名管道

①理解匿名管道

匿名管道:用于父子进程、兄弟进程,具有亲缘关系的进程之间通信。管道是一种单向通信方式,数据只能在一个方向上流动。

简化图:
管道原理简化图

匿名管道的特殊性导致:

  1. 只能用于共同祖先的进程(具有亲缘关系)之间进行通信。通常一个管道由一个进程创建,然后fork,此后父子进程就都可以使用该管道。
  2. 不能双向通信,所以想要双向通信,再建立一条匿名管道
  3. 到这里只是让通信进程看到了同一份“资源”,还没有进行通信。所以因为进程独立性,进程间通信是需要成本的。

注:
数据为脏:无论读写都要先把数据加载到内存中。如果进行修改,在内存中修改好,此时内存中的数据和磁盘上的数据不一致,此时内存中的就是脏数据

②创建匿名管道——pipe

系统调用介绍:pipe

头文件:#include <unistd.h>函数声明:int pipe(int pipefd[2]);函数参数:pipefd[2]:两个整型元素的数组。输出型参数:这两个元素带出来的是管道的文件描述符。pipefd[0]:读端的文件描述符pipefd[1]:写端的文件描述符返回值:1. 成功返回02. 失败返回-1,错误码被设置

输出型参数pipefd[2]:根据上面匿名管道的简化图,可以看到想要建立任取一方读或写的单向通信。就需要在父进程时,以读和写两种方式打开管道文件然后fork,子进程继承这两种方式,然后根据需要再关闭通信进程对应一端。

使用系统调用——pipe:

介绍下面这串代码的逻辑:
创建管道 -> 子进程继承,然后关闭子进程的读端和父进程的写端。子进程每秒写一条信息,父进程读。对子进程的写和父进程的读都进行封装,读写方法我会单独拿出来验证管道的四种状态

#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <cerrno>using namespace std;#define SIZE 1024// child -> w
void Writer(int wfd)
{string s = "hello, I am child";pid_t child_pid = getpid();int number = 0;  //展现数据是变化的char buffer[SIZE];while (true){buffer[0] = 0; // 字符串情况,告诉阅读代码的人,这个数组被当成字符串使用// 通过上面三个数据构建字符串snprintf(buffer, sizeof(buffer), "%s-%d-%d\n", s.c_str(), child_pid, number++);// 发送/写入给父进程信息 system callwrite(wfd, buffer, strlen(buffer));sleep(1); //一秒写一条信息}
}// father -> r
void Reader(int rfd)
{char buffer[SIZE];while (true){buffer[0] = 0; // 告诉阅读代码的人,这个数组被当成字符串使用ssize_t n = read(rfd, buffer, sizeof(buffer));if (n < 0)break;else if (n == 0){printf("read file done!\n");break;}else{buffer[n] = 0; // 因为是字符串,所以读出来要注意cout << "father get a message[" << getpid() << "]#" << buffer << endl;}}
}int main()
{//创建管道int pipefd[2] = {0};int n = pipe(pipefd);if (n < 0)exit(errno);  //创建失败直接退出// child -> w,  father -> rpid_t id = fork();if (id < 0)exit(errno);else if (id == 0){// child 关闭读端close(pipefd[0]);// 写方法Writer(pipefd[1]);close(pipefd[1]);exit(0);}// fatherclose(pipefd[1]);// 读方法Reader(pipefd[0]);// wait child processpid_t rid = waitpid(id, nullptr, 0);if (rid < 0)exit(errno);close(pipefd[0]);return 0;
}

运行结果:
运行结果

管道的四种情况:

  1. 读写端正常 ,写方法改成从键盘输入,其余代码不变

改变写方法代码:

void Writer(int wfd)
{pid_t child_pid = getpid();char buffer[SIZE];while (true){buffer[0] = 0; cout << "Please Enter@";fgets(buffer, sizeof(buffer), stdin);buffer[strlen(buffer) - 1] = '\0';      //把换行符去掉// 发送/写入给父进程信息 system callwrite(wfd, buffer, strlen(buffer));sleep(1);}
}

实验结果:
运行结果
现象:

  • 在子进程输入数据然后向父进程发送消息,父进程进行读取,再一次写的时候,上一次信息清空(调整位置),父进程读取新内容
  • 子进程写完之后父进程读取,因此子进程未写之前,父进程读端处于阻塞状态
  1. 读写端正常,写端写满

改变读写方法代码:

void Writer(int wfd)
{int cnt = 0;while(true){cout << cnt++ << endl;write(wfd, "c", 1);} 
}
void Reader(int rfd)
{while(true){sleep(5);break;}
}

实验结果:
实验结果
现象:

  • 写端写满,写端陷入阻塞,管道写入了65536字节,即64KB
  • 不同内核,可能有一定差别
  1. 读端正常,写端关闭

改变写方法代码:

void Writer(int wfd)
{string s = "hello, I am child";pid_t child_pid = getpid();int cnt = 3;char buffer[SIZE];while (cnt){buffer[0] = 0; snprintf(buffer, sizeof(buffer), "%s-%d-%d\n", s.c_str(), child_pid, cnt--);write(wfd, buffer, strlen(buffer));//读三次之后关闭文件描述符if(cnt == 0){close(wfd);cout << "wfd close!!" << endl;}sleep(1);}
}

实验结果:
实验结果
现象:

  • 正常读写三次之后,写端关闭,读端会返回0,表面读到文件尾,不会被阻塞
  1. 写端正常,读端关闭

改变读方法代码,并且在父进程等待处获取子进程退出状态

void Reader(int rfd)
{char buffer[SIZE];int cnt = 3;while (cnt){buffer[0] = 0; // 告诉阅读代码的人,这个数组被当成字符串使用ssize_t n = read(rfd, buffer, sizeof(buffer));if (n < 0)break;else if (n == 0){printf("read file done!\n");break;}else{buffer[n] = 0; // 因为是字符串,所以读出来要注意cout << "father get a message[" << getpid() << "]#" << buffer << endl;}cnt--;if(cnt == 0){close(rfd);}}
}//在main函数最后多加了几行的内容 ——> 获取子进程退出状态int status = 0;pid_t rid = waitpid(id, &status, 0);if (rid < 0)exit(errno);cout << "wait success pid:" << id;cout << " exit code:" << ((status >> 8)&0xFF) << " exit signal:" << (status&0x7F) << endl;

实验结果:
实验结果
现象:

  • 首先我们没有对写代码做任何更改,只是读端被我们读了三次关闭了。结果OS杀掉正在写入的进程,子进程异常退出。
  • 父进程等待子进程,获取的子进程退出信息,退出码没有问题,退出信号是13号信号
    13号信号

③模拟实现进程池——管道

注:我们可以使用管道模拟实现进程池

由父进程创建一批进程,父子进程之间建立管道,当父进程有任务时,给子进程发送信号,让指定的子进程去完成该任务。进程池代码实现链接:本质上这个进程池的实现,体现了OS底层的模式:先描述再组织的思想。首先创建一个结构体进行描述,然后使用C++的容器进行管理。
简易图解:
图解
每次有任务时,父进程只需要把任务码通过管道发送给空闲的子进程。(判断是否是空闲的子进程,可以在结构体中添加一些属性,然后派送任务的时候进行遍历,判断该进程是否空闲)

3. 命名管道

①理解命名管道

命名管道:命名管道是一种特殊的管道,可以在无关的进程之间进行通信。命名管道是一种有名字的通信方式,可以通过文件系统中的路径来访问。

②使用命名管道——mkfifo

  1. 命名管道可以直接使用命令创建
    mkfifo [filename]
  2. 也可以使用系统调用接口mkfifo

注:

  1. Linux下,创建一个命名管道(也称为FIFO)时,无论用户设置的权限是什么,最终创建出来的命名管道的权限都会被内核自动修改为0666(即所有用户都有读写权限)。
  2. 管道创建出来,属于内存级文件,内容不会进行落盘,所以管道不占用磁盘空间
拓展 —— 日志

日志简单介绍:
日志信息

日志类实现的一些函数、宏和结构体介绍:
time、localtime、struct tm、snprintf、可变参数(va_list、va_start、va_end)、vsnprintf
注:可变参数,至少要有一个具体参数

//time —— 返回时间戳
头文件:#include <time.h>函数声明:time_t time(time_t *t);参数:t:输出型参数,放的是当前的时间戳函数返回值:1. 成功返回时间戳2. 失败返回-1,错误码被设置
注:使用时,参数设为nullptr即可time使用:
#include <iostream>
#include <time.h>int main()
{time_t t1  = 0;time_t t2 = time(&t1);std::cout << "t1:" << t1 << " t2:" << t2 << std::endl;return 0;
}//output:
//t1:1710156476 t2:1710156476//
//localtime 和 struct tm
头文件:#include <time.h>函数声明:struct tm *localtime(const time_t *timep);参数:timep:指向要转换成本地时间的时间戳返回值:1. 成功返回结构体指针(struct tm),包含了转换后的本地之间信息。2. 失败返回nullptrstruct tm定义:struct tm {int tm_sec;   // 秒,范围为 0-59int tm_min;   // 分,范围为 0-59int tm_hour;  // 时,范围为 0-23int tm_mday;  // 一个月中的日期,范围为 1-31int tm_mon;   // 月份,范围为 0-11int tm_year;  // 年份,从 1900 年开始int tm_wday;  // 一周中的天数,范围为 0-6 (0 表示周日)int tm_yday;  // 一年中的天数,范围为 0-365int tm_isdst; // 夏令时标识符,负数表示不可确定,0 表示不使用夏令时,正数表示使用夏令时};
localtime使用:
#include <iostream>
#include <time.h>
#include <cstdio>int main()
{time_t t = time(nullptr);struct tm *ctime = localtime(&t);printf("%d-%d-%d %d:%d:%d\n", ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,ctime->tm_hour, ctime->tm_min, ctime->tm_sec);return 0;
}//output:
// 2024-3-11 19:47:31/
//snprintf
头文件:#include <stdio.h>函数声明:int snprintf(char *str, size_t size, const char *format, ...);参数:1. 区别sprintf,snprintf限制输出的字符数2. 将可变参数(...)按照format格式化成字符串,然后复制到str中3. size:要写入的大小,超过size会被截断,最终写size-1大小,末尾添加'\0'返回值:1. 成功返回想要写入str的长度,而不是实际写入的长度2. 失败返回负值使用:
#include <cstdio>int main()
{char buffer[50];const char* s = "runoobcom";int j = snprintf(buffer, 6, "%s\n", s);printf("string:%s\ncharacter count = %d\n", buffer, j);return 0;
}// string:
// runoo
// character count = 10/
//可变参数va_list、va_start(ap, last_arg)、va_end(ap)、va_arg(ap,type)
头文件:#include <stdarg.h>四个宏:1. va_list:实际就是char*类型2. va_start:初始化可变参数列表,ap是va_list类型的变量,last_arg是最后一个固定参数的名称(可变参数裂变前一个参数)。目的是为了将ap指向可变参数列表中第一个参数3. va_end:结束可变参数裂变访问,ap置为nullptr4. va_arg:获取可变参数列表中下一个参数,ap是一个va_list类型的变量,type是下一个参数的类型。返回值是type类型的值,并将ap指向下一个参数可变参数使用:
int sum(int n, ...)
{va_list s;   // va_list <==> char*va_start(s, n);int sum = 0;while (n--){sum += va_arg(s, int);}va_end(s); // s=NULLreturn sum;
}
int main()
{int c = sum(4, 1, 2, 3, 4);int d = sum(5, 1, 2, 3, 4, 5);cout << "c = " << c << ", d = " << d <<endl;return 0;
}
//output: c = 10, d = 15/
//vsnprintf
头文件:#include <stdio.h>函数声明:int vsnprintf(char *str, size_t size, const char *format, va_list ap);参数:1. str:用于存储格式化后的字符串2. size:字符数组大小3. format:类似printf函数中的格式化字符串4. ap:va_list类型的参数列表,包含要格式化的数据返回值:1. 成功返回想要写入str的长度,而不是实际写入的长度2. 失败返回负值vsnprintf使用:
#include <cstdio>
#include <stdarg.h>void my_vsnprintf(const char *format, ...) {va_list args;va_start(args, format);char buffer[100]; int n = vsnprintf(buffer, sizeof(buffer), format, args);va_end(args);printf("string:%s return val:%d\n", buffer, n);
}int main() 
{my_vsnprintf("Hello, %s Size: %d", "world", 42);return 0;
}//output:
// string:Hello, world Size: 42 return val:21

实现一个简单的日志类:
文件名:log.hpp

#pragma once#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <cstdarg>
#include <time.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>// 定义日志等级
#define Info 0
#define Debug 1
#define Waring 2
#define Error 3
#define Fatal 4// 输出方式
#define Screen 1
#define OneFile 2
#define SortFile 3#define LOG_MODE 0666
#define SIZE 1024// 输出到一个文件的文件名   输出到多个文件时,可以加日志等级作为后缀,进行区分
#define LogFile "log.txt"class Log
{
public:Log(int printMethod = Screen, std::string path = "./log/"): _printMethod(printMethod), _path(path){}// 改变输出方式,使用者设置void Enable(int method){_printMethod = method;}// 根据等级转字符串std::string LevelToString(int level){switch (level){case Info:return "Info";case Debug:return "Debug";case Waring:return "Waring";case Error:return "Error";case Fatal:return "Fatal";default:return "None";}}void PrintLog(int level, const std::string &logtxt){switch (_printMethod){case Screen:std::cout << logtxt << std::endl;break;case OneFile:PrintOneFile(LogFile, logtxt);break;case SortFile:PrintSortFile(level, logtxt);break;default:break;}}// 写在一个文件中void PrintOneFile(const std::string &logname, const std::string &logtxt){std::string _logname = _path + logname;int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, LOG_MODE);if (fd < 0)return;write(fd, logtxt.c_str(), logtxt.size());close(fd);}//根据日志等级不同,写在多个文件中void PrintSortFile(int level, const std::string &logtxt){std::string filename = LogFile;filename += '.';filename += LevelToString(level);PrintOneFile(filename, 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-%d-%d %d:%d:%d]", 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 << std::endl;PrintLog(level, logtxt);}~Log(){}private:int _printMethod;std::string _path;
};
俩无关进程通信

mkfifo接口介绍:

头文件:#include <sys/types.h>#include <sys/stat.h>函数声明:int mkfifo(const char *pathname, mode_t mode);函数参数:1. pathname:要创建的所在路径+管道名2. mode:所创建管道的权限返回值:1. 成功返回02. 失败返回-1,错误码被设置

使用: 实现两个无关的进程间的通信,使用命名管道,同时引用我们上面刚刚实现的日志类

首先要先看到同一份资源:
comm.hpp

#pragma once
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <iostream>//创建的管道文件名 + 权限
#define FIFO_FILE "./myfifo"
#define MODE 0666
#define MAX_SIZE 1024//程序退出码都封装在枚举中
enum exit
{FIFO_CREAT_ERR = 1,FIFO_DESTROY_ERR,FIFO_OPEN_ERR,FIFO_READ_ERR,FIFO_WRITE_ERR
};//把创建管道和销毁管道,封装成一个类
class InitPipe
{
public://创建管道InitPipe(){int n = mkfifo(FIFO_FILE, MODE);if (n == -1){perror("mkfifo");exit(FIFO_CREAT_ERR);}}// 销毁管道~InitPipe(){int m = unlink(FIFO_FILE);if (m == -1){perror("unlink");exit(FIFO_DESTROY_ERR);}}
};

服务端:server.cc

#include "comm.hpp"
#include "log.hpp"int main()
{// 借用命名管道类,实现创建和销毁管道InitPipe Init;//使用日志类定义对象,并选择输出方式Log log;log.Enable(OneFile);log(Info, "already creat pipe success!");// 打开管道 以读的方式int fd = open(FIFO_FILE, O_RDONLY);if (fd == -1){perror("open");log(Fatal, "error string:%s, errno code:%d", strerror(errno), errno);exit(FIFO_OPEN_ERR);}//这里主要是为了测试日志类,下面这四条语句log(Info, "server already open fifo_file, error string:%s, errno code:%d", strerror(errno), errno);log(Waring, "server already open fifo_file, error string:%s, errno code:%d", strerror(errno), errno);log(Fatal, "server already open fifo_file, error string:%s, errno code:%d", strerror(errno), errno);log(Debug, "server already open fifo_file, error string:%s, errno code:%d", strerror(errno), errno);// 开始通信while (true){char buffer[MAX_SIZE] = {0};int x = read(fd, buffer, sizeof(buffer));if (x > 0){buffer[x] = 0;std::cout << "client say:" << buffer << std::endl;}else if (x == 0){log(Debug, "client quit, server too!error string:%s, errno code:%d", strerror(errno), errno);break;}else{perror("read");exit(FIFO_READ_ERR);}}close(fd);return 0;
}

客户端:client.cc

#include "comm.hpp"
#include "log.hpp"int main()
{// 服务端创建好管道,客户端打开即可// 打开管道int fd = open(FIFO_FILE, O_WRONLY);if (fd == -1){perror("open");exit(FIFO_OPEN_ERR);}log(Info, "client already open fifo_file!");std::string line;// 通信while (true){std::cout << "Please Enter@ ";getline(std::cin, line);int w = write(fd, line.c_str(), line.size());if(w < 0){perror("write");exit(FIFO_WRITE_ERR);}}close(fd);return 0;
}

3. 小结

①管道总结

  1. 管道的四种状态:
    • 读写端正常,管道为空,读端阻塞
    • 读写端正常,管道被写满,写端阻塞
    • 读端正常,写端关闭,读端就会读到0,表示读到文件结尾,不会被阻塞
    • 写端正常,读端关闭,OS就会杀掉正在写入的进程,使用13号信号杀掉
  2. 管道的特征:
    • 进程间会进行协同:内核会对管道操作进行同步和互斥——保护管道文件的数据安全
    • 管道是面向字节流的
  3. 匿名管道和命名管道的区别:
    • 匿名管道由pipe接口创建,并打开,后续要关闭相应的文件描述符。命名管道由mkfifo接口创建,打开要使用open,要决定用读还是写的方式打开
    • 匿名管道只有具有亲缘关系的进程才能进行通信。命名管道,无关的进程间也可以通信
    • 管道只能单向通信
    • 管道是基于文件的,文件的声明周期是随进程的,所以管道的生命周期随进程。(匿名管道的声明周期随进程,命名管道的声明周期要显示的删除)
  4. 原子性:
    • 当要写入的数据量小于PIPE_BUF时,Linux保证写入的原子性
    • 当要写入的数据量大于PIPE_BUF时,Linux不再保证写入的原子性

注:
PIPE_BUF——大概意思就是向管道写入的内容小于PIPE_BUF就是原子的,PIPE_BUF的大小是4KB。
原子性: 是一个操作要么完全执行成功,要么完全不执行 PIPE_BUF

②拓展命令和接口

  1. ulimit -a OS对一些重要资源的限制
    ulimit -a

  2. 写代码时最好有一套规范,参数传参规范:
    - 输入型参数: const &
    - 输出型参数: *
    - 输入输出型参数:&

  3. unlink删除文件,不论是管道文件还是普通文件,软链接和硬链接等都可以删除。

  4. gettimeofday也可以获取时间

二、System V

1. 共享内存

共享内存是最快的IPC形式,允许多个进程共享同一块内存区域。进程可以直接读写共享内存中的数据,不再涉及到内核(不需要执行内核的系统调用来传递彼此的数据),也无需进行数据拷贝。

①原理

共享内存看到同一份资源的原理
图解:
图解

  1. 上图的内容可以理解为创建共享内存,让进程拿到这块内存。用完还是要和进程地址空间去关联,然后释放共享内存。这些操作只能由OS来做 —— 系统调用
  2. OS中肯定不止一份共享内存,所以OS就要对共享内存进行管理——先描述,再组织(后面对System V总结时,介绍其内核结构体)
  3. 共享内存映射到进程地址空间的共享区,并且会把映射的起始虚拟位置返回给上层用户
  4. 因为这块内存空间是映射到不同进程的进程地址空间内,可以直接使用(都以为是自己的),通信时不需要更多的拷贝。
  5. 共享内存的生命周期是随内核的,需要手动关闭,如果忘记也是内存泄漏

②使用共享内存

接口介绍

1. shmget和ftok: 共享内存的创建

/ftok///
头文件:#include <sys/types.h>#include <sys/ipc.h>函数声明:key_t ftok(const char *pathname, int proj_id);参数:1. pathname:指向路径(现有,可访问的路径)的字符串2. proj_id:自定义整数返回值:1. 成功,返回key_t类型的唯一键值2. 失败,返回-1,错误码被设置/shmget///
头文件:#include <sys/ipc.h>#include <sys/shm.h>函数声明:int shmget(key_t key, size_t size, int shmflg);参数:1. key:共享内存的内核标识符,通常使用ftok生成。(保证了让不同进程获得同一个共享内存)2. size:开辟共享内存的大小(单位字节)3. shmflg:创建方式IPC_CREAT(单独使用):没有就创建,有就返回IPC_CREAT | IPC_EXIT:有就出错返回,没有就创建。(保证是新创建的共享内存)IPC_EXIT:不单独使用在最后可以直接给共享内存|一个权限
返回值:1. 成功,返回共享内存的标识符2. 失败,返回-1,错误码被设置

上述两个接口的介绍:

  1. ftok:
    • ftok函数本质就是一套算法,将pathname转换成一个唯一的整数值,然后和proj_id再组合,生成唯一键值,用于创建、获取IPC对象的标识符。
    • key能让不同进程进行唯一性标识,第一个进程通过key(这个唯一键值)创建共享内存,第二个之后的进程,只要拿着同一个key就可以和第一个进程看到同一个共享内存
    • key类似路径(具有唯一性)
      注:生成的值可能存在冲突的风险。
  2. shmget:
    • 通过ftok接口获取的key,创建一个新的共享内存或者获取一个已存在的共享内存的shmid
    • shmid就是shmget的返回值,其是为了用户接下来控制共享内存。

上述两个函数的返回值对比:shmid和key
图解

测试shmget和ftok接口:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <iostream>
#include <string>
#include <cerrno>
#include <cstdio>// 为了获取key值设置的两个参数 -> ftok
const std::string pathname = "/home/kpl_2023";
const int proj_id = 0x32231;// 开辟共享内存的大小
// 共享内存的大小建议4096的整数倍
#define SIZE 4096// 创建共享内存的权限.实际权限可能在OS内核中被限制和管理
#define SHM_MODE 0666int main()
{// 获取key,用来创建共享内存key_t key = ftok(pathname.c_str(), proj_id);   // key_t 实际就是 intif (key < 0){perror("ftok");exit(errno);}printf("get key success! key:0x%x\n", key);// 创建共享内存int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | SHM_MODE); //权限直接在最后的位置 |上即可if (shmid < 0){perror("shmget");exit(errno);}printf("Create shm success! shmid:%d\n", shmid);return 0;
}

注:

  1. 查看系统中共享内存的命令
    ipcs -m
  2. 删除系统中共享内存的命令
    ipcrm -m [shmid]

测试结果:
运行结果

2. shmat和shmdt: 共享内存操作

/shmat///
头文件:#include <sys/types.h>#include <sys/shm.h>函数声明:void *shmat(int shmid, const void *shmaddr, int shmflg);参数:1. shmid:共享内存标识(shmget成功的返回值)2. shmaddr:指定连接的地址(一般设置成nullptr,由OS自己分配。如果设置了,并且该被挂接的地址空间位置又被占用,返回-13. shmflg:进程映射共享内存的方式,一般设置成0。两个可能取值:SHM_RND:这可以提高内存访问的效率。SHM_RDONLY:进程只能读取共享内存中的数据,而不能修改数据。shmaddr不为nullptr且shmflg设置SHM_RND标记,则连接地址会自动向下调整SHMLBA的整数倍。返回值:1. 成功,返回共享内存映射到进程地址空间的地址2. 失败,返回-1,错误码被设置/shmdt///
头文件:#include <sys/types.h>#include <sys/shm.h>函数声明:int shmdt(const void *shmaddr);参数:shmaddr:共享内存映射到进程地址空间的地址返回值:1. 成功返回02. 失败返回-1,错误码被设置

上述两个接口的作用

  1. shmat:将共享内存段连接到进程地址空间
  2. shadt:将共享内存段与当前进程脱离

3. shmctl: 共享内存控制

头文件:#include <sys/ipc.h>#include <sys/shm.h>函数声明:int shmctl(int shmid, int cmd, struct shmid_ds *buf);参数:1. shmid:共享内存标识(shmget成功的返回值)2. cmd:要控制的动作:IPC_STAT、IPC_SET、IPC_RMIDIPC_STAT:获取共享内存的状态信息,放在buf中IPC_SET:在进程有足够的权限下,把共享内存当前的管理值设置为shmid_ds数据结构中给出的值IPC_RMID:删除共享内存段3. buf:根据选项不同可以是输入型参数也可以是输出型参数,指向一个保存着共享内存的模式状态和访问权限的数据结构返回值:1. 成功,返回02. 失败返回-1,错误码被设置	

可以删除共享内存、获取共享内存的信息

使用

两个进程间的通信,使用共享内存

首先创建同一份资源:
comm.hpp

#ifndef __COMM_HPP__
#define __COMM_HPP__#include <iostream>
#include <string>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>// 为了获取key值
const std::string pathname = "/home/kpl_2023";
const int proj_id = 0x32231;// 开辟共享内存的大小
// 共享内存的大小简易4096的整数倍
#define SIZE 4096// 创建共享内存的权限
#define SHM_MODE 0666enum exit
{SHM_FTOK_ERR = 1,SHM_SHMGET_ERR,SHM_SHMAT_ERR,SHM_SHMDT_ERR,SHM_SHMCTL_IPC_RMID_ERR,FIFO_CREATE_ERR = 10,FIFO_DELETE_ERR,FIFO_OPEN_ERR
};// 获取key
key_t GetKey()
{key_t key = ftok(pathname.c_str(), proj_id); // key_t 实际就是 intif (key < 0){perror("ftok");exit(SHM_FTOK_ERR);}return key;
}// 获取shmid
int GetShareMemHelper(int flag)
{key_t key = GetKey();int shmid = shmget(key, SIZE, flag);if (shmid < 0){perror("shmget");exit(SHM_SHMGET_ERR);}return shmid;
}// 给服务端的接口
int CreatShm()
{return GetShareMemHelper(IPC_CREAT | IPC_EXCL | SHM_MODE);
}// 给客户端的接口
int GetShm()
{return GetShareMemHelper(IPC_CREAT);
}#endif

进程1:
share_mema.cc

#include "comm.hpp"int main()
{//创建共享内存int shmid = CreatShm();// shm挂接到进程地址空间 ->  返回的时挂接在进程地址空间处的地址char *shmaddr = (char *)shmat(shmid, nullptr, 0);if (*shmaddr < 0){perror("shamt");exit(SHM_SHMAT_ERR);}// OS提供的结构体struct shmid_ds shmds;// 通信while (true){std::cout << "client say@ " << shmaddr << std::endl; // 直接访问地址即可sleep(1);shmctl(shmid, IPC_STAT, &shmds);std::cout << "shm size: " << shmds.shm_segsz << std::endl;std::cout << "shm nattch: " << shmds.shm_nattch << std::endl;printf("shm key: 0x%x\n", shmds.shm_perm.__key);}// 去挂接int shmdtid = shmdt(shmaddr);if (shmdtid < 0){perror("shmdt");exit(SHM_SHMDT_ERR);}// 删除共享内存int rmid = shmctl(shmid, IPC_RMID, nullptr);if (rmid < 0){perror("shmctl::IPC_RMID");exit(SHM_SHMCTL_IPC_RMID_ERR);}return 0;
}

进程2:
share_memb.cc

#include "comm.hpp"// 客户端不能先运行,因为我们没有让它创建共享内存,所以没有设置权限,
//  所以shmat挂接不上会出现段错误int main()
{//获取共享内存shmidint shmid = GetShm();char *shmaddr = (char *)shmat(shmid, nullptr, 0);if (*shmaddr < 0){perror("shamt");exit(SHM_SHMAT_ERR);}std::cout << "shmat success !!!" << std::endl;// 通信while (true){std::cout << "Please Enter@ ";fgets(shmaddr, SIZE, stdin);}// 去挂接int shmdtid = shmdt(shmaddr);if (shmdtid < 0){perror("shmdt");exit(SHM_SHMDT_ERR);}return 0;
}

③小结

  1. 共享内存没有同步互斥之类的保护机制
  2. 共享内存是所有进程间通信中,速度最快的,拷贝少
  3. 共享内存内部的数据,由用户自己维护

2. 消息队列

  1. 消息队列是一种消息传递机制。消息队列可以实现进程之间的异步通信。
  2. 允许不同的进程,向内核中发送带类型的数据块,接收者进程接收的数据块可以有不同类型。
  3. 看到同一份资源:不同进程看到同一个队列(内核中)
  4. 消息的生命周期是随内核的,需要手动删除,如果忘记也是内存泄漏

原理:
图解

内核中,OS管理着消息队列,进程(用户)想要使用这块资源,OS一定会提供系统调用给用户。

3. 信号量

信号量是一种用于进程同步和互斥的机制,可以用来解决进程之间的竞争条件和临界区问题。
信号量的生命周期是随内核的,需要手动删除,如果忘记也是内存泄漏

信号量是进程间通信的原因:

  1. 通信不仅仅是数据的交互,互相协同也是
  2. 信号量可以被所有通信进程看到

①引入

  1. 数据不一致问题: A、B进程看到同一份资源(共享资源),如果不加保护,会导致数据不一致问题。eg:共享内存
  2. 互斥: 任何时刻只允许一个执行流访问共享资源。加锁——互斥访问
  3. 临界资源: 共享资源,任何时刻只允许一个执行流访问——一般是内存空间
  4. 临界区: 访问临界资源的代码

②原理

1. 理解信号量

信号量的本质就是一把计数器(描述临界资源中资源数量的多少)
图解
信号量解释:

  1. 所以申请计数器成功,那就代表还有资源,具有访问资源的权限
  2. 申请计数器资源,并不表示我(执行流)访问要的资源了,而是对资源的预定机制
  3. 计数器可以有效保证进入这一份资源(共享资源)的执行流数量

所以每个执行流,想要访问共享资源的一部分时,不能直接访问,而是要先申请计数器资源,而这个计数器就是信号量

2. 二元信号量(锁)

当我们把上面那个临界资源当成一个整体,那么计数器cnt就是1。这就只能一个执行流访问,当再来执行流时计数器是0,就不能再有执行流访问了——互斥
所以,我们把值只能为1、0两态的计数器叫做二元信号量——本质就是一个锁

3. 共享资源——信号量:

  1. 要访问临界资源,就得先申请信号量(计数器)资源,它保护着临界资源。但是计数器也被很多执行流共享,所以也是共享资源,那也得被保护。
  2. 申请计数器本质就是对计数器减一,但是虽然在C语言上计数器减一是一条语句,但是转成汇编就是三条(也可能多条)汇编。进程在运行时随时被切换,所以这减一操作是不安全的
  3. 所以为了保护这个信号量资源,在底层做了工作
    • P操作:申请信号量,本质是对计数器减一
    • V操作:释放资源,释放信号量,本质是对计数器加一

结论: 信号量的PV操作都是原子的——要么不做,要么做完

4. 总结

1. 共享内存、消息队列和信号量的接口:
接口

观察发现三者的接口相似,原因:System V标准规定
所以:

  1. 消息队列
    • 查看系统中的消息队列的命令
      ipcs -q
    • 删除系统中消息队列的命令
      ipcrm -q [msgid]
  2. 信号量
    • 查看系统中信号量的命令
      ipcs -s
    • 删除系统中信号量的命令
      ipcs -s [semid]
  1. IPC在内核中的数据结构设计:

图解

相关文章:

  • vue的生命周期有那些
  • React 教程
  • windows环境,gitbash可以连接拉取代码,但是idea没有权限
  • C#,红黑树(Red-Black Tree)的构造,插入、删除及修复、查找的算法与源代码
  • 离子束铣削(Ion Beam milling)
  • 惬意了解 —— 前端发展史
  • 【敬伟ps教程】视频动画
  • LeetCode 面试题08.04.幂集
  • FFmpeg开发笔记(十)Linux环境给FFmpeg集成vorbis和amr
  • 30个Linux性能问题诊断思路
  • 【构建部署_Docker介绍与安装】
  • 【框架学习 | 第六篇】SpringBoot基础篇(快速入门、自动配置原理分析、配置文件、整合第三方技术、拦截器、文件上传/下载、访问静态资源)
  • 使用yarn创建vite+vue3electron多端运行
  • 【C语言】人生重开模拟器
  • Elasticsearch使用Kibana进行基础操作
  • 【译】React性能工程(下) -- 深入研究React性能调试
  • canvas 五子棋游戏
  • Git学习与使用心得(1)—— 初始化
  • JAVA_NIO系列——Channel和Buffer详解
  • KMP算法及优化
  • leetcode46 Permutation 排列组合
  • Netty源码解析1-Buffer
  • SpringBoot几种定时任务的实现方式
  • spring学习第二天
  • Twitter赢在开放,三年创造奇迹
  • 彻底搞懂浏览器Event-loop
  • 翻译--Thinking in React
  • 给github项目添加CI badge
  • 关于extract.autodesk.io的一些说明
  • 小而合理的前端理论:rscss和rsjs
  • 一加3T解锁OEM、刷入TWRP、第三方ROM以及ROOT
  • 【干货分享】dos命令大全
  • MPAndroidChart 教程:Y轴 YAxis
  • 树莓派用上kodexplorer也能玩成私有网盘
  • ​Linux Ubuntu环境下使用docker构建spark运行环境(超级详细)
  • $HTTP_POST_VARS['']和$_POST['']的区别
  • (poj1.2.1)1970(筛选法模拟)
  • (博弈 sg入门)kiki's game -- hdu -- 2147
  • (二)斐波那契Fabonacci函数
  • (附源码)spring boot公选课在线选课系统 毕业设计 142011
  • (附源码)流浪动物保护平台的设计与实现 毕业设计 161154
  • (转)ABI是什么
  • (转)机器学习的数学基础(1)--Dirichlet分布
  • (转)项目管理杂谈-我所期望的新人
  • **登录+JWT+异常处理+拦截器+ThreadLocal-开发思想与代码实现**
  • .NET CF命令行调试器MDbg入门(四) Attaching to Processes
  • .Net Core webapi RestFul 统一接口数据返回格式
  • .NET8.0 AOT 经验分享 FreeSql/FreeRedis/FreeScheduler 均已通过测试
  • @manytomany 保存后数据被删除_[Windows] 数据恢复软件RStudio v8.14.179675 便携特别版...
  • [1]-基于图搜索的路径规划基础
  • [14]内置对象
  • [Android]使用Android打包Unity工程
  • [Asp.net MVC]Asp.net MVC5系列——Razor语法
  • [autojs]autojs开关按钮的简单使用
  • [C/C++随笔] char与unsigned char区别