Linux进程间通信:管道与System V IPC的全解析

文章目录


进程间通信的介绍

进程间通信是什么?(概念)

两个或多个进程实现数据层面的交互就是进程间通信,简称IPC(Interprocess communication)。

然而,因为进程独立性的存在,所以进程通信的成本是比较高的。

为什么要进程间通信?(目的)

  • 数据传输:一个进程需要将它的数据发送给另一个进程
  • 资源共享:多个进程之间共享同样的资源
  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程。
  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

进程间通信的本质

进程间通信的本质:必须让不同的进程看到同一份资源

这个资源指的是什么?

特定形式的内存空间

这个资源由谁提供?

一般由操作系统来提供

为什么不能是通信的进程提供呢?

假设让进程来提供,那这个资源属于谁?是属于进程独有的,如果这个资源可以让其他进程看到,就破坏了进程的独立性了!所以这个资源必须是第三方空间。

既然这个空间由操作系统提供,那么我们进程访问这个空间进行通信,本质就是访问操作系统,而进程代表的就是用户。

因此,我们的"资源"从创建到使用再到释放,一定是由系统调用接口来完成。

而且这类系统调用接口的底层设计都要由操作系统独立设计。

所有Linux操作系统会有一个独立的通信模块(IPC通信模块),这个模块隶属于文件系统。

进程间通信的分类

  • 管道:基于文件级别的通信方式
    • 匿名管道
    • 命名管道
  • 进程通信模块标准
    • System V IPC
      • System V 共享内存
      • System V 消息队列
      • System V 信号量
    • POSIX IPC
      • 消息队列
      • 共享内存
      • 信号量
      • 互斥量
      • 条件变量
      • 读写锁

管道

何为管道?

  • 管道是Unix中最古老的进程间通信的形式
  • 我们把从一个进程连接到另一个进程的数据流称为一个"管道"。

匿名管道

匿名管道原理

进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。

注意

  • 管道文件是由操作系统维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
  • 管道虽然是文件,但操作系统并不会把进程进行通信的数据刷新到磁盘中,因为这样做没必要(进程通信的内容不必保存),多余的IO操作会降低效率。所以说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。

现在,我们两进程间已经有同一份资源了,该资源是多执行流共享的,难免会出现访问冲突的问题,所以匿名管道规定:只能进行单向通信

eg:让子进程进行写入,父进程进行读取

父进程对一个管道文件打开两次,一个专门用来读取数据,另一个专门用来写入数据(和打开两次显示器文件一样,一个用来输出结果,一个用来输出错误)。然后创建子进程,关闭父进程的写,关闭子进程的读即可。

具体原理如图:

如果我们需要双向通信呢?

多个管道可以实现双向通信

如果进程间没有任何关系,可以用匿名管道进行通信吗?

不能!进程之间需要有血缘关系才能,常用于父子之间。

系统调用接口pipe

pipe函数用于创建匿名管道,函数原型如下:

cpp 复制代码
#include <unistd.h>

int pipe(int pipefd[2]);

它的参数是输出型参数,其作用是将管道文件的文件描述符带出来,供用户使用

  • pipefd[0] :管道读端的fd
  • pipefd[1] :管道写端的fd

返回值 :创建成功返回0,失败则返回-1。

匿名管道实现进程间通信

创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,实现父进程写,子进程读的具体步骤如下:

完整代码:

cpp 复制代码
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

#define N 2
#define NUM 1024

//father
void Read(int rfd)
{
    char buffer[NUM];
    while(true)
    {
        int n = read(rfd, buffer, sizeof(buffer));   
        if(n > 0)
        {
            buffer[n] = 0;//设置字符串末尾\0
            cout<< "father get a message[" << buffer << "]" << endl;
        }
        else if(n == 0)
        {
            printf("father get end\n");
            break;
        }
        else 
        {
            printf("father get fail\n");
            break;
        }
    }
}

//child
void Write(int wfd)
{
    std::string s("I am child");
    pid_t self = getpid();
    int numbers = 0;
    char buffer[NUM];
    while(true)
    {
        buffer[0] = 0;//清空字符串,并表明这个数组当做字符串了
        snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, numbers++);
        write(wfd, buffer, strlen(buffer));
        sleep(1);

        if(numbers > 5) break;
    }
}

int main()
{
    //先创建管道
    int pipefd[N] = {0};
    int n = pipe(pipefd);
    if(n < 0) return 1;
    //创建子进程
    pid_t id = fork();
    if(id < 0) return 2;
    if(id == 0)
    {
        //child
        close(pipefd[0]);
        //子进程在管道中写
        Write(pipefd[1]);
        
        close(pipefd[1]);
        exit(0);
    }
    //father
    close(pipefd[1]);
    //父进程在管道中读
    Read(pipefd[0]);
    //wait child
    pid_t rid = waitpid(id, nullptr, 0);
    if(rid < 0) return 3;

    close(pipefd[0]);

    return 0;
}
}

运行结果:

匿名管道的特征
  1. 管道具有血缘关系的进程才能进行进程间通信
  2. 管道只能单向通信
  3. 父子进程是会进程协同的(同步互斥),以保障管道文件的数据安全
  4. 管道是面向字节流的
  5. 管道是基于文件的,而它的生命周期是随进程的
  6. 管道是有固定大小的
    在不同的内核里,大小可能有差异。
    在Linux 2.6.11之后的版本,管道的最大容量一般是65536字节。
管道的四种读写情况

管道的读写有4种情况,它们都被制定了规则,以保障管道文件的数据安全。

  1. 读写端正常,如果管道为空,读端就要阻塞
  2. 读写端正常,如果管道被写满了,写端就要阻塞
  3. 读端正常,写端关闭,读端就会读到0,表明读到了管道文件的结尾,不会被阻塞
  4. 写端正常,读端关闭,操作系统会用13号信号杀掉写端的进程。
    操作系统这个动作是合理的,因为只有写没有读一定是无用功。操作系统是不会做低效、浪费等类似工作的,如果做了,那就是操作系统的bug。

命名管道

匿名管道只能局限于具有血缘关系的进程间通信,那没有任何关系的进程间呢?命名管道可以做到!

命名管道原理

命名管道与匿名管道原理基本一致,不同的是管道是由我们用户手动创建并命名的。

对于匿名管道,管道是由父进程打开并继承给子进程的,直接就可以保证它是两进程的同一份资源。那命名管道是如何确保两进程打开的是同一个文件呢?

没错,利用的就是它的文件名
文件名+路径 ,就可以保证该管道的唯一性,让两个进程都去打开这个管道文件,就可以看到同一份资源了。

注意

  • 为什么普通文件不能用来通信?普通文件很难做到通信,因为无法解决一些安全问题。
  • 管道文件也不需要刷新到磁盘,也属于内存级文件。
命令创建命名管道

mkfifo [文件名]命令用于创建命名管道。

bash 复制代码
zhh@hcss-ecs-c21b:~/code/oscode/fifo/test$ mkfifo myfifo

创建出来的文件的类型是p,代表该文件是命名管道文件。

程序中创建命名管道

在程序中创建命名管道可以使用mkfifo函数,mkfifo函数的函数原型如下:

cpp 复制代码
#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);

函数剖析:

  • 参数1pathname :表示要创建的命名管道文件
    • pathname以路径的方式给出,则将命名管道文件创建在pathname指定的路径下。
    • pathname只以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义:当前工作目录)
  • 参数2 mode :表示创建命名管道文件的默认权限
    mode通常设置为0664
  • 返回值int:创建成功返回0,失败返回-1
程序中删除命名管道

命名管道需要手动删除,否则下次运行程序时存在同名同路径的命名管道会导致创建失败。

可以使用unlink函数,它是一个系统调用函数,unlink函数的函数原型如下:

cpp 复制代码
#include <unistd.h>

int unlink(const char *pathname);

函数剖析:

  • 参数1pathname:表示要删除的命名管道文件,同mkfifo的参数一样。
  • 返回值int:删除成功返回0,失败返回-1
命名管道实现serve&client通信

实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端先运行起来,我们需要让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了,最后需要关闭服务端时,我们删除命名管道文件。

对于命名管道的创建与删除,我们可以利用C++类的构造来创建命名管道和析构来删除命名管道,让这些操作自动化。在服务端,我们只需要实例化一个对象即可。

头文件comm.hpp:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

using namespace std;

#define FIFO_FILE "./myfifo"
#define MODE 0664
#define NUM 1024

//自定义错误码
enum
{
    FIFO_CREATE_ERR = 1,
    FIFO_DELETE_ERR,
    FIFO_OPEN_ERR
};

class Init
{
public:
    Init()
    {
        int n = mkfifo(FIFO_FILE, MODE);
        if(n == -1) 
        {
            perror("mkfifo");
            exit(FIFO_CREATE_ERR);
        }
    }
    ~Init()
    {
        int m = unlink(FIFO_FILE); 
        if(m == -1)
        {
            perror("unlink");
            exit(FIFO_DELETE_ERR);
        } 
    }
};

服务端代码server.cc

cpp 复制代码
#include "comm.hpp"

int main()
{
    Init init;
    //打开命名管道
    pid_t fd = open(FIFO_FILE, O_RDONLY, 0664);//没有被写打开的话,会阻塞等待
    if(fd < 0) 
    {
        perror("open");
        exit(FIFO_OPEN_ERR);
    }

    cout << "server open file done" << endl;
    
    //开始通信
    while(true)
    {
        char buffer[NUM] = {0};
        int n = read(fd, buffer, sizeof(buffer));
        if(n > 0)
        {
            buffer[n] = 0;
            cout << "client say# " << buffer << endl;
        }
        else if(n == 0)
        {
            cout << "client quit, me too!" << endl;
            break;
        }
        else break;
    }
    
    close(fd);
    return 0;
}

对于客户端来说,因为服务端运行起来后命名管道文件就已经被创建了,所以客户端只需以写的方式打开该命名管道文件,之后客户端就可以将通信信息写入到命名管道文件当中即可。

客户端代码client.cc

cpp 复制代码
#include "comm.hpp"

int main()
{
    //打开管道
    pid_t fd = open(FIFO_FILE, O_WRONLY, MODE);
    if(fd < 0) 
    {
        perror("open");
        exit(FIFO_OPEN_ERR);
    }

    std::cout<< "client open file done" <<std::endl;

    //开始通信
    string line;
    while (true)
    {
        cout << "Please Entry#";
        getline(cin, line);
        write(fd, line.c_str(), line.size());
    }
    
    close(fd);
    return 0;
}

现在我们来测试一下代码功能:

先将服务端进程运行起来,之后我们就能在客户端看到这个已经被创建的命名管道文件。

此时服务端处于堵塞状态,因为命名管道没有写端

此时,我们将客户端也运行起来

现在,我们从客户端输入的内容就可以被服务端读取了

当客户端退出后,服务端将管道当中的数据读完后就再也读不到数据了,那么此时服务端也就会去执行它的其他代码了(在当前代码中是直接退出了)

当服务端退出后,客户端写入管道的数据就不会被读取了,也就没有意义了,那么当客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。

命名管道和匿名管道的区别
  • 匿名管道由pipe函数创建并打开
  • 命名管道由mkfifo函数创建,打开用open
  • FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。

System V IPC

管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但无论如何,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。

System V IPC提供的通信方式分为三种:共享内存、消息队列和信号量。本文我会重点介绍共享内存,而消息队列和信号量我会阐述其通信原理。

System V共享内存

共享内存的原理

共享内存是怎么让不同进程看到同一份资源的呢?

共享内存的原理十分简单粗暴,操作系统在物理内存中申请一块空间,然后挂接到进程的进程空间的共享区,在进程上层返回共享内存的首地址(起始虚拟地址)。如此,进程访问该空间进行读写即可完成进程间通信。

原理如图:


注意

  • 以上的操作包括申请、挂接、释放共享内存,这些操作都是由操作系统来做的,怎么做?通过系统调用!
  • 在内存中,可能会有大量的共享内存的存在,操作系统会去管理所有的共享内存,如何管理呢?先描述,再组织!也就是说,内核中一定存在结构体会去描述共享内存,具体我们后面说。
共享内存的创建

创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:

cpp 复制代码
#include <sys/ipc.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);

函数剖析

  • 参数1key:表示待创建共享内存在系统当中的唯一标识,用ftok获取。
  • 参数2size:表示待创建共享内存的大小,单位为字节。
  • 参数3shmflg:表示创建共享内存的方式。
  • 返回值int
    • 共享内存创建成功,返回该共享内存标识符(类似于文件描述符的机制)
    • 共享内存创建失败,返回-1

注意:共享内存标识符本质上也是数组下标,但它的分配机制并不是顺序分配。

key

我们如何保证让不同的进程看到的是同一个共享内存?

我们如何知道一个共享内存是否存在?

  1. key是一个数字,这个数字的大小不重要,关键在于它必须在内核中具有唯一性,能够让不同的进程进行唯一标识
  2. 第一个进程可以通过key创建共享内存,第二个之后的进程,只需拿着同一个key就可以和第一个进程看到同一个共享内存了。
  3. 对于一个已经创建好的共享内存,key在哪??在共享内存的描述对象中!
  4. 共享内存第一次创建的时候,必须有一个key,这个key在,怎么来?用ftok获取。
  5. key就类似于路径一样,具有唯一性

ftok本质就是一套算法(计算出重复的key值概率极低),ftok函数原型如下:

cpp 复制代码
#include <sys/types.h>
#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);

函数剖析

  • 参数:由用户自由指定
  • 返回值:返回一个key值

size的设置

我们在设置共享内存大小的时候应当为4096(4kb)的倍数。

因为在物理内存中,申请内存以页(4kb)为单位,就算我们只申请1字节的空间,操作系统都会为我们申请4kb的空间,这样我们消耗了4kb,但我们却只能用1字节,岂不是很浪费了。

shmflg的两种常用组合方式

组合方式 作用
IPC_CREAT 如果该共享内存不存在,就创建该共享内存;如果已经存在,则获取
`IPC_CREAT IPC_EXCL`

注意

  • IPC_CREAT|IPC_EXCL可以保证申请一个新的共享内存
  • IPC_EXCL不单独使用
  • 另外我们还需要指定共享内存的权限,通常为0666

创建共享内存方法如下:

注意:代码中的log是我自制的一个简易日志小插件,需要的可以自取日志插件

cpp 复制代码
#define SIZE 4096
#define PATHNAME "/home/zhh"
#define PROJ_ID 0x555

key_t GetKey()
{
    int key = ftok(PATHNAME, PROJ_ID);
    if(key < 0)
    {
        log(Fatal, "ftok error:%s", strerror(errno));
        exit(1);
    }
    log(Info, "ftok success,key is 0x%x", key);
    return key;
}

int GetShareMemHelper(int flag)
{
    key_t k = GetKey();
    int shmid = shmget(k, SIZE, flag|0666);
    if(shmid < 0) 
    {
        log(Fatal, "shmget error:", strerror(errno));
        exit(2);
    }
    log(Info, "shmget success,shmid is %d", shmid);

    return shmid;
}
//创建共享内存
int CreateShm()
{
    return GetShareMemHelper(IPC_CREAT|IPC_EXCL);
}
//获取共享内存
int GetShm()
{
    return GetShareMemHelper(IPC_CREAT);
}
使用指令查看共享内存信息和删除共享内存

在Linux当中,我们可以使用ipcs命令查看有关进程间通信设施的信息(共享内存、消息队列和信号量)。

ipcs -m选项只查看共享内存的信息

删除释放共享内存:

bash 复制代码
ipcrm -m [shmid]
共享内存的挂接

shmat函数用于将共享内存挂接到进程,函数原型如下:

cpp 复制代码
#include <sys/types.h>
#include <sys/shm.h>

void *shmat(int shmid, const void *shmaddr, int shmflg);

函数剖析

  • 参数1shmid:共享内存的标识符
  • 参数2shmaddr :挂接到进程地址空间的哪个位置,通常设为nullptr,由系统决定
  • 参数3shmflg:权限,通常设为0,为系统默认权限
  • 返回值void* :成功挂接,返回挂接好后进程地址空间的首地址;挂接失败,返回(void *) -1
共享内存的去挂接

shmdt函数用于进程与共享内存的去挂接,函数原型如下:

cpp 复制代码
#include <sys/types.h>
#include <sys/shm.h>

int shmdt(const void *shmaddr);

函数剖析

  • 参数shmaddr:需要去挂接共享内存在进程地址空间中的首地址
  • 返回值int:去挂接成功返回0,失败返回-1
释放共享内存

共享内存的生命周期是随内核的,如果用户不去主动关闭,共享内存会一直存在。也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。

释放共享内存有两个方法,一就是使用命令释放共享内存(上文的ipc指令),二就是在进程通信完毕后调用释放共享内存的函数进行释放。

释放共享内存是通过调用共享内存的修改接口shmctl来实现的,这个接口可以修改或查看共享内存的任意属性。

函数原型如下:

cpp 复制代码
#include <sys/ipc.h>
#include <sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • 参数1shmid:共享内存的标识符
  • 参数1cmd :操作选项,常用的有以下两个选项:
    • IPC_RMID:设为删除
    • IPC_STAT:查看属性
      我们要释放共享内存,选择IPC_RMID即可
  • 参数3buf :共享内存的属性集
    我们删除共享内存无需这个属性集,传入nullptr即可
  • 返回值int:操作成功返回0,失败返回-1
共享内存实现serve&client通信

知道了共享内存的创建、挂接、去挂接以及释放后,现在可以尝试让两个进程通过共享内存进行通信了。

我们将前文编写的创建共享内存的方法放在头文件里,届时需要创建共享内存时直接调用接口即可:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include "log.hpp"

#define SIZE 4096
#define PATHNAME "/home/zhh"
#define PROJ_ID 0x555

using namespace std;

Log log;

key_t GetKey()
{
    int key = ftok(PATHNAME, PROJ_ID);
    if(key < 0)
    {
        log(Fatal, "ftok error:%s", strerror(errno));
        exit(1);
    }
    log(Info, "ftok success,key is 0x%x", key);
    return key;
}

int GetShareMemHelper(int flag)
{
    key_t k = GetKey();
    int shmid = shmget(k, SIZE, flag|0666);
    if(shmid < 0) 
    {
        log(Fatal, "shmget error:", strerror(errno));
        exit(2);
    }
    log(Info, "shmget success,shmid is %d", shmid);

    return shmid;
}

int CreateShm()
{
    return GetShareMemHelper(IPC_CREAT|IPC_EXCL);
}

int GetShm()
{
    return GetShareMemHelper(IPC_CREAT);
}

服务端具体逻辑如下

  1. 创建新的共享内存
  2. 挂接共享内存到服务端进程
  3. 进行通信(读数据)
  4. 去挂接共享内存
  5. 释放共享内存

客户端具体逻辑如下

  1. 获取同一个共享内存
  2. 挂接
  3. 通信(写数据)
  4. 去挂接

需要注意的是

  • 进程使用共享内存通信无需调用系统调用,因为这个空间对于进程来说是可以直接访问的
  • 共享内存不像管道一样具有同步互斥的机制,进程间通信会混乱,这里我们可以借助命名管道来让客户端写完一次数据后再通知服务端来读取一次数据,实现简单的同步互斥机制

我们将前文编写的创建共享内存的方法放在头文件里,届时需要创建共享内存时直接调用接口即可

头文件代码:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include "log.hpp"

#define SIZE 4096
#define PATHNAME "/home/zhh"
#define PROJ_ID 0x555

using namespace std;

Log log;

key_t GetKey()
{
    int key = ftok(PATHNAME, PROJ_ID);
    if(key < 0)
    {
        log(Fatal, "ftok error:%s", strerror(errno));
        exit(1);
    }
    log(Info, "ftok success,key is 0x%x", key);
    return key;
}

int GetShareMemHelper(int flag)
{
    key_t k = GetKey();
    int shmid = shmget(k, SIZE, flag|0666);
    if(shmid < 0) 
    {
        log(Fatal, "shmget error:", strerror(errno));
        exit(2);
    }
    log(Info, "shmget success,shmid is %d", shmid);

    return shmid;
}

int CreateShm()
{
    return GetShareMemHelper(IPC_CREAT|IPC_EXCL);
}

int GetShm()
{
    return GetShareMemHelper(IPC_CREAT);
}

//命名管道
#define FIFO_FILE "./myfifo"
#define MODE 0664
#define NUM 1024

enum
{
    FIFO_CREATE_ERR = 1,
    FIFO_DELETE_ERR,
    FIFO_OPEN_ERR
};

class Init
{
public:
    Init()
    {
        int n = mkfifo(FIFO_FILE, MODE);
        if(n == -1) 
        {
            perror("mkfifo");
            exit(FIFO_CREATE_ERR);
        }
    }
    ~Init()
    {
        int m = unlink(FIFO_FILE); 
        if(m == -1)
        {
            perror("unlink");
            exit(FIFO_DELETE_ERR);
        } 
    }
};

服务端代码:

cpp 复制代码
#include "comm.hpp"
#include "log.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, "error string: %s, error code: %d", strerror(errno), errno);
        exit(FIFO_OPEN_ERR);
    }

    //开始通信
    while(true)
    {
        char c;
        ssize_t s = read(fd, &c, 1);
        if(s <= 0) break;

        //直接访问共享内存
        cout<< "client say#" << shmaddr << endl;
        sleep(1);
    }
    //去挂接
    shmdt(shmaddr);
    //释放共享内存
    shmctl(shmid, IPC_RMID, nullptr);
    return 0;
}

客户端代码:

cpp 复制代码
#include "comm.hpp"
#include "log.hpp"

int main()
{
    //获取共享内存
    int shmid = GetShm();
    //挂接该进程
    char* shmaddr = (char*)shmat(shmid, nullptr, 0);
    int fd = open(FIFO_FILE, O_WRONLY);
    if (fd < 0)
    {
        log(Fatal, "error string: %s, error code: %d", strerror(errno), errno);
        exit(FIFO_OPEN_ERR);
    }
    //开始通信
    while(true)
    {
        //直接访问共享内存
        cout<<"Please Enter@";  
        fgets(shmaddr, 4096, stdin);
        
        write(fd, "c", 1);//提醒对方,该接收消息了
    }
    //去挂接
    shmdt(shmaddr);
    return 0;
}

运行结果:

共享内存与管道对比

其实,共享内存是所有进程间通信方式中最快的一种通信方式

我们来与管道对比一下:

共享内存创建好后就不再需要调用系统接口进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。

以进程A写入,进程B读取为例:

  • 对于管道:进程A将写入好的数据拷贝 到用户缓冲区,然后用户缓冲区再拷贝 到管道中。进程B再将管道的数据读取,拷贝 到用户缓冲区中,最后进程B再从用户级缓冲区中拷贝 读取出来。
    需要拷贝四次
  • 对于共享内存:进程A直接将数据拷贝 写入到共享内存中,进程B直接将数据拷贝 读取出来,
    只需两次拷贝

但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。

共享内存的数据结构

原理部分我们说过,操作系统会将所有共享内存进行统一管理。也就是说内核中一定存在一个结构体,其中包含共享内存的所有属性。

描述共享内存的结构体如下:

cpp 复制代码
struct shmid_ds {
	struct ipc_perm     shm_perm;   /* operation perms */
	int         shm_segsz;  /* size of segment (bytes) */
	__kernel_time_t     shm_atime;  /* last attach time */
	__kernel_time_t     shm_dtime;  /* last detach time */
	__kernel_time_t     shm_ctime;  /* last change time */
	__kernel_ipc_pid_t  shm_cpid;   /* pid of creator */
	__kernel_ipc_pid_t  shm_lpid;   /* pid of last operator */
	unsigned short      shm_nattch; /* no. of current attaches */
	unsigned short      shm_unused; /* compatibility */
	void            *shm_unused2;   /* ditto - used by DIPC */
	void            *shm_unused3;   /* unused */
};

可以看到上面共享内存数据结构的第一个成员是shm_permshm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:

cpp 复制代码
struct ipc_perm{
	__kernel_key_t  key;
	__kernel_uid_t  uid;
	__kernel_gid_t  gid;
	__kernel_uid_t  cuid;
	__kernel_gid_t  cgid;
	__kernel_mode_t mode;
	unsigned short  seq;
};

System V消息队列

消息队列原理

让操作系统在系统中创建一个队列,队列的每个结点都是一个数据块,这个数据块由类型的信息两部分组成,确保两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型。

如图:

注意

  • 系统中可能存在大量消息队列,OS会将它们统一管理起来:先描述,再组织
  • 如何确保进程间看到的是同一个队列?
    和共享内存一样,描述属性的结构体中会维护一个key
  • 和共享内存一样,消息队列的资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的。
  • 每个数据块都有一个类型,接收者进程接收的数据块可以有不同的类型值。
消息队列的数据结构

描述消息队列的结构体如下:

cpp 复制代码
struct msqid_ds {
	struct ipc_perm msg_perm;
	struct msg *msg_first;      /* first message on queue,unused  */
	struct msg *msg_last;       /* last message in queue,unused */
	__kernel_time_t msg_stime;  /* last msgsnd time */
	__kernel_time_t msg_rtime;  /* last msgrcv time */
	__kernel_time_t msg_ctime;  /* last change time */
	unsigned long  msg_lcbytes; /* Reuse junk fields for 32 bit */
	unsigned long  msg_lqbytes; /* ditto */
	unsigned short msg_cbytes;  /* current number of bytes on queue */
	unsigned short msg_qnum;    /* number of messages in queue */
	unsigned short msg_qbytes;  /* max number of bytes on queue */
	__kernel_ipc_pid_t msg_lspid;   /* pid of last msgsnd */
	__kernel_ipc_pid_t msg_lrpid;   /* last receive pid */
};

可以看到消息队列数据结构的第一个成员是msg_perm,它和shm_perm是同一个类型的结构体变量,ipc_perm结构体的定义如下:

cpp 复制代码
struct ipc_perm{
	__kernel_key_t  key;
	__kernel_uid_t  uid;
	__kernel_gid_t  gid;
	__kernel_uid_t  cuid;
	__kernel_gid_t  cgid;
	__kernel_mode_t mode;
	unsigned short  seq;
};
命名查看消息队列信息和删除消息队列
  • ipcs -q :查看消息队列
  • ipcrm -q:删除释放消息队列

System V信号量

共享内存和消息队列都没有同步互斥的功能,也就是没有任何保护机制,这合理吗?

其实信号量就是协同它们同步互斥的。

进程互斥(锁)
  1. 当我们两进程看到同一份资源时,如果没有保护机制,会导致数据不一致问题。

什么是数据不一致问题?

当一个进程在写入时,写了一半就被另一个进程拿走了,导致双方发送和收取的数据不完整

  1. 对于进程间通信,任何时刻,只允许一个执行流(进程)访问共享资源 ,这就是进程互斥
  2. 任何时刻,一份共享资源只允许一个执行流(进程)访问这个共享资源就是临界资源(一般是内存空间)。
  3. 在我们进行通信的代码中,只有一小部分(比如100行代码只有5~10行)是在访问临界资源的。对于这些代码,我们称之为临界区。
理解信号量

信号量的本质其实就是一个计数器,类似于我们程序中常用的int cnt,它的作用是:描述临界资源中剩余资源数量多少

对于临界资源,其内部的资源可以被划分许多的小资源。这样的话,当执行流需要资源,就给它分配小资源

这里我们最担心的问题是什么?怕多个执行流返回一个资源!如果只有n个小资源,存在n+1个执行流,这个问题就必然发生了。

此时,我们的信号量闪亮登场,它会记录临界资源中的剩余的小资源数量:

  • 当数量大于0时,允许执行流在临界资源的访问申请。
  • 当数量为0时,会拒绝执行流在临界资源的访问申请。

    注意
  • 执行流得到信号量允许,此时临界资源还并没有被执行流访问,向信号量申请资源的访问权限是对资源的预定机制。(好比我们图书馆线上预约座位)。
  • 信号量可以有效保证进入共享资源的执行流的数量
  • 因此每一个执行流,想要访问共享资源中的一部分时,不是直接访问,而是先向计数器(信号量)申请访问资源的权限。(好比我们去看电影得先买票)

如果临界资源没有划分,只有一份资源呢?

此时我们只需要一个值为1的计数器,每次访问共享资源,只有一个执行流,这就是互斥。我们把值只能为1和0的两态计数器叫做二元信号量(本质就是一个互斥功能(锁))。

要想访问临界资源,必须要向信号量申请,那信号量本身也是临界资源,那信号量怎么维护自己的安全呢??

  • 当执行流向信号量申请资源成功时,本质是对信号量--,这个操作被称为P操作。
  • 当执行不再访问资源时,会释放资源,释放信号量,本质是对信号量++,这个操作被称为V操作。

然而信号量的P操作和V操作都是原子的

原子操作 :要么不做,要做就是做完了(两态的),没有"正在做"的情况!从计算机技术上来定义就是:操作只有一条汇编语句的操作就是原子操作

为什么这样定义?因为进程随时会被替换,如果有多条汇编语句的话,操作执行到一半进程被替换,该操作就被堵塞了,导致存在"正在做"的情况。

注意:信号量的++和--并不是像上图一样简单的对整数++和--(这可不是原子操作,转为汇编语言会有至少3条语句,),具体如何操作后续多线程我会讲。

信号量本身的操作都是原子性的话,那信号量本身就是安全的。

信号量的数据结构

信号量同共享内存和消息队列一样,也被操作系统统一管理。

描述信号量属性结构体如下:

cpp 复制代码
struct semid_ds {
	struct ipc_perm sem_perm;       /* permissions .. see ipc.h */
	__kernel_time_t sem_otime;      /* last semop time */
	__kernel_time_t sem_ctime;      /* last change time */
	struct sem  *sem_base;      /* ptr to first semaphore in array */
	struct sem_queue *sem_pending;      /* pending operations to be processed */
	struct sem_queue **sem_pending_last;    /* last pending operation */
	struct sem_undo *undo;          /* undo requests on this array */
	unsigned short  sem_nsems;      /* no. of semaphores in array */
};

信号量数据结构的第一个成员也是ipc_perm类型的结构体变量,ipc_perm结构体的定义如下:

cpp 复制代码
struct ipc_perm{
	__kernel_key_t  key;
	__kernel_uid_t  uid;
	__kernel_gid_t  gid;
	__kernel_uid_t  cuid;
	__kernel_gid_t  cgid;
	__kernel_mode_t mode;
	unsigned short  seq;
};

也有key值。

信号量为什么属于是进程间通信?

信号量与我们前面学习的几个通信方式差异很大,它的作用是帮助其他System V IPC 维护通信安全的。

其实:

  • 进程间通信不仅仅只是互相通信数据,互相协同也属于
  • 协同的本质其实就是通信,因为信号量本质也是一个共享资源,可以让所有通信的进程看到。

最后提一嘴:信号量与信号没有半毛钱关系。

下篇预告:Linux信号

相关推荐
紫荆鱼3 小时前
设计模式-备忘录模式(Memento)
c++·后端·设计模式·备忘录模式
egoist20233 小时前
[linux仓库]打开线程的“房产证”:地址空间规划与分页式管理详解[线程·壹]
linux·页表·地址空间·分页式存储管理·缺页异常
Wind哥4 小时前
VS Code搭建C/C++开发调试环境-Windows
c语言·开发语言·c++·visual studio code
Dreamboat-L4 小时前
从零开始在云服务器上部署Gitlab
运维·服务器·gitlab
Skrrapper4 小时前
【C++】C++ 中的 map
开发语言·c++
喵了meme4 小时前
Linux学习日记6:文件IO与标准IO
linux·c语言·学习
m0_748233645 小时前
【C++list】底层结构、迭代器核心原理与常用接口实现全解析
c++·windows·list
qq_310658515 小时前
webrtc代码走读(八)-QOS-FEC-flexfec rfc8627
网络·c++·webrtc
惊讶的猫6 小时前
c++基础
开发语言·c++