【Linux】进程间通信

🌇个人主页平凡的小苏
📚学习格言:命运给你一个低的起点,是想看你精彩的翻盘,而不是让你自甘堕落,脚下的路虽然难走,但我还能走,比起向阳而生,我更想尝试逆风翻盘。
🛸C++专栏:Linux内功修炼
家人们更新不易,你们的👍点赞👍和⭐关注⭐真的对我真重要,各位路 过的友友麻烦多多点赞关注。欢迎你们的私信提问,感谢你们的转发! 关注我,关注我,关注我,你们将会看到更多的优质内容!!

在这里插入图片描述

一、进程间通信介绍

1、进程间通信的概念

进程间通信(IPC,Interprocess communication)是一组编程接口,让程序员能够协调不同的进程,使之能在一个操作系统里同时运行,并相互传递、交换信息。 这使得一个程序能够在同一时间里处理许多用户的要求。 因为即使只有一个用户发出要求,也可能导致一个操作系统中多个进程的运行,进程之间必须互相通话。

进程间通信的目的:

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

2、进程间通信的本质

进程间通信的本质:让不同的进程看到同一份资源(内存,文件,内核缓冲等)

  • 由于各个运行进程之间具有独立性,这个独立性主要体现在数据层面,而代码逻辑层面可以私有也可以公有(例如父子进程),因此各个进程之间要实现通信是非常困难的。

  • 各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。

在这里插入图片描述

因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。

由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。

3、进程间通信的分类

管道

  • 匿名管道pipe
  • 命名管道

System V IPC

  • System V 消息队列
  • System V 共享内存
  • System V 信号量

POSIX IPC

  • 消息队列
  • 共享内存
  • 信号量
  • 互斥量
  • 条件变量
  • 读写锁

二、管道

通信之前,要让不同的进程看到同一份资源(文件,内存块)。我下面谈的进程间通信,不是告诉我们如何通信,而是如何让这两个进程先看到同一份资源。因为资源的不同,会决定不同种类的通信方式,而管道,是提供共享资源的一种手段。

1、什么是管道

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

  • 例如我们统计当前使用云服务器上的登录用户个数:

在这里插入图片描述

其中,who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。

在这里插入图片描述

  • 注意:who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l用于统计当前的行数。

现实中的管道是单向的,并且是传输资源的。进程间通信中的管道也是单向的,并且是传输数据的。当要写入的数据量不大于PIPE_BUF(一般是4096字节)时,linux将保证写入的原子性。当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

2、匿名管道

2.1、匿名管道的原理

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

在这里插入图片描述

解释上图:

  • 我们都清楚一个进程会维护一个文件指针数组,默认打开0,1,2。磁盘要把信息加载到内存上struct file这个结构体里,这里面包含了文件的所有属性,有操作方法和file自己的内部缓冲区,当打开一个文件的时候,进程给它分配文件描述符为3,并指向此struct file。
  • 此时fork创建子进程,struct file文件不会被拷贝一份,因为创建进程和文件没有关系,而files_struct会被拷贝,因为它属于进程,并拷贝了文件描述符的内容,曾经父进程所打开的文件,子进程也拷贝下来了此映射关系,此时父子进程指向同一份文件,这个文件就是这俩进程的同一份资源,所以我们设计的时候,是普通文件就往磁盘上写,如果是管道文件,就直接往缓冲区写,不要往磁盘上刷新了。

2.2、pipe函数

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

int pipe(int pipefd[2]);

pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符:

在这里插入图片描述

pipe函数调用成功时返回0,调用失败返回-1.

2.3、匿名管道的使用步骤

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

1、父进程调用pipe函数创建管道

在这里插入图片描述

2、父进程fork创建子进程

在这里插入图片描述

3、父进程关闭写端,子进程关闭读端

在这里插入图片描述

注意:

  1. 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
  2. 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。

问1:为什么父进程要用两个文件描述符分别打开读端和写端

  • 为了让子进程继承,让子进程不用再打开读和写了。

问2:为什么父子要关闭对应的读写端

  • 管道必须是单向通信的。

问3:谁来决定父子进程关闭什么读写?

  • 不是由管道本身决定的,而是由我们自己的需求决定的。

现在我们来实现一个管道

#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<cstring>
#include<string>
#include<ctime>
#include<sys/wait.h>
#include<sys/types.h>
using namespace std;

#define NUM 1024
void Writer(int wfd)
{
    string s = "hello, I am child";
    pid_t self = getpid();
    int number = 0;
    char buffer[NUM];
    while(true)
    {
        buffer[0] = 0;//字符串清空
        snprintf(buffer,sizeof buffer, "%s-%d-%d",s.c_str(),self,number++);
        // cout << buffer << endl;
        write(wfd,buffer,strlen(buffer));
        sleep(1);
    }
}

void Reader(int rfd)
{
    char buffer[NUM];
    while(true)
    {
        buffer[0] = 0;
        ssize_t s = read(rfd,buffer,sizeof(buffer) - 1);
        if(s > 0)
        {
            buffer[s] = 0;
            cout << "父进程收到消息,内容是:" << buffer << endl;
        }
        else if(s == 0)
        {
            cout << "子进程写完了,父进程我也退出啦" << endl;
            break;
        }
        else
        {
            //
        }
    }
}

int main()
{
    int pipefd[2] = {0};
    if(pipe(pipefd) != 0)
    {
        perror("fail:");
        return 1;
    }

    pid_t id = fork();
    if(id < 0)
    {
        cerr << "fork error" << endl;
        return 2;
    }
    else if(id == 0)
    {
        //子进程关闭读端
        close(pipefd[0]);

        Writer(pipefd[1]);
        close(pipefd[1]);
        exit(1);
    }

    //父进程关闭写端
    close(pipefd[1]);

    Reader(pipefd[0]);
    pid_t rid = waitpid(id,nullptr,0);
    close(pipefd[0]);
    return 0;
}

问1:当父进程关闭管道的写端,子进程是怎么知道父进程关了的?并且后面还把数据读完就关了呢?

  • 父进程创建了子进程之后,在文件对应的属性中有引用计数,表示有多少个指针指向改进程,因此如果引用计数为1,说明该进程只要一个人在用,因此只要这个人读完就代表文件结束了。

问2:父进程是每隔2秒sleep一次,那为什么子进程没有进行sleep,但读取节奏却和父进程一样呢?

  • 因为当父进程没有写入数据的时候,子进程在等待。所以,当父进程写入之后,子进程才能read(会返回)到数据,子进程打印读取数据要以父进程的节奏为主。

因此,父进程和子进程在读写的时候,是有一定的顺序性的。

  • 管道内部,没有数据的时候,reader就必须阻塞等待(将当前进程的take_struct放入等待队列中),等待管道有数据;如果数据被写满,writer就必须阻塞等待,等待管道中有空间。不过呢,在父子进程各自printf的时候(向显示器写入【显示器也是文件】),并没有什么顺序,因为缺乏访问控制。而管道内部是有顺序的,因为它自带访问控制机制,同步和互斥机制。

再来实现一个进程控制,让父进程通过管道控制子进程,让子进程去做事情

#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<cstring>
#include<string>
#include<ctime>
#include<sys/wait.h>
#include<sys/types.h>
#include <vector>
#include <cassert>
#include "Task.hpp"

using namespace std;

const int processnum = 5;
vector<task_t>tasks;

struct channel
{
public:
    channel(int cmdfd, int slaverid, const std:: string& processname)
        :_cmdfd(cmdfd),_slaverid(),_processname(processname)
    {}
public:
    int _cmdfd;  //发送任务的文件描述符
    pid_t _slaverid; //子进程的PID
    std::string _processname;  //子进程的名字
};

void slaver()
{
    while(true)
    {
        int cmdcode = 0;
        int n = read(0,&cmdcode,sizeof cmdcode);
        if(n == sizeof(cmdcode))
        {
            cout << "slaver a command: " << getpid() << " : cmdcode: " << cmdcode  << endl;
            if(cmdcode < 0 || cmdcode > tasks.size()) continue;
            tasks[cmdcode]();
        }
        if(n == 0) break;
    }
}

void InitProcess(vector<channel>* channels)
{
    vector<int>oldfd;
    //初始化
    for(int i = 0; i < processnum; i++)
    {
        int pipefd[2];
        int n = pipe(pipefd);
        assert(!n);
        (void)n;

        pid_t id = fork();
        if(id == 0)
        {
            for(auto fd : oldfd) close(fd);
            close(pipefd[1]);
            // slaver(pipefd[0]);
            dup2(pipefd[0],0);
            close(pipefd[0]);
            slaver();
            cout  << "process : " << getpid() << "quit!" << endl;
            exit(0);
        }

        close(pipefd[0]);

        //添加channel字段
        std::string name = "process-" + std::to_string(i);
        channels->push_back(channel(pipefd[1],id,name));
        oldfd.push_back(pipefd[1]);
    }
}
void debug(const vector<channel>& channels)
{
    for(auto &t : channels)
    {
        cout << t._cmdfd << " " << t._slaverid << " " << t._processname << endl;
    }
}

void CtrlProcess(vector<channel>& channels)
{
    int which = 0;
    int cnt = 0;
    //控制子进程
    while(cnt < 5)
    {
        //选择任务
        int cmdcode = rand() % tasks.size();
        //选择进程
        // int processpos = rand() % channels.size();

        //发送任务
        write(channels[which]._cmdfd,&cmdcode,sizeof(cmdcode));
        
        which++;
        which %= channels.size();
        cnt++;
        sleep(1);
    }
}
void QuitProcess(vector<channel>& channels)
{
    for(const auto &c : channels){
        close(c._cmdfd);
        waitpid(c._slaverid, nullptr, 0);
    }
}

int main()
{
    LoadTask(&tasks);
    srand(time(nullptr)^ getpid() * 1023);//种随机数种子
    vector<channel> channels;
    InitProcess(&channels);
    // debug(channels);
    CtrlProcess(channels);
    QuitProcess(channels);
    return 0;
}
#pragma once
#include <iostream>
#include <functional>
#include <vector>
using namespace std;
typedef void (*task_t)();
void task1()
{
    cout << "lol 刷新日志" << endl;
}
void task2()
{
    cout << "lol 更新野区,刷新出来野怪" << endl;
}
void task3()
{
    cout << "lol 检查日志" << endl;
}
void task4()
{
    cout << "lol 释放技能" << endl;
}

void LoadTask(vector<task_t>* tasks)
{
    tasks->push_back(task1);
    tasks->push_back(task2);
    tasks->push_back(task3);
    tasks->push_back(task4);
}

上述操作实现了让父进程去控制一个进程,那么如何让父进程控制一批进程呢?

  • 假设现在想让父进程控制3个进程,那么我可以创建三个管道,让每一个进程都和父进程建立管道,父进程向指派进程1,那就往进程1的管道去写,向指派进程2,就往进程2的管道去写……。此时我的主进程就相当于分配业务的进程,这些子进程就相当于是执行业务的进程。我们基于上述的策略其实就是利用的进程池。

在这里插入图片描述

问:我曾经在命令行中写的 | 管道是什么意思呢?

  • 如上我通过 | 链接形成了10000和2000这俩进程,并写了一段监控脚本。通过观察可以看到,这俩进程的PID各不相同,但是PPID是相同的,所以这俩进程是兄弟关系。此时这俩进程指向的读端和写端是相同的,因此可以让父进程不再进行操作,就从父子通信转换成了兄弟通信,就相当于两个子进程共享了一个管道。综上,命令行中的 | 就是一种匿名管道。

2.4、管道的读写规则

1、当没有数据可读时

  • O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
  • O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。

2、当管道满的时候

  • O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
  • O_NONBLOCK enable:调用返回-1,errno值为EAGAIN

3、如果所有管道写端对应的文件描述符被关闭,则read返回0

4、如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出

5、当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。

6、当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

2.5、匿名管道的特点

1、管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;常用于父子间通信,一个管道由一个进程创建,然后 该进程调用fork,此后父、子进程之间就可应用该管道。

2、管道只能单向通信(内核的实现所决定),是半双工的一种特殊情况

​ 在数据通信中,数据在线路上的传送方式可以分为以下三种:

  • 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
  • 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
  • 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。

管道是半双工的,数据只能向一个方向流动,需要双方通信时,要建立起两个管道:

3、管道自带同步互斥机制(pipe满,writer等;pipe空,reader等) —— 自带访问控制

我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。为了避免这些问题,内核会对管道操作进行同步与互斥:

  • 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
  • 互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。

实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。

4、管道的生命周期随进程

  • 管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。

5、管道是面向字节流的 —— 先写的字符,一定是先被读取的,没有格式边界,需要用户来定义区分内容的边界

3、命名管道

3.1、命名管道的原理

匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。

注意:

  1. 普通文件是很难做到通信的,即便做到通信也无法解决一些安全问题。
  2. 命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。

3.2、使用命令创建命名管道

我们可以使用mkfifo命令创建一个命名管道

在这里插入图片描述

使用如下:

在这里插入图片描述

如上可以看到,创建出来的文件类型是p,代表该文件是命名管道文件。

  • 使用这个命名管道文件,就能实现两个进程之间的通信了。我们在一个进程(进程A)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程B)当中用cat命令从命名管道当中进行读取。现象就是当进程A启动后,进程B会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信。

在这里插入图片描述

之前我们说过,当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉,在这里就可以很好的得到验证:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。

在这里插入图片描述

3.3、mkfifo创建命名管道

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

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

mkfifo的第一个参数pathname,表示要创建的命名管道文件。

  • 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。

  • 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义)

mkfifo的第二个参数mode,表示创建命名管道文件的默认权限。

mkfifo函数的返回值:

  • 命名管道创建成功,返回0。
  • 命名管道创建失败,返回-1。

3.4、用命名管道实现server & client间的通信

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

注意:如果一个进程已经把文件创建好了,那么另一个进程不需要创建这个文件了,直接用就可以了

server服务端代码如下:

#include <iostream>
#include "comm.hpp"
#include <unistd.h>

using namespace std;

int main()
{
    //创建管道
    int n = mkfifo(FIFO_FILE, MODE);
    if(n == -1)
    {
        perror("mkfifo:");
        exit(1);
    }
    //打开管道
    int fd = open(FIFO_FILE,O_RDONLY);
    if(fd < 0)
    {
        perror("open:");
        exit(3);
    }
    //开始通信
    while(true)
    {
        char buffer[1024] = {0};
        int x = read(fd,buffer,sizeof(buffer));
        if(x > 0)
        {
            buffer[x] = 0;
            cout << "client say# " << buffer << endl;
        }
        else if(x == 0) break;
        else break;
    }
    int m = unlink(FIFO_FILE);
    if(m == -1)
    {
        perror("unlink:");
        exit(2);
    }
    return 0;
}
  • 接着再将客户端也运行起来,此时我们从客户端写入的信息被客户端写入到命名管道当中,服务端再从命名管道当中将信息读取出来打印在服务端的显示器上,该现象说明服务端是能够通过命名管道获取到客户端发来的信息的,换句话说,此时这两个进程之间是能够通信的。
#include <iostream>
#include "comm.hpp"
#include <unistd.h>
using namespace std;

int main()
{
    int fd = open(FIFO_FILE,O_WRONLY);
    if(fd < 0)
    {
        perror("open:");
        exit(1);
    }
    string line;
    while(true)
    {
        cout << "Please enter@ ";
        cin >> line;;
        write(fd,line.c_str(),line.size());
    }
    close(fd);
    return 0;
}

comm.h头文件代码如下:

#pragma once 
#include <iostream>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

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

3.5、匿名管道和命名管道的区别

  • 匿名管道:子进程继承父进程。由pipe函数创建并打开。
  • 命名管道:通过一个fifo文件(有路径,具有唯一性),通过路径找到同一个资源。由mkfifo函数创建,由open函数打开。
  • FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。

4、system V共享内存

4.1、共享内存原理

共享内存让不同进程看到同一份资源的方式就是,通过一种接口在物理内存当中申请一块内存空间,然后通过此接口将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。

在这里插入图片描述

注意:
这里所说的开辟物理空间、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。所以操作系统需要提供具有如下功能的接口:

  • 创建共享内存 —— 删除共享内存(OS内部帮我们做)
  • 关联共享内存 —— 去关联共享内存(进程做,实际也是OS做)

4.2、共享内存的数据结构

在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。共享内存的数据结构如下:

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 */
};

4.3、共享内存函数

shmget创建共享内存

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

#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);

shmget函数的参数说明

  • key:表示待创建共享内存在系统中的唯一标识
  • size:表示带创建共享内存的大小(建议设置为页[4KB]的整数倍)
  • shmflg:表示创建共享内存的方式

shmget函数的返回值说明

  • shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)
  • shmget调用失败,返回-1

着重强调size

  • 上面说到size要设置为页(4KB)的整数倍,我们假设有4GB的空间,约等于2^20次方个页,对于这么多页,操作系统需要把共享内存上的这么多页管理起来,依旧是先描述,再组织,OS内部用数组的方式将页保存了起来(struct page mem[2^20])。

着重强调shmflg:

  • 当创建共享内存的时候,OS需要在物理内存中申请如上的物理页(struct page……)。在申请的时候可能会面临如下的问题:此共享内存该由谁创建呢?如果你创建好了,那我该怎么办?如果底层存在,我该怎么办?针对上述问题,就由shmget函数的第三个参数shmflg来解决:

shmflg有两个常见的选项(IPC_CREAT和IPC_EXCL)

组合方式作用
IPC_CREAT如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄。
IPC_CREAT|IPC_EXCLIPC_EXCL不能单独使用,必须和IPC_CREAT配合,如果不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回。

换句话说:

  • 使用组合IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
  • 使用组合IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。

着重强调key

  • 共享内存是存在内核中的,内核会给我们维护共享内存的结构。共享内存需要被管理,依旧是先描述,再组织。于是乎就和我们上文谈到的共享内存的数据结构串联起来了,此结构就是struct shmid_ds[ ],里面维护了各种属性:
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_perm,shm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:
struct ipc_perm {
	key_t          __key;    /* Key supplied to shmget(2) */
	uid_t          uid;      /* Effective UID of owner */
	gid_t          gid;      /* Effective GID of owner */
	uid_t          cuid;     /* Effective UID of creator */
	gid_t          cgid;     /* Effective GID of creator */
	unsigned short mode;     /* Permissions + SHM_DEST and SHM_LOCKED flags */
	unsigned short __seq;    /* Sequence number */
};
  • ipc_perm结构体的第一个成员key就是标识共享内存唯一性的方法,这个key一般由用户提供。综上,我们是否知道共享内存存在与否就取决于这个key方法。

问:为什么此key值得由用户提供呢?

假设通信的进程为client和server进程,如果key值由用户提供,那么server就可以提供一个key值让操作系统帮他创建一个进程,并约定好让client也使用同样的key值,访问此共享内存。进程间通信的前提是让不同的进程看到同一份资源。综上:共享内存在内核中,想让不同的进程看到同一份共享内存,做法就是让他们拥有同一个key即可。

那么我们如何能拥有与之前不同的key值呢,这就需要用到 ftok 函数来获取key值。ftok函数的原型如下:

#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char* pathname, int proj_id);

ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。

注意

  • 使用ftok函数生成key值可能会产生冲突,此时可以对传入ftok函数的参数进行修改。
  • 需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。

至此我们就可以使用ftok和shmget函数创建一块共享内存了,创建后我们可以将共享内存的key值和句柄进行打印,以便观察,代码如下:

shmctl释放共享内存

此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。

法一:使用命令释放共享内存

在Linux中,我们可以使用 ipcs 命令查看有关进程间通信设施的信息。

在这里插入图片描述

单独使用 ipcs 命令时,会默认列出消息队列,共享内存以及信号量相关的信息,若指向查看他们之间某一个的相关信息,可以选择携带以下选项:

  • -q:列出消息队列相关信息。
  • -m:列出共享内存相关信息。
  • -s:列出信号量相关信息。

此时,根据ipcs命令的查看结果和我们的输出结果可以确认,共享内存已经创建成功了。ipcs命令输出的每列信息的含义如下:

在这里插入图片描述

在共享内存列表中的perms代表的是该共享内存的权限,这个是可以修改的:

int shmid = shmget(key, MEM_SIZE, flags | 0666);

再创建时,| 0666就更改权限为666了:

在这里插入图片描述

  • 注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系类似于fd和FILE*之间的的关系。

如果我们想要显示的删除,就使用ipcrm -m shmid

ipcrm -m 6

法二:使用系统接口shmctl删除共享内存

控制共享内存我们需要使用shmctl函数,shmctl函数的函数原型如下:

#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

shmctl函数的参数说明:

  • shmid:表示所控制共享内存的用户级标识符
  • cmd:表示具体的控制动作
  • buf:用于获取或设置所控制共享内存的数据结构

shmctl函数的返回值说明:

  • shmctl调用成功,返回0
  • shmctl调用失败,返回-1

其中,作为shmctl函数的第二个参数cmd传入的常用的选项有以下三个:

在这里插入图片描述

shmat关联共享内存

将共享内存连接到进程地址空间我们需要用shmat函数,shmat函数的原型如下:

#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);

shmat函数的参数说明:

  • shmid:表示待关联共享内存的用户级标识符
  • shmaddr:指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置
  • shmflg:表示关联共享内存时设置的某些属性

shmat函数的返回值说明:

  • shmat调用成功,返回共享内存映射到进程地址空间中的起始地址
  • shmat调用失败,返回(void*)-1

其中,作为shmat函数的第三个参数shmflg传入的常用的选项有以下三个:

在这里插入图片描述

shmdt去关联共享内存

取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:

#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);

shmdt函数的参数说明:

  • shmaddr:待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址(shmat函数的返回值)

shmdt函数的返回值说明:

  • shmdt调用成功,返回0
  • shmdt调用失败,返回-1

4.4、共享内存与管道进行对比

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

write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。

我们先来看看管道通信:

在这里插入图片描述

从这张图可以看出,使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:

  1. 服务端将信息从输入文件复制到服务端的临时缓冲区中
  2. 将服务端临时缓冲区的信息复制到管道中
  3. 客户端将信息从管道复制到客户端的缓冲区中
  4. 将客户端临时缓冲区的信息复制到输出文件中

再来看看共享内存通信:

在这里插入图片描述

从这张图可以看出,使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作:

  1. 从输入文件到共享内存。
  2. 从共享内存到输出文件。

所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。

  • 50
    点赞
  • 41
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 24
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 24
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

平凡的小苏

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

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

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

打赏作者

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

抵扣说明:

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

余额充值