进程间通信——IPC(Linux)

前言

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. 成功返回0
	2. 失败返回-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 call
        write(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 -> r
    pid_t id = fork();
    if (id < 0)
        exit(errno);
    else if (id == 0)
    {
        // child 关闭读端
        close(pipefd[0]);
        // 写方法
        Writer(pipefd[1]);

        close(pipefd[1]);
        exit(0);
    }
    // father
    close(pipefd[1]);
    // 读方法
    Reader(pipefd[0]);

    // wait child process
    pid_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 call
        write(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. 失败返回nullptr
		struct tm定义:
		struct tm 
		{
		    int tm_sec;   // 秒,范围为 0-59
		    int tm_min;   // 分,范围为 0-59
		    int tm_hour;  // 时,范围为 0-23
		    int tm_mday;  // 一个月中的日期,范围为 1-31
		    int tm_mon;   // 月份,范围为 0-11
		    int tm_year;  // 年份,从 1900 年开始
		    int tm_wday;  // 一周中的天数,范围为 0-6 (0 表示周日)
		    int tm_yday;  // 一年中的天数,范围为 0-365
		    int 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置为nullptr
	4. 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=NULL
    return 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. 成功返回0
	2. 失败返回-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 0666

int main()
{

    // 获取key,用来创建共享内存
    key_t key = ftok(pathname.c_str(), proj_id);   // key_t 实际就是 int
    if (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. 成功返回0
	2. 失败返回-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_RMID
		IPC_STAT:获取共享内存的状态信息,放在buf中
		IPC_SET:在进程有足够的权限下,把共享内存当前的管理值设置为shmid_ds数据结构中给出的值
		IPC_RMID:删除共享内存段
	3. buf:根据选项不同可以是输入型参数也可以是输出型参数,指向一个保存着共享内存的模式状态和访问权限的数据结构

返回值:
	1. 成功,返回0
	2. 失败返回-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 0666

enum 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 实际就是 int
    if (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()
{
    //获取共享内存shmid
    int 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在内核中的数据结构设计:

图解

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

kpl_20

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值