Linux基础——进程间的通信

目录

1. 进程间的通信是什么?

2. 为什么需要进程间的通信?

3. 进程间的通信是怎么做的?

① 管道

1. 管道的原理

2. 管道的接口

3. 管道的特征

4. 管道的4种情况

5. 管道的应用场景

6. 命名管道

② system V共享内存

1. 共享内存的原理

2. 共享内存的接口

3. 共享内存的特性

4. 共享内存的扩展 

1. 查看共享内存属性

2. 解决没有同步机制的问题

③ 消息队列 && 信号量

1. 消息队列的原理

2. 消息队列的接口

3. 信号量的接口

4. 如何理解信号量

④补充


1. 进程间的通信是什么?

进程间的通信通俗点来说,就是 两个或多个进程实现数据层面的交互 。但是由于进程具有独立性,导致进程间通信的成本比较高。这就是我们常说的,进程通信是有成本的

2. 为什么需要进程间的通信?

其实在实际操作中,有很多东西都必须要互相之间通信起来,举几个例子:

1. 基本数据

2. 发送命令

3. 互相之间进行某种协同

4. 通知

3. 进程间的通信是怎么做的?

让我们综述一下,即

1. 进程通信的本质:必须要让不同的进程看到同一份“资源”;

2. 这个“资源”是什么?——它是一个特定形式的内存空间,如图

3. 这个“资源”是由谁来提供的?——一般来说是操作系统,那么为什么不能是进程来提供呢?——假设某一个进程来提供,这个资源肯定由这个进程所独享。如果不是这样的话,就破坏了进程的独立性。

4. 进程访问这个共享空间进行通信,从本质上来说就是访问操作系统。而进程代表用户,“资源”从创建到使用再到释放,我们都需要使用系统调用接口才行!

5. 基于文件级别的通信方式——管道

注:不论是从底层设计来说,还是从接口设计来说,都应该由OS独立设计!对于一般的OS来说,都会有一个独立的通信模板——其隶属于文件系统,我们将其称为IPC通信模块;此外,进程间的通信是有标准的——即 system && posix。其中system 是本机内部的标准,posix是网络中的标准。

① 管道

其实在此之前我们已经或多或少的使用过管道,举个例子

who | wc -l

其中 who 表示显示当前登录系统的用户,wc -l 表示计算字数(word count), 带上 -l 选项表示计算行数,我们可以通过画图来理解上面的结果,如图

1. 管道的原理

我们还是画图来理解管道的原理,如图

进程间进行交互的前提就是需要先让不同的进程看到同一份资源!因此,对于绿色箭头所指部分,父子进程都能看见它,即完成了进程间的通信!那么有没有不依靠磁盘的file文件呢?——内存级文件!我们要学习的管道就是一种内存级文件。但是这只是实现进行通信的理论基础,要实际建立管道还需要进行下面几步,即

这就是管道通信的基本原理!那如果我们想让父子进程实现双向管道呢?——使用多个管道即可。需要注意的是:要使用管道,两个进程间必须是父子关系,兄弟关系或者爷孙关系,即进程间要有血缘关系,常用于父子。

说了这么多知识,我们开始进行通信了吗?——并没有,只是建立了通信信道,那么为什么这么费劲呢?——进程具有独立性,进程间通信是有成本的!

2. 管道的接口

说完了管道的原理,我们再来谈谈管道的接口 pipe ,我们查看手册有

简单翻译一下

pipe () 创建一个管道,这是一个可以用于进程间通信的单向数据通道。数组 pipefd 用于返回指向管道两端的两个文件描述符。pipefd [0] 指向管道的读取端。pipefd [1] 指向管道的写入端。写入管道写入端的数据由内核缓冲,直到从管道读取端读取数据。

 

如果 flags 为 0,那么 pipe2 () 与 pipe () 相同。可以在 flags 中按位或(bitwise OR)以下值以获得不同的行为:

 

O_NONBLOCK 在两个新的打开文件描述符上设置 O_NONBLOCK 文件状态标志。使用此标志可以避免通过额外调用 fcntl (2) 来实现相同的结果。

 

O_CLOEXEC 在两个新的文件描述符上设置 close-on-exec (FD_CLOEXEC) 标志。有关此标志可能有用的原因,请参见 open (2) 中对同一标志的描述。

 其中,pipefd[2] 是一个输出型参数,它的作用是将文件的文件描述符数字带出来,让用户使用,比如:3,4,其中pipefd[0] 表示读下标(3),pipefd[1] 表示写下标(4)。我们可以使用代码来验证,即

#include <unistd.h>
#include <iostream>

#define N 2

using namespace std;

int main()
{
    int pipefd[N] = {0};
    int n = pipe(pipefd);
    if (n < 0) return 1;

    cout << "pipefd[0]: " << pipefd[0] << "  pipefd[1]: " << pipefd[1]; 

    return 0;
}

运行有

下面我们就用代码来模拟一下上面的管道的建立过程,即

#include <unistd.h>
#include <iostream>

#define N 2

using namespace std;

int main()
{
    int pipefd[N] = {0};
    int n = pipe(pipefd);
    if (n < 0) return 1;

    pid_t id = fork();
    if (id < 0) return 2;

    // child
    if (id == 0) 
    {
        close(pipefd[0]);

        // IPC code
        Writer(pipefd[1]);

        close(pipefd[1]);
        exit(0);
    }
    // father
    close(pipefd[1]);

    // IPC code
    Reader(pipefd[0]);

    close(pipefd[0]);

    return 0;
}

这就是我们对管道的基础建立的基础代码,其中Writer函数与Reader函数分别表示建立好通信管道后要进行的写与读事件。接下来我们来举例完成它们,即

#define SIZE 1024
// child IPC code
void Writer(int wfd)
{
    string s = "hello, this is child!";
    pid_t selfid = getpid();
    int number = 0;

    char buffer[SIZE];
    while (true)
    {
        buffer[0] = '\0'; // 提醒阅读者将buffer视为字符串
        // 构建发送字符串
        snprintf(buffer, sizeof(buffer), "%s-%d-%d\n", s.c_str(), selfid, number++);
        // 将内容发送给父进程
        write(wfd, buffer, strlen(buffer));

        sleep(1);
    }
}
// father IPC code
void Reader(int rfd)
{
    char buffer[SIZE];

    while (true)
    {
        buffer[0] = '\0';
        ssize_t n = read(rfd, buffer, sizeof(buffer));
        if (n > 0)
        {
            buffer[n] = '\0';
            cout << getpid() << " father recieve a message: " << buffer;
        }
    }
}

我们运行有

接下来我们再加入进程等待,用以防止出现僵尸进程等情况,即

#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <iostream>
#include <string>
#include <cstring>

#define N 2

using namespace std;

#define SIZE 1024
// child IPC code
void Writer(int wfd)
{
    string s = "hello, this is child!";
    pid_t selfid = getpid();
    int number = 0;

    char buffer[SIZE];
    while (true)
    {
        buffer[0] = '\0'; // 提醒阅读者将buffer视为字符串
        // // 构建发送字符串
        // snprintf(buffer, sizeof(buffer), "%s-%d-%d\n", s.c_str(), selfid, number++);
        // // 将内容发送给父进程
        // write(wfd, buffer, strlen(buffer));

        // sleep(1);

        char ch = 'a';
        write(wfd, &ch, 1);
        number++;
        cout << number << endl;
    }
}

// father IPC code 等待5s关闭
void Reader(int rfd)
{
    char buffer[SIZE];

    int num = 5;
    while (true)
    {
        buffer[0] = '\0';
        ssize_t n = read(rfd, buffer, sizeof(buffer));
        if (n > 0)
        {
            buffer[n] = '\0';
            cout << getpid() << " father recieve a message: " << buffer;
        }

        sleep(1);
        num--;
        if (num == 0) 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;

    // child
    if (id == 0) 
    {
        close(pipefd[0]);

        // IPC code
        Writer(pipefd[1]);

        close(pipefd[1]);
        exit(0);
    }
    // father
    close(pipefd[1]);

    // IPC code
    Reader(pipefd[0]);
    close(pipefd[0]);
    cout << "father has already closed readfd : " << pipefd[0] << endl;
    sleep(5); // 观察僵尸情况

    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid < 0) return 3;

    cout << "wait child success! waitid : " << rid << " exit code : " << ((status>>8)&0xFF) << " exit signal : " << (status&0x7F) << endl;

    sleep(5);

    cout << "father quit!" << endl;

    return 0;
}

我们运行有

 

3. 管道的特征

讲完了管道的原理与接口,让我们总结一下管道的特征

1. 只能在具有血缘关系的进程之间进行进程间通信;

2. 只能单向通信;

3. 父子进程是会进行进程协同的(即同步与互斥), 这样也就保护了管道文件的数据安全。

4. 管道是面向字节流的!举个例子,自来水管只负责向外流出水,不在意其他的。

5. 管道是基于文件的,而文件的生命周期是跟随进程的!

5. 管道是有固定大小的!——我们可以使用ulimit -a来查看,即

6. 管道具有单原子性,举个例子,对于一个pipe_buf(内容为 hello world)来说,我们是不能单独的把hello读出来的。 

4. 管道的4种情况

 由于管道是单向的,那么对于管道的两端就会出现四种情况,即

1. 读端与写端均正常,若管道为空,读端阻塞

2. 读写端均正常,若管道被写满,写端阻塞

3. 读端正常,写端关闭,读端会读到0,表明读到了文件(pipe)的结尾,不会阻塞

4. 写端正常,读端关闭,此时这是一个无意义的动作,因此OS会将正在进行写入的进程杀掉!即杀掉子进程,那么我们如何杀掉呢?——信号。

5. 管道的应用场景

接下来我们使用管道来实现一个简易进程池,我们先讲讲什么是池

我们首先需要知道,系统调用本身也是有成本的!因此,我们可以预先一次性申请好大量的对应资源,我们将这种行为称为池化。举几个通俗的例子,如果一个有水源的地方离我们很远,那我们一般不会采取一次只打一桶水的措施,而是一次打一池子水来满足我们的长期要求。

接下来讲讲我们要做的进程池原理

整体流程为:父进程接收到任务后,自行决定将任务下发给哪个子进程。为此,绿色部分的任务就是决定哪个任务由哪个进程来执行。因此,我们可以对绿色部分进行一个先描述后组织,通过子进程PID与任务码来确定执行关系,框架代码如下

#include "Task.hpp"
#include <unistd.h>
#include <assert.h>
#include <cstring>
#include <string>
#include <iostream>
#include <vector>

const int processnum = 4;

// 先描述
struct channel
{
    channel(int cmdfd, pid_t slaverid, std::string processname)
        :_cmdfd(cmdfd), _slaverid(slaverid), _processname(processname)
    {}

    int _cmdfd;               // 发送文件的文件描述符
    pid_t _slaverid;          // 执行进程的id
    std::string _processname; // 执行进程的名字——便于打印日志
};

// 交由子进程执行任务
void ctrlSlaver(const std::vector<channel>& channels)
{}

// 初始化
void InitProcessPool(std::vector<channel>* channels)
{}

// 退出
void ProcessQuit(const std::vector<channel>& channels)
{}

int main()
{
    // 再组织
    std::vector<channel> channels;

    // 1. 初始化
    InitProcessPool(&channels); 

    // 2. 控制子进程
    ctrlSlaver(channels);

    // 3. 退出
    ProcessQuit(channels);

    return 0;
}

接下来我们将其补充完整

首先我们模拟出来一个任务头文件

#pragma once

#include <iostream>
#include <vector>

// 函数指针
typedef void (*task_t)();

void task1()
{
    std::cout << "更新日志" << std::endl;
}
void task2()s
{
    std::cout << "装填武器子弹" << std::endl;
}
void task3()
{
    std::cout << "检测软件是否更新,如果需要,就提示用户" << std::endl;
}
void task4()
{
    std::cout << "用户打出子弹,更新子弹数量" << std::endl;
}

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

然后是进程池

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

// 子进程数
const int processnum = 10;

// 任务列表
std::vector<task_t> tasks;

// 先描述
struct channel
{
    channel(int cmdfd, pid_t slaverid, std::string processname)
        :_cmdfd(cmdfd), _slaverid(slaverid), _processname(processname)
    {}

    int _cmdfd;               // 发送文件的文件描述符
    pid_t _slaverid;          // 执行进程的id
    std::string _processname; // 执行进程的名字——便于打印日志
};

void Slaver()
{
    while(true)
    {
        int cmdcode = 0;
        // 从 0(stdin) 处读取数据,需要使用重定向dup2,此时0是pipefd[0]的拷贝
        // 即本来是从键盘读,以后都改成从管道读
        int n = read(0, &cmdcode, sizeof(int)); // 若父进程没有子进程发送数据,则处于阻塞等待状态
        if(n == sizeof(int))
        {
            //执行cmdcode对应的任务列表
            std::cout << getpid() << " slaver get a command! " << "cmdcode: " <<  cmdcode << std::endl;
            if(cmdcode >= 0 && cmdcode < tasks.size()) tasks[cmdcode]();
            sleep(1);
        }
        if(n == 0) break;
    }
}

// 初始化
void InitProcessPool(std::vector<channel>* channels)
{
    // 使用oldfd保证每个子进程均只能有一个写端
    std::vector<int> oldfds;

    for (int i = 0; i < processnum; i++)
    {
        int pipefd[2];
        int n = pipe(pipefd);
        assert(!n);
        (void)n;

        pid_t id = fork();
        // child
        if (id == 0)
        {
            for (auto& fd : oldfds) close(fd);
            close(pipefd[1]);
            // 改变重定向,便 0(stdin) 成为pipefd[0]的拷贝
            dup2(pipefd[0], 0);
            close(pipefd[0]);
            Slaver();

            // 子进程退出提示
            std::cout << "process " << getpid() << ": quit" << std::endl;
            exit(0); 
        }

        // father
        close(pipefd[0]);

        // 将当前进程添加进channels中
        std::string processname = "process " + std::to_string(i);
        (*channels).push_back(channel(pipefd[1], getpid(), processname));
        oldfds.push_back(pipefd[1]);
    }
}

// 菜单目录
void Menu()
{
    std::cout << "#################################################" << std::endl;
    std::cout << "##############     1. 更新日志     ###############" << std::endl;
    std::cout << "###########      2. 装填武器子弹      #############" << std::endl;
    std::cout << "###   3. 检测软件是否更新,如果需要,就提示用户    ###" << std::endl;
    std::cout << "#####       4. 用户打出子弹,更新子弹数量       #####" << std::endl;
    std::cout << "##############       0. 退出       ###############" << std::endl;
    std::cout << "##################################################" << std::endl;
}

// 交由子进程执行任务
void ctrlSlaver(const std::vector<channel>& channels)
{
    int which = 0;
    while (true)
    {
        // 1. 选择任务
        // int cmdcode = rand()%30;
        int select = 0;
        sleep(2);
        Menu();
        std::cout << "请输入要执行的任务 :";
        std::cin >> select;

        if (select <= 0 || select >= 5) break;

        int cmdcode = select - 1;

        // 2. 选择进程
        // int processpos = rand()%channels.size();

        std::cout << "father say: " << " cmdcode: " <<
            cmdcode << " already sendto " << channels[which]._slaverid << " process name: " 
                << channels[which]._processname << std::endl;
        // 3. 发送任务
        write(channels[which]._cmdfd, &cmdcode, sizeof(cmdcode));

        which++;
        which %= channels.size();
    }
}

// 退出
void ProcessQuit(const std::vector<channel>& channels)
{
    for (const auto& c : channels)
    {
        close(c._cmdfd);
        waitpid(c._slaverid, nullptr, 0);
    }
}

int main()
{
    LoadTask(&tasks);

    // 再组织
    std::vector<channel> channels;

    // 种下随机数种子,便于后续使用随机数
    srand(time(nullptr) ^ getpid() ^ 2534);

    // 1. 初始化
    InitProcessPool(&channels); 

    std::cout << "Process Pool Init success!" << std::endl; 

    // 2. 开始控制子进程
    ctrlSlaver(channels);

    // 3. 退出
    ProcessQuit(channels);

    return 0;
}

6. 命名管道

前面我们说过,管道是让具有血缘关系的进程进行进程间通信,那么我们有没有办法让毫不相关的进程进行通信呢?——命名管道!

我们可以使用mkfifo来创建一个命名管道,即

mkfifo mypipe

这个p表示这个文件是一个命名管道。

之前我们提到过进程间通信的本质是让不同的进程看到同一份资源,那如果我们不想让文件刷盘到磁盘中,只想交给另一个进程怎么办呢?——使用内存级缓冲区即可,也就是上面的mypipe(内存级文件)。对于管道文件来说,是不需要对其进行刷盘的。

那对于两个进程来说,我怎么知道我们打开的是同一个文件呢?——确保打开的是同路径下的同一文件名即可,由于路径 + 文件名具有唯一性,因此我们可以用其创建命名管道,以提供通信信道。

接下来我们在代码中来使用一下它的接口,即

mkfifo() 函数用于创建一个名为 pathname 的 FIFO(先进先出)特殊文件。mode 参数指定了该 FIFO 文件的权限,这个权限会按照进程的 umask(用户文件创建模式屏蔽码)进行常规修改:所创建文件的权限是 (mode & ~umask)

FIFO 特殊文件类似于管道,但其创建方式不同。管道是一个匿名的通信通道,而 FIFO 特殊文件是通过调用 mkfifo() 函数在文件系统中创建的。

一旦以这种方式创建了 FIFO 特殊文件,任何进程都可以像打开普通文件一样打开它进行读取或写入。但是,在进行任何输入或输出操作之前,它必须同时在两端被打开。打开 FIFO 进行读取通常会阻塞,直到有其他进程打开同一个 FIFO 进行写入,反之亦然。有关 FIFO 特殊文件的非阻塞处理方式,请参阅 fifo(7)

unlink()函数用于从文件系统中删除一个名称(即文件或目录的链接)。如果这个名称是文件的最后一个链接,并且没有任何进程打开该文件,那么该文件将被删除,并且它所占用的空间将被释放以供重新使用。

如果这个名称是文件的最后一个链接,但仍有进程通过文件描述符打开了该文件,那么该文件将继续存在,直到指向它的最后一个文件描述符被关闭。

如果这个名称指向的是一个符号链接,那么该链接将被移除,但目标文件或目录本身不会受到影响。

如果这个名称指向的是一个套接字、FIFO(先进先出)特殊文件或设备文件,那么该名称将被删除,但是已经打开这些对象的进程仍然可以继续使用它们。

测试代码如下

#pragma once 

#include <sys/stat.h>
#include <sys/types.h>
#include <cstdio>
#include <unistd.h>
#include <stdlib.h>

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

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.cpp)

int main()
{
    Init pipe;

    // 打开信道
    int fd = open(FIFO_FILE, O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        exit(FIFO_OPEN_ERR);
    }

    // 开始通信
    while (true)
    {
        char buffer[1024] = {0};
        int n = read(fd, buffer, sizeof(buffer));
        if (n > 0)
        {
            buffer[n] = '\0';
            cout << "client : " << buffer << endl;
        }
    }

    close(fd);
    return 0;
}

客户端(client.cpp)

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

    string line;
    // 开始通信
    while (true)
    {
        cout << "Please enter : ";
        cin >> line;
        
        write(fd, line.c_str(), line.size());
    }

    close(fd);
    return 0;
}

 通信效果如下

我们还可以使用命名管道实现一个简易的日志(详见Linux应用——简易日志

② system V共享内存

1. 共享内存的原理

我们已经知道,进程通信的本质就是让不同的进程看到同一份资源,让我们来看看共享内存是如何做到的,如图

通过图示我们可以知道,AB进程通过首地址与页表的映射来访问同一空间。那如果我们想要释放共享内存的话,就需要 1. 先去关联(对应②)  2. 再释放共享内存(对应①)。上面这些操作都是进程来做的吗?——并不是,它们都是由OS直接释放的!既然这些共享内存由OS直接管理,那么OS肯定对其进行了先描述后组织!

2. 共享内存的接口

我们先来查看第一个接口

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

让我们好好讲一下这个接口

首先是这个返回值 int , 成功的话它会返回共享内存标识符;

然后是第一个参数 key_t key, 这是一个独特的数字,我们稍后再讲;

再然后是第二个参数 size_t size, 它表示创建共享内存的大小(单位为字节);

最后是第三个参数 int shmflag, 它表示创建与获取模式,举个例子

IPC_CREAT: 若申请之前不存在,直接创建;若存在,获取并返回。

IPC_CREAT | IPC_EXCL:  若申请之前不存在,直接创建;若存在,出错返回。

注:IPC_EXCL不能单独使用

测试代码如下

const int size = 4096;
const string pathname = "/home/tr";
const int proj_id = 0x6666;

Log lg;

key_t Getkey()
{
    key_t k = ftok(pathname.c_str(), proj_id);
    if (k < 0)
    {
        lg(Fatal, "ftok error : %s", strerror(errno));
        exit(1);
    }

    lg(Info, "ftok get cuccess! key : %d", k);
    return k;
}

int GetShareMemory(int flag)
{
    key_t k = Getkey();
    int shmid = shmget(k, size, flag);
    if (shmid < 0)
    {
        lg(Fatal, "creat share memory error : %s", strerror(errno));
        exit(2);
    }

    lg(Info, "creat share memory success! shmid : %d", shmid);
    return shmid;
}

// 根据不同的flag参数做不同的工作
int CreateShm()
{
    // 0666表示设置权限为666
    return GetShareMemory(IPC_CREAT | IPC_EXCL | 0666);
}

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

让我们来谈谈这个key_t key

1. key 是一个数字,这个数字并不重复,关键在于它必须在内核中具有唯一性,能让不同进程进行唯一性标识;

2. 第一个进程可以通过 key 创建 shm ,第二个及之后的进程只需要拿着同一个 key 就可以和第一个进程看到同一个共享内存了;

3. 对于一个已经创建好的 shm 来说, key 在哪?—— shm 的描述对象中!

4. 第一次创建 shm 的时候,必须要有一个 key ,它怎么来?——使用 ftok 函数( key_t ftok(const char *pathname, int proj_id) ),它是一套算法,用户自行传入路径与项目 id ,然后通过这两个参数进行数值计算,从而得出一个 key 。那么为什么不让 OS 来进行设计呢?——因为用户不好拿到 key 值。

5. 通过上面的 ftok 我们可以知道, key 类似于路径,具有唯一性!

6. key 可以在 OS 内标定唯一性,而 shmid 只在进程内标识资源的唯一性。

在我们创建好共享资源后,我们可以使用

ipcs -m

 来查看已创建的共享资源

注:共享内存的生命周期是随内核的,用户不主动关闭,共享内存会一直存在,除非内核重启(内存释放)。

接下来我们看第二个接口

shmat() 函数将由 shmid 标识的 System V 共享内存段附加到调用进程的地址空间中。附加地址由 shmaddr 指定,并遵循以下准则之一:

  • 如果 shmaddr 为 NULL,系统会选择一个合适的(未使用的)地址来附加该段。

  • 如果 shmaddr 不为 NULL 且在 shmflg 中指定了 SHM_RND,则附加操作发生在 shmaddr 向下舍入到最近的 SHMLBA(共享内存段对齐边界)倍数的地址上。否则,shmaddr 必须是一个页面对齐的地址,附加操作在该地址进行。

  • 如果在 shmflg 中指定了 SHM_RDONLY,则该段以只读方式附加,进程必须对该段具有读取权限。否则,该段以读写方式附加,进程必须对该段具有读取和写入权限。不存在只写的共享内存段概念。

  • (Linux 特有)可以在 shmflg 中指定 SHM_REMAP 标志,以指示段的映射应替换从 shmaddr 开始并持续段大小的范围内任何现有的映射。(通常,如果此地址范围内已存在映射,会导致 EINVAL 错误。)在这种情况下,shmaddr 不能为 NULL

  • 调用进程的 brk(2) 值不会因附加操作而改变。段将在进程退出时自动分离。相同的段可以作为读和读写两种模式多次附加到进程的地址空间中。

shmat() 调用成功时,会更新与共享内存段关联的 shmid_ds 结构(参见 shmctl(2))的成员,如下所示:

  • shm_atime 设置为当前时间。
  • shm_lpid 设置为调用进程的进程 ID。
  • shm_nattch 增加 1。

shmdt() 函数将从调用进程的地址空间中分离位于 shmaddr 指定的地址的共享内存段。要分离的段必须当前以等于 shmaddr(由附加的 shmat() 调用返回的值)的方式附加。

shmdt() 调用成功时,系统会更新与共享内存段关联的 shmid_ds 结构的成员,如下所示:

  • shm_dtime 设置为当前时间。
  • shm_lpid 设置为调用进程的进程 ID。
  • shm_nattch 减少 1。如果它变为 0 且段被标记为删除,则删除该段。

在 fork(2) 后,子进程会继承附加的共享内存段。

在 execve(2) 后,所有附加的共享内存段都会从进程中分离。

在 _exit(2) 时,所有附加的共享内存段都会从进程中分离。

简单来说,就是shmat就是将该某个共享内存与当前进程关联起来,而shmdt就是将它们去关联。使用示例代码如下

extern Log lg;

int main()
{
    int shmid = CreateShm();
    lg(Debug, "create shm done");

    sleep(3);

    char* shmaddr = (char*)shmat(shmid, nullptr, 0);
    lg(Debug, "attach shm done, shmaddr : 0x%x", shmaddr);

    sleep(3);
    shmdt(shmaddr);
    lg(Debug, "detach shm done, shmaddr : 0x%x", shmaddr);

    return 0;
}

 此外,如果我们想将共享内存删除还有一个接口

shmctl() 函数对由 shmid 指定的 System V 共享内存段执行由 cmd 指定的控制操作。

buf 参数是指向 shmid_ds 结构的指针,该结构在 <sys/shm.h> 中定义如下:

struct shmid_ds {  
    struct ipc_perm shm_perm;    /* 所有权和权限 */  
    size_t          shm_segsz;   /* 段的大小(字节) */  
    time_t          shm_atime;   /* 上次附加时间 */  
    time_t          shm_dtime;   /* 上次分离时间 */  
    time_t          shm_ctime;   /* 上次改变时间 */  
    pid_t           shm_cpid;    /* 创建者的进程ID */  
    pid_t           shm_lpid;    /* 最后一次 shmat(2)/shmdt(2) 调用的进程ID */  
    shmatt_t        shm_nattch;  /* 当前附加的数量 */  
    ...  
};

 ipc_perm 结构定义如下(其中高亮字段可通过 IPC_SET 设置):

struct ipc_perm {  
    key_t          __key;    /* 调用 shmget(2) 时提供的键 */  
    uid_t          uid;      /* 所有者的有效用户ID */  
    gid_t          gid;      /* 所有者的有效组ID */  
    uid_t          cuid;     /* 创建者的有效用户ID */  
    gid_t          cgid;     /* 创建者的有效组ID */  
    unsigned short mode;     /* 权限 + SHM_DEST 和 SHM_LOCKED 标志 */  
    unsigned short __seq;    /* 序列号 */  
};

cmd 的有效值包括:

  • IPC_STAT:将内核中与 shmid 关联的数据结构的信息复制到 buf 指向的 shmid_ds 结构中。调用者必须对该共享内存段具有读取权限。

  • IPC_SET:将 buf 指向的 shmid_ds 结构中某些成员的值写入与此共享内存段关联的内核数据结构中,并更新其 shm_ctime 成员。可以更改的字段包括:shm_perm.uidshm_perm.gid 和(shm_perm.mode 的最低9位)。调用者的有效用户ID必须与共享内存段的所有者(shm_perm.uid)或创建者(shm_perm.cuid)匹配,或者调用者必须具有特权。

  • IPC_RMID:标记该段以进行销毁。段仅在实际最后一个进程分离它之后(即,当关联的 shmid_ds 结构的 shm_nattch 成员为零时)才会被销毁。调用者必须是所有者或创建者,或者具有特权。如果段已被标记为销毁,则通过 IPC_STAT 检索的关联数据结构中的 shm_perm.mode 字段的(非标准)SHM_DEST 标志将被设置。调用者必须确保段最终会被销毁;否则,其曾经映射的页面将保留在内存或交换空间中。

  • IPC_INFO(Linux 特定):返回有关系统范围共享内存限制和参数的信息,信息存储在 buf 指向的结构中。此结构为 shminfo 类型(因此需要进行类型转换),在定义了 _GNU_SOURCE 功能测试宏的 <sys/shm.h> 中定义。

  • SHM_INFO(Linux 特定):返回一个 shm_info 结构,其字段包含有关共享内存消耗的系统资源的信息。此结构在定义了 _GNU_SOURCE 功能测试宏的 <sys/shm.h> 中定义。

  • SHM_STAT(Linux 特定):返回与 IPC_STAT 相同的 shmid_ds 结构。但是,shmid 参数不是段标识符,而是内核内部维护有关系统上所有共享内存段信息的数组的索引。

调用者可以使用以下 cmd 值来防止或允许共享内存段的交换:

  • SHM_LOCK(Linux 特定):防止共享内存段交换。调用者必须确保在启用锁定后需要存在的任何页面都已映射。如果段已被锁定,则通过 IPC_STAT 检索的关联数据结构中的 shm_perm.mode 字段的(非标准)SHM_LOCKED 标志将被设置。

  • SHM_UNLOCK(Linux 特定):解锁段,允许其被换出。

在内核版本 2.6.10 之前,只有特权进程才能使用 SHM_LOCK 和 SHM_UNLOCK。自内核 2.6.10 起,如果其有效用户ID与段的所有者或创建者用户ID匹配,并且(对于 SHM_LOCK)要锁定的内存量在 RLIMIT_MEMLOCK 资源限制(见 setrlimit(2))范围内,则非特权进程也可以使用这些操作。

示例代码如下

extern Log lg;

int main()
{
    int shmid = CreateShm();
    lg(Debug, "create shm done");

    sleep(3);

    char* shmaddr = (char*)shmat(shmid, nullptr, 0);
    lg(Debug, "attach shm done, shmaddr : 0x%x", shmaddr);

    sleep(3);
    shmdt(shmaddr);
    lg(Debug, "detach shm done, shmaddr : 0x%x", shmaddr);

    sleep(3);
    shmctl(shmid, IPC_RMID, nullptr);
    lg(Debug, "remove shm done, shmaddr : 0x%x", shmaddr);

    return 0;
}

 运行有

讲了这么多接口,那我们开始通信了吗?——还没呢!我们现在所做的工作只是让不同的进程看到同一份资源,我们现在来编写通信代码(IPC code),对于客户端(client)来说,一旦有数据写入到共享内存,立马就能看到,不需要经过系统调用,直接就能看到地址;对于服务端(server)来说,一旦有共享内存链接到自己的地址空间中,直接把他当做自己的内存空间用即可,也不需要经过系统调用,编写的代码如下

server

#include "command.hpp"

extern Log lg;

int main()
{
    int shmid = CreateShm();
    char* shmaddr = (char*)shmat(shmid, nullptr, 0);

    // IPC code
    while (true)
    {
        // 直接访问共享内存
        cout << "client say: " << shmaddr << endl;
        sleep(1);
    }

    shmdt(shmaddr);
    shmctl(shmid, IPC_RMID, nullptr);

    return 0;
}
client

#include "command.hpp"

extern Log lg;

int main()
{
    int shmid = GetShm();
    char* shmaddr = (char*)shmat(shmid, nullptr, 0);

    // IPC code
    while (true)
    {
        char buffer[1024];
        cout << "Please cin: ";
        fgets(buffer, sizeof(buffer), stdin);
        memcpy(shmaddr, buffer, strlen(buffer)+1);
    }

    shmdt(shmaddr);

    return 0;
}

 运行效果如下

3. 共享内存的特性

1. 共享内存没有同步互斥之类的保护机制;

2. 共享内存是所有进程通信中速度最快的,因为它的拷贝次数少!我们拿管道来对比

3. 共享内存内部的数据由用户自己来维护。

4. 共享内存的扩展 

1. 查看共享内存属性

我们之前在查看 shmctl 时可以发现它其中有提到共享内存的结构,即

我们可以在代码中查看,即

int main()
{
    int shmid = CreateShm();
    char* shmaddr = (char*)shmat(shmid, nullptr, 0);

    struct shmid_ds shmds;
    // IPC code
    while (true)
    {
        sleep(1);

        shmctl(shmid, IPC_STAT, &shmds);
        cout<< "shm size: " <<shmds.shm_segsz <<endl;
        cout<< "shm nattch: " <<shmds.shm_nattch << endl;
        printf("0x%x\n", shmds.shm_perm.__key);
        cout << "shm mode: " <<shmds.shm_perm.mode << endl;
    }

    shmdt(shmaddr);
    shmctl(shmid, IPC_RMID, nullptr);

    return 0;
}

运行效果如下 

2. 解决没有同步机制的问题

前面我们在共享内存的特性中提到共享内存没有同步与互斥的机制,那我们如何改善它呢?——通过管道!改善后代码如下

client

int main()
{
    int shmid = GetShm();
    char* shmaddr = (char*)shmat(shmid, nullptr, 0);

    int fd = open(FIFO_FILE, O_WRONLY);
    if (fd < 0)
    {
        lg(Fatal, "error string: %s, error code: %d", strerror(errno), errno);
        exit(FIFO_OPEN_ERR);
    }
    
    while(true)
    {
        cout << "Please cin : ";
        fgets(shmaddr, 4096, stdin);
        
        write(fd, "c", 1);// 通知对方
    }

    shmdt(shmaddr);
    close(fd);

    return 0;
}
server

int main()
{
    int shmid = CreateShm();
    char* shmaddr = (char*)shmat(shmid, nullptr, 0);

    // 等待写入方打开之后,自己才会打开文件,向后执行, open阻塞了!
    int fd = open(FIFO_FILE, O_RDONLY);
    if (fd < 0)
    {
        lg(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;
        else if (s < 0) break;

        cout << "client say : " << shmaddr;
        sleep(1);
    }

    shmdt(shmaddr);
    shmctl(shmid, IPC_RMID, nullptr);

    close(fd);
    return 0;
}

运行效果如下

③ 消息队列 && 信号量

1. 消息队列的原理

简单来说,就是让A进程与B进程间一数据块的形式互相发送数据,我们还是画图来理解

首先,我们知道进程间通信的基本要求就是必须先让不同的进程看到同一份资源,对于管道来说这个资源是文件缓冲区,对于共享内存来说这个资源是内存块,而对于消息队列来说这个资源就是这个队列!这个队列有如下特点

1. 不同的进程可以看到同一个队列

2. 不同的进程可以向内核中发送带类型的数据块

2. 消息队列的接口

消息队列的接口与共享内存的大致相似,我们对比一下就能知道大致效果,如图

其调用方式类似于共享内存,这里就不再赘述。

注:可以使用ipcs -q来查看OS中的消息队列

3. 信号量的接口

信号量的接口与共享内存的接口大差不差,如图

我们注意到,整套 System V 的接口都有一个 xxxid_ds 的结构体,即

在这里我们可以明显看到其设计风格,即将 ipc_perm 视为基类,而 上面的 shm_perm, msg_perm, sem_perm 都为子类,这种设计体现出了多态的思想。

4. 如何理解信号量

在解释信号量前,我们先讲讲共享内存中的问题

由于共享内存没有保护机制,在A想写入,B想读取的情况下,当A正在写入,刚写入了一部分,就被B拿走了,导致双方发和收的数据不完整,这就产生了数据不一致问题。

1. 对于 A 和 B 看到的同一份资源,我们将其称为共享资源, 如果不加以保护,就会产生数据不一致问题。

2. 为了解决这个问题,我们可以对其进行加锁,即互斥访问——任何时候只允许一个执行流访问共享资源。简单来说就是:写的时候不能读,读的时候不能写。

3. 对于任何时刻只允许一个执行流访问的共享资源,我们将其称为临界资源(一般都是内存空间)。如 ATM 取钱, 网上购票服务等。

4. 如果有100行代码,其中 5~10行在访问临界资源,我们将这几行访问临界资源的代码称为临界区。

上面这几点也可以解释一个现象,在多进程/多线程并发打印的时候,显示器上的消息可能会出现:1. 错乱的;2. 混乱的;3. 打印内容和命令行混在一起。这是因为显示器也是一个共享资源,由于我们没有对其进行保护,导致其出现了各种问题。

接下来我们来谈谈信号量

信号量的本质是一把计数器,类似于 int cnt = n; 它用来描述临界资源中资源数量的多少!

举个例子

在一个放映厅中,有100个座位,100张票

当我们想看电影的时候,需要先买票——买票的本质就是一个预订机制

剩余票数的计数器,每卖一张,计数器就要减1——放映厅里的资源就少一个

剩余票数的计数器到0后,资源已经被申请完毕。

现在我们来看临界资源被申请的情况

 在申请的时候我们最怕的是什么?

1. 多个执行流访问了同一个资源

2. 一共只有n个资源,但是放入了n+1个执行流

为了防止出现这种情况,我们引入一个计数器

int cnt = 32;
int num = cnt--; // 申请资源
if (cnt <= 0) // 资源被申请完了,再有执行流申请就不给了

 程序员就将这个计数器称为信号量!

如果放映厅只有一个座位呢?——只需要一个值为1的计数器,我们将这种值只能为0/1两态的计数器称为二元信号量——它的本质是一个锁。这个资源为1的本质是什么?——其实是将临界资源不要分为很多块,而是当做一个整体,整体申请整体释放。

总结一下

1. 申请计数器成功,就表示有访问资源的权限了;

2. 申请了计数器资源,就表示我现在就要访问我申请的资源了吗?——并不是,申请计数器资源是对资源的预定机制;

3. 计数器可以有效保证进入共享资源的执行流数量

4. 每一个执行流想访问共享资源中的一部分,不是直接访问,而是先申请计数器。即想看电影先买票。

 让我们稍作思考一下

要访问临界资源的时候,先要申请信号量计数器资源,即

int cnt = 10;
cnt--;

但是信号量计数器(cnt)本身不也是共享资源吗?要使用它来保护别人的话其本身也应该是安全的,但是这里的 cnt-- 操作并不是安全的!在C语言中,一条语句变成汇编会变成多条语句,举个例子这里的 cnt-- 在汇编中会转换成

1. 将 cnt 变量的内容由内存加载到CPU寄存器中

2. CPU内执行--操作

3. 将计算后的结果写回到 cnt 变量的内存中

而进程在运行的时候,是可以被随时被切换的,这就可能会导致数据在不同的进程中不一致的问题。

此外,我们在申请信号量的时候,本质是对计数器进行 -- 操作,我们将这个操作称为 P 操作; 与之相对的,释放资源,释放信号量,本质就是对计数器进程 ++ 操作,我们将这个操作称为 V 操作。综上,我们对信号量进行 PV 操作时,必须保证它们是原子的(要么做完,要么不做,没有正在做的概念)! 

我们可以使用 semop 函数来进行 PV 操作,其接口如下

我们可以设置第二个参数中的sem_op,如果为1的话就是V操作,-1为P操作。

那么信号量看起来并没有和进程间通信有什么关系,那它为什么是进程间通信的一种呢?

1. 进程间的通信不仅仅是通信数据,互相之间协同也算;

2. 要进行协同,本质上来说也是通信,信号量是需要被所有进程看到的。

④补充

除了上面的几种通信方式外,还有一种共享内存,他就是mmap,我们查看其接口有

通俗来讲,它是将磁盘中的文件映射到内存中,以此让所有进程都能看到他!整体类似于共享内存,这里就不做赘述了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值