【Linux】进程池bug、命名管道、systemV共享内存

发布于:2025-04-16 ⋅ 阅读:(16) ⋅ 点赞:(0)

一.进程池bug

我们在之前进程池的创建中是通过循环创建管道,并且让子进程与父进程关闭不要的读写段以构成通信信道。但是我们这样构建的话会存在一个很深的bug。

我们在销毁进程池时是先将所有的信道的写端关闭,让其子进程read返回值为0,并退出,此时我们在进行等待子进程,就完成了销毁的行为。

按照我们实现的逻辑图来看,我们可以对一个信道先进行关闭在进行子进程的回收,然后再对下一个信道进行关闭操作。但是因为这个bug的存在,如果我们将关闭信道和回收子进程放在一起时,就会导致进程死循环。

    void DestoryAndRecycle()
    {
        for(auto& channel : _channels)
        {
            channel.closewfd();
            std::cout << channel.Ref() << "closed!" << std::endl;
            channel.waitid();
            std::cout << channel.Ref() << "recycled !" << std::endl;
        }

    }   

    // 销毁进程池
    void Destroy()
    {
        // 可不可以关闭一个信道就立刻回收呢?
        _cm.DestoryAndRecycle();
    }

说明:此时我们便对该进程的销毁进行修改,由先关闭所有信道再回收子进程,改为关闭一个回收一个。

我们看上图,我们执行完10次任务之后,开始销毁进程池。但是只关闭了第一个信道,进程就进入了死循环。我们在观察该一下该进程,5个子进程以及一个父进程都还在,并没有被结束 这样的结果就是我们底层的bug导致的。那么这个bug到底是什么呢?

 我们在创建第一个子进程时,子进程的文件描述符表是拷贝自父进程的,而父进程的文件描述符表除了012外,就是管道文件的读写端了。此时父子进程各自关闭自己不需要的读写端形成单向信道,此时没有任何问题。

但是父进程循环上来再进行创建管道之后,它的文件描述符分别为3和5,此时fork创建子进程时,依旧会拷贝父进程的文件描述符表,所以此时子进程的文件描述符表除了3和5之外,还有4指向自己兄弟进程的写端!!!

所以,对于后面创建的子进程来说,它除了自己的读端外,他还会指向前面所有的兄弟进程的写端。

现在,我们默认创建了5个子进程,那么对于第一个子进程来说,一共有5个进程指向它的写端——一个父进程四个子进程。所以,当我们销毁进程池先关闭信道的写端后,此时对于第一个进程来说它的写端并没有全部关闭,此时子进程并不会读到返回值0,而是继续再read处阻塞。所以此时我们进行wait的时候,第一个子进程并没有退出。所以就会陷入死循环。

解决方案1:倒着关闭信道并回收子进程,对于最后一个子进程来说,它对应的信道只有父进程指向,所以关闭该信道,read的返回值就会为0,此时就可以退出read阻塞状态,进行等待,最后一个子进程都退出了,它的文件描述符表也就释放了,指向前面兄弟的写端也就释放了 。

for(int i = _channels.size() - 1; i >= 0; i--)
{
    _channels[i].closewfd();
    std::cout << _channels[i].Ref() << "closed!" << std::endl;
    _channels[i].waitid();
    std::cout << _channels[i].Ref() << "recycled !" << std::endl;
}


 解决方案2:我们只需要在创建子进程的时候关闭指向前面兄弟进程的文件描述符即可。这里有两种方式解决:第一种,对于子进程来说,我么可以拿到读端和写端,而文件描述符表都来自父进程,读端都是3是确定的,而写端会递增。所以对于子进程来说,它的pipe[0]一定是3,而pipe[1]是它自己的写端,而这两个之前的文件描述符就是兄弟进程的写端。我们只需要关闭这些即可。

for(int i = pipefd[0] + 1; i <= pipefd[1]; ++i)
{
    close(i);
}

第二种,子进程fork会拷贝父进程的pcb等各种信息,所以子进程也会有自己的数据和代码。而对于子进程来说,它也有自己的channelmanager,里面存储了前面兄弟进程的所有的写端。所以我们在创建子进程的时候,可以先将子进程的channelmanager给清空掉。这里并不会对父进程有所影响,因为会发生写时拷贝。

 void CloseChildWrite()
{
    for(auto channel : _channels)
    {
        close(channel.Wfd());
    }
}

void Init()
{
    ...

    else if(id == 0)
    {
        _cm.CloseChildWrite();

        // 关闭的只是自己之前创建的子进程的写端,还需要关闭自己的写端
        close(pipefd[1]); // 关闭自己的写端
        Work(pipefd[0]); // 执行任务
        close(pipefd[0]);
        exit(0);
    }

    ...
}

 那么程序有这个问题,为什么我们之前先关闭所有信道,再回收子进程就没有问题呢? 

按照上面的分析,关闭第一个信道,它的写端并不会全部关闭,子进程不会退出,但是我们并没有进行等待,而是直接关闭下一个信道,所以等到所有的信道关闭完成,此时就会有类似递归回退的效果,从后往前,子进程的read读到返回值0,开始退出,自此,我们才进行等待。

 二.命名管道

我们已经了解过了匿名管道,但是匿名管道只能让具有血缘关系的进程进行通信。那么如果我们想让两个毫不相关的进程之间进行通信呢?

1.命名管道原理

首先我们得明确一点,两个毫不相关的进程如果同时打开同一个文件,该文件的内容和属性并不会在内存中加载两份。因为操作系统是不会允许有浪费内存资源的情况出现的。所以对于这两个进程来说,它们有各自的文件描述符表,里面都有一个struct file*指向打开的同一个文件,但是struct file指向的inode和文件内核缓冲区则只有一份。

而我们说过,进程间通信的前提条件就是让进程看到同一份资源,而上面两个不同进程打开同一个文件其实就达到了我们的目的。而这个资源是我们通过路径+文件名打开的, 所以这个资源是具有唯一性的。而上述的原理就是命名管道的原理。

但是打开的普通文件会进行刷盘,会进行IO操作。但是我们进行的进程间通信是不涉及刷盘操作的。所以我们两个进行打开的同一个文件不是普通文件,而是管道文件。

2.命名管道的构建

有了命名管道,我们就可以让不相关的进程之间进行通信。而前提是我们得打开管道文件。在命令行,我们可以通过mkfifo命令创建一个管道文件

我们可以通过rm删除该管道文件或者通过unlink的方式来删除该管道文件。 

 但是我们想要的是利用系统调用的方式创建管道文件以运用在代码中。

#include <sys/types.h>
#include <sys/stat.h>

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

#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>

int main()
{
    int n = mkfifo("fifo", 0666);
    if(n < 0)
    {
        std::cerr << "fifo error" << std::endl;
        return 1;
    }
    return 0;
}

这里权限与我们设置的不同主要是因为权限掩码的存在,它会屏蔽掉一些权限。 

有了上面的管道文件,我们就可以利用这个管道文件让两个毫不相关的进程进行通信。下面是demo代码:

// server.cc

#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "file.hpp"

int main()
{
    // 1.创建管道文件
    int n = mkfifo(FILE_FIFO, 0666);
    if (n < 0)
    {
        perror("mkfifo");
        exit(1);
    }

    // 2.打开管道文件,该进程以读打开
    int fd = open(FILE_FIFO, O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        exit(2);
    }

    // 3.read进行通信
    char buffer[1024];
    while (true)
    {
        n = read(fd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "slient say:" << buffer << std::endl;
        }
        else if (n == 0)
        {
            std::cout << "read EOF" << std::endl;
            break;
        }
        else
        {
            perror("read");
            exit(3);
        }
    }

    // 4.关闭管道文件
    close(fd);

    // 5.删除管道文件
    unlink(FILE_FIFO);

    return 0;
}
client.cc

#include <iostream>
#include <string>
#include <cstdio>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "file.hpp"

int main()
{
    // 1.以写方式打开管道文件
    int fd = open(FILE_FIFO, O_WRONLY);
    if(fd < 0)
    {
        perror("open");
        exit(1);
    }

    // 2.对管道文件内写,以进行通信
    std::string message;
    while(true)
    {
        std::cout << "client enter->";
        std::cin >> message;
        int n = write(fd, message.c_str(), message.size());
    }

    close(fd);
    return 0;
}

说明:首先要进行两个进程间的通信,得看到同一份资源。而这份资源就是管道文件。我们现在有服务端和客户端。我们让服务端创建管道文件,而这个管道文件的名字定义在一个公共的文件中,两个端口都可以看到。创建好之后我们便开始构建通信信道,让服务端以读打开,客户端以写打开,这样我们就在两个进程之间构建起了一个通信信道。客户端端每写一条消息,就在服务端打印一次。最后通信完毕,都得关闭对应的文件描述符,服务端还得删除管道文件。

3.命名管道实现文件备份

我们既然已经知道了命名管道的原理,以及如何利用命令管道实现两个进程间的通信。那么就可以实现让两个毫不相关的程序进行文件的备份工作。

我们可以让一个sever进程创建管道文件,并进行读取管道文件内容,生成一个备份文件。而我们的client进程用来读已有的文件,写入到管道文件中。

server.cc

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

int main()
{
    // 1.创建管道文件
    int n = mkfifo("fifo_for_copy", 0666);
    if (n < 0)
    {
        perror("mkfifo");
        exit(1);
    }

    // 2.以读方式打开管道文件
    int fd = open("fifo_for_copy", O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        exit(2);
    }
    // 备份文件
    int bak = open("log.txt.bak", O_CREAT | O_TRUNC | O_WRONLY, 0666);
    if (bak < 0)
    {
        perror("open");
        exit(2);
    }

    // 从管道文件中读取文件,并做备份
    char text[1024];
    while ((n = read(fd, text, sizeof(text))) > 0)
    {
        write(bak, text, n);
    }

    close(fd);
    close(bak);

    unlink("fifo_for_copy");
    return 0;
}
client.cc

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

int main()
{
    // 1.以写方式打开管道文件
    int fd = open("fifo_for_copy", O_WRONLY);
    if (fd < 0)
    {
        perror("open");
        exit(2);
    }
    // 以读方式打开待拷贝的文件
    int need = open("log.txt", O_RDONLY);
    if (need < 0)
    {
        perror("open");
        exit(2);
    }

    // 读need,写入管道文件中
    char buffer[1024];
    ssize_t n = 0;
    while ((n = read(need, buffer, sizeof(buffer))) > 0)
    {
        write(fd, buffer, n);
    }

    close(fd);
    close(need);

    return 0;
}

4. 封装命名管道进程间通信

我们将命名管道的创建,以及借助管道通信的方式都封装成类的接口。这样在进行进程间通信的时候我们直接调用对应的接口即可。

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

#define ERR(m)              \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0);

#define DEFAULTPATH "."
#define DEFAULTNAME "fifo"

class fifo
{
public:
    fifo(const std::string &path = DEFAULTPATH, const std::string &name = DEFAULTNAME)
        : _path(path), _name(name)
    {
        _fifoname = path + "/" + name;
    }

    ~fifo()
    {
        int n = unlink(_fifoname.c_str());
        if (n == 0)
        {
            std::cout << "fifo deleted" << std::endl;
        }
        else
            ERR("unlink");
    }

    void CreatFifo()
    {
        int n = mkfifo(_fifoname.c_str(), 0666);
        if (n < 0)
            ERR("mkfifo");
        std::cout << "mkfifo succeess" << std::endl;
    }

    void OpenForWrite()
    {
        // 以写方式打开管道文件
        _fd = open(_fifoname.c_str(), O_WRONLY);
        if (_fd < 0)
            ERR("open");
        std::cout << "open for write succeed!" << std::endl;
    }

    void Write()
    {
        // 写内容到管道文件中
        std::string message;
        std::cout << "please enter->";
        std::cin >> message;
        write(_fd, message.c_str(), message.size());
    }

    void OpenForRead()
    {
        // 以读方式打开管道文件
        // 如果write方没有打开管道文件
        // read方就会在open处阻塞
        // 知道有人打开了管道文件,open才返回
        _fd = open(_fifoname.c_str(), O_RDONLY);
        if (_fd < 0)
            ERR("open");
        std::cout << "open for read succeed!" << std::endl;
    }

    void Read()
    {
        // 从管道文件中读内容
        char buffer[1024];
        int n = read(_fd, buffer, sizeof(buffer));
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "i get->" << buffer << std::endl;
        }
        else if (n == 0)
        {
            std::cout << "read end of file" << std::endl;
            exit(0);
        }
        else
        {
            ERR("read");
            exit(0);
        }
    }

    void Close()
    {
        close(_fd);
    }

private:
    std::string _path;
    std::string _name;
    std::string _fifoname;
    int _fd;
};

说明:当我们创建好对应的管道文件后,读端此时如果要打开管道文件的话是无法打开的,它会阻塞在open处。它必须得等其他的以写方式打开管道文件的进程打开之后,才会打开。

当打开写端之后,对应的读端也会打开 

 测试代码:

server.cc

#include "fifo.hpp"

// 服务端
// 用来读
int main()
{
    // 服务端得先创建管道
    fifo f;
    f.CreatFifo();

    // 以读方式打开管道文件
    f.OpenForRead();
    while(true)
    {
        // 读
        f.Read();
    } 
    f.Close();

    return 0;
}

client.cc

#include "fifo.hpp"

// 客户端
// 用来写
int main()
{
    // 以写方式打开管道文件
    fifo f; 
    f.OpenForWrite();
    int n = 5;
    while(n--)
    {
        // 写
        f.Write();
    }

    f.Close();
    return 0;
}

三.systemV共享内存

我们前面基于管道文件实现进程间的通信是在已有代码的基础上如文件管理等实现的。而随着通信要求的日益增长,基于管道的通信终究是不能满足需求了。

所以Linux就专门开发出了一套通信模块,而其支持的标准就是systemV。

1.共享内存的原理

进程间通信的前提是让不同的进程看到同一个资源。而共享内存也得遵循这个前提。

共享内存在通信之前会先在物理内存开辟一块内存空间,然后通过映射到进程的虚拟地址空间上的共享区中,再借助页表进行虚拟地址与物理地址的映射,这样该进程就看到了一个资源。同样的,将该物理内存映射到另一个进程的虚拟地址空间的共享区中,并进行页表映射。此时这两个进程就看到了同一份资源,有了通信的前提要求。

上面所说的所有动作都是由操作系统自己完成的。而我们用户想进行上面的操作需要借助操作系统提供的系统调用才可以。

当我们将共享内存与进程关联之后,就有了通信的基础,而如果取消关联关系,及没有页表的映射,此时OS就会释放这段共享内存。

在同一时间,可能同时存在多个正在进行通信的进程,而他们都有自己对应的共享内存。这些共享内存有的可能刚申请,有的正在是使用,有的则正准备释放。所以OS要对这些共享内存进行管理,先描述再组织。所以共享内存一定要有自己的内核数据结构对象。这样,共享内存与进程之间的关系就转变成了内核数据结构之间的关系!!!

2.共享内存的接口

0x1.shmget

#include <sys/ipc.h>
#include <sys/shm.h>

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

shmget - allocates a System V shared memory segment

size:表明要申请的共享内存的大小

通常这个空间的大小是4kb(4096type)的整数倍,如果不够4kb就会向上取整。

shmflg:创建共享内存的选项

这里传选项的方式与当是open打开的方式类似,参数其实就宏,可以通过|的方式,同时传递多个不同的选项。

IPC_CREAT

创建共享内存,如果指定目标共享内存不存在就创建,否则就打开这个已存在的共享内存。

IPC_EXCL

该选项单独使用没有任何意义,通常与IPC_CREAT同时使用。

同时使用时表示:创建共享内存,如果指定目标共享内存不存在就创建,否则就shmget就报错返回。也就是说,只要使用这两个选项创建成功后,创建出来的共享内存一定是全新的。

那么 我们怎么评估,共享内存是否存在呢?怎么保证两个不同的进程,拿到的是同一个共享内存呢?

当我们想要创建一个共享内存时,我们怎么判断该共享内存存在与否呢?就算我们知道我们创建好了一个新的共享内存,我们怎么确定我们即将要进行通信的进程拿到的是同一个共享内存呢?

而这两个问题的答案就在shmget的第一个参数上

key:用来表示共享内存的唯一性

不同的进程,用key值来表示共享内存的唯一性。而这个key值只是用于区分不同的共享内存,这并不是OS内核直接提供的,而是由用户提供的,构建key并传递给操作系统。操作系统可以根据这个key值来进行类似于遍历的操作,如果已存在的共享内存中没有这个key值的,就创建,如果有,则根据选项的不同,进行不同而操作。

所以对于进程来说,它们在通信之前可以共同约定一个key值,一个进程通过IPC_CREAT | IPC_EXCL这两个选项,创建一个全新的共享内存,而另一个进程可以通过IPC_CREAT这个选项打开一个指定key值的共享内存。这样,它们就可以看到同一份资源了。

那么这个key值为什么不由操作系统提供,而要由待通信的进程进行约定呢?

因为对于这两个进程来说,一个是创建共享内存的,而另一个只是打开该共享内存。A进程根据操作系统给出key值创建共享内存,那么B进程怎么拿到这个key值来打开共享内存呢?

此时还没有进行通信,也无法让A给B。所以这个key值只能由用户提供。

RETURN VAL

On success, a valid shared memory identifier is returned.  On error, -1 is returned, and errno is set to indicate the error.

如果创建成功,就会返回一个有效的描述符指向这个共享内存。之后用户访问该共享内存都使用这个描述符。

如果创建失败,则返回-1,并设置错误码! 

0x2.ftok

而shmget函数的key值,通常使用ftok这个函数生成。

#include <sys/types.h>
#include <sys/ipc.h>

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

convert a pathname and a project identifier to a System V IPC key

pathname:传一个路径

proj_id:传一个随机的数

ftok函数会根据底层算法,结合pathname和proj_id生成一个key值。毕竟是算法生成的,所以难免有重复值的出现。但是如果只要pathname和proj_id给定,那么给出的key值也是固定的。


int main()
{
    // 1.生成key值
    key_t key = ftok(".", 0x11);
    if(key < 0) perror("ftok");
    printf("key:0x%x\n", key);

    // 2.创建共享内存
    int n = shmget(key, 4096, IPC_CREAT | IPC_EXCL);
    if(n < 0) perror("shmget");
    printf("n:%d\n", n);

    sleep(5);

    return 0;
}

说明:我们根据上面所说的创建一个共享内存。我们可以使用命令ipcs -m来查看此时内核中所有的共享内存。

key就是我们创建共享内存的key值

shmid就是shmget函数返回的共享内存的描述符

owner表示这段共享内存的所有者

perms表示该共享内存的权限

bytes表示共享内存的大小,注意我们说过,大小是4096的整数倍,但是这里显示时会按照你申请的大小显式,但是实际上依旧会采取向上取整

nattch表示与该共享内存关联的进程

status表示该共享内存的状态 

但是我们的进程都已经结束了,该共享内存还是没有删除,这么因为共享内存的生命周期是随内核的。如果我们的进程结束了,我们没有显式的释放该共享内存就会一直被占用。

那么我们怎么删除共享内存呢?

0x3.ipcrm -m 、shmctl

ipcrm -m

ipcrm -m是一个删除共享内存的命令,删除时需要指定要删除的shmid,也就是该共享内存的描述符。

那么为什么不用key值来删除呢?

我们的key值只是用来在内核中声明共享内存的唯一性,而我们用户使用共享内存都是通过shmid来使用的。

shmctl 

我们期望的是在通信结束后,由代码的方式关闭共享内存。

该接口实际上是对我们的共享内存进行控制管理的,但是我们现在不管别的,只是用该接口为我们关闭对应的共享内存。

#include <sys/ipc.h>
#include <sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

System V shared memory control

shmid:即我们要关闭的内存空间id

cmd:即我们要如何进行共享内存管理,这里我们想要关闭共享内存,对应的选项为IPC_RMID

buf:用来获取共享内存内核数据结构,我们这里不需要直接传NULL即可。

int main()
{
    // 1.生成key值
    key_t key = ftok(".", 0x11);
    if(key < 0) perror("ftok");
    printf("key:0x%x\n", key);

    // 2.创建共享内存
    int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL);
    if(shmid < 0) perror("shmget");
    printf("shmid:%d\n", shmid);

    sleep(2);

    // 3.关闭共享内存
    shmctl(shmid, IPC_RMID, NULL);
    sleep(2);

    std::cout << "shmctl done!" << std::endl;
    return 0;
}

说明:首先我们创建共享内存,2秒过后,关闭共享内存,我们利用监控脚本来查看共享内存:

while :; do ipcs -m ; sleep(1); done

0x4.shmat

我们现在可以创建和关闭共享内存,但我们现在还无法进行通信,因为共享内存,还没有映射到任何一个进程的进程地址空间。只有让共享内存映射到不同的两个进程的地址空间中,让它们看到同一个资源,此时才有了通信的前提。

我们可以使用shmat系统调用使调用该shmat的进程挂载到该共享内存上。

#include <sys/types.h>
#include <sys/shm.h>

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

shmid:待挂载的共享内存

shmaddr:共享内存映射到该进程地址空间的固定位置,即我们可以指定共享内存映射到地址空间的位置,当然,这个不常用,容易产生冲突,这里直接给NULL即可。让其默认映射

shmflg:控制共享内存附加行为的标志。常见的标志有

  • SHM_RDONLY:附加只读权限,进程对共享内存段必须有可读权限
  • 0:默认的读写权限
  • SHM_REMAP:替换位于shmaddr处的任意既有映射
  • SHM_EXEC:共享内存段的内存允许被执行,调用者必须对共享内存段有执行权限

我们通常传0即可。

return val 

成功时,返回该共享内存映射到虚拟地址空间的起始地址。

失败时,返回(void*)-1,并设置退出码

这里为什么shmdt返回值是虚拟地址呢?

我们可以将shmat当作malloc,malloc返回的也是地址。所以当我们让进程与共享内存关联之后,我们就可以直接用该虚拟地址访问该共享内存,像malloc一样,我们想要该共享内存是什么类型就是什么类型。

... 
// 与共享内存关联
void *adder = shmat(shmid, NULL, 0);
if (adder == (void *)-1)
    perror("shmat");
printf("adder:0x%p\n", adder);
...

我们看到,关联时报错了,权限不允许。这是为啥呢?

我们在创建共享内存时并没有指定权限,而在Linux中一切皆文件,所以共享内存也有对应的读写可执行权限,我们需要在创建共享内存时与选项一同传给shmget 

int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);

0x4.shmdt

我们既然能让进程与共享内存关联,我们也可以让进程与共享内存取消关联

#include <sys/types.h>
#include <sys/shm.h>

int shmdt(const void *shmaddr);

shmdt如果取关联成功则返回0,失败返回-1.

...
sleep(2);

// 取关联
int n = shmdt(adder);
if(n < 0)
    perror("shmdt");
std::cout << "shmdt success!" << std::endl;
sleep(2);

...

说明:我们让其先关联共享内存,然后过两秒再去关联,我们观察监控脚本打印的结果

3.利用共享内存进行进程间通信

要想要两个进程间进行通信,我们就得先创建共享内存,并让共享内存与这两个进程关联。而且共享内存的使用方式与管道文件不同,它不需要使用系统调用从文件内核缓冲区中读写数据,而是像malloc创建的堆空间一样使用地址。

#include <iostream>
#include <string>
#include <cstdio>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define DEFAULT_PATHNAME "."
#define DEFAULT_PROJ_ID 0
#define DEFAULT_SIZE 4096

#define CREATOR "creater"
#define USER "user"

#define ERR(m)              \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0);

class shm
{
private:
    void CreatShm()
    {
        _shmid = shmget(_key, DEFAULT_SIZE, IPC_CREAT | IPC_EXCL | 0666);
        
        std::cout << "creat shm success!" << std::endl;
    }

    void Getshm()
    {
        _shmid = shmget(_key, DEFAULT_SIZE, IPC_CREAT);

        std::cout << "get shm success!" << std::endl;
    }

    void Attach()
    {
        // 让进程与共享内存关联
        void* adder = shmat(_shmid, NULL, 0);
        if(adder == (void*)-1)
            ERR("shmat");
        _start_mm = adder;

        std::cout << "attach success!" << std::endl;
    }

    void Detach()
    {
        int n = shmdt(_start_mm);
        if(n == -1)
            ERR("shmdt");
        
        std::cout << "detach success!" << std::endl;
    }

    void Destory()
    {
        shmctl(_shmid, IPC_RMID, NULL);
        
        std::cout << "delete shm sucess!" << std::endl;
    }

public:
    shm(const std::string &pathname, const int proj_id, const std::string &usertype)
        : _usertype(usertype), _start_mm(nullptr)
    {
        // 获取key值
        _key = ftok(pathname.c_str(), proj_id);
        if (_key == -1)
            ERR("key");

        // 创建/获取共享内存
        if (usertype == CREATOR)
            CreatShm();
        else if (usertype == USER)
            Getshm();
        else
            std::cout << "usertype error!" << std::endl;
        
        // 让进程与共享内存关联
        Attach();
    }

    ~shm() 
    {
        Detach();
        if(_usertype == CREATOR)
            Destory();
    }

    void* GetAdder()
    {
        return _start_mm;
    }

private:
    int _shmid;
    key_t _key;
    void *_start_mm;
    const std::string _usertype;
};

说明:我们将创建与关联共享内存等操作定义为类。首先,因为共享内存要有一个进程创建,一个进程获取。所以我们定义使用者类型,以区别调用shmget的选项。另外就是共享内存的声明周期,它应该由创建它的进程来释放,所以我们在析构时也要进行使用者类型的判断。

下面给出server和client进程进行通信的过程:

// server.cc

#include "shm.hpp"

int main()
{
    shm shm(DEFAULT_PATHNAME, DEFAULT_PROJ_ID, CREATOR);

    char* buf = (char*)shm.GetAdder();
    int cnt = 15;
    while(cnt--)
    {
        printf("client say->%s\n", buf);
        sleep(1);
    }

    return 0;
};
// client.cc

#include "shm.hpp"

int main()
{
    shm shm(DEFAULT_PATHNAME, DEFAULT_PROJ_ID, USER);

    char *msg = (char *)shm.GetAdder();
    int index = 0;
    for (int i = 0; i <= 10; i++)
    {
        msg[index++] = i + '0';
        sleep(1);
        msg[index] = 0;
    }

    return 0;
}

说明:我们让server来创建共享内存,client获取共享内存。server作为读端每一秒从共享内存中读入数据,而client作为写端,每隔一秒向共享内存中写入数据。我们这里将共享内存传出的地址强制类型转换为char* ,将其像字符串一样使用。

 当我们启动sever端的时候,我们此时还没有像共享内存中写入数据,他也会从中读取空白内容

此时我们在启动client端,开始向共享内存中写入数据

总结:利用共享内存进行进程间通信的优点就是快。数据传输速度快,一个进程写入了,另一个进程马上就可以拿到,并且共享内存的通信过程中没有使用系统调用接口哦。

但是因为读写速度很快,这就会导致数据的不一致!!!比如我们想写一个hello world,但是我才写了一个hello就被读端读取了。也就是说,共享内存没有保护机制使进程间进行同步,导致产生数据不一致的结果。

所以我们应该在写的时候不让读,读的时候不让写。我们可以利用我们之前实现的命名管道来模拟锁,以实现互斥性,即读写同一时间只有一个可以进行。

4.利用命名管道保护共享内存

管道通信时如果写端没有写内容,就会导致读端阻塞。所以我们可以让client向共享内存写的时候,写完想要写的内容之后,同时给管道文件写消息,通知server端。server端再从共享内存中读的时候,先会再命名管道处阻塞,当收到client发的消息了,这时就知道写好了,此时我们在读。

我们对命名管道的接口进行调整,因为本来就是为了充当锁,所以简单实现一下即可。

fifo.hpp

//void Read()
bool wait()
{
    int n = 0;
    return read(_fd, &n, sizeof(n));
}

//void Write()
void wakeup()
{
    int n = 1;
    write(_fd, &n, sizeof(n));
}

我们只向管道里写一个整数,作为通知的效果。

    // server.cc
    ...
    char* buf = (char*)shm.GetAdder();
    int cnt = 15;
    while(cnt--)
    {
        // 读的时候,先读管道文件,看看有没有内容
        // 有内容则表示client这一次已经写完了。
        if(f.wait())
        {
            printf("client say->%s\n", buf);
        }
    }
    ...

    //client.cc
    ...
    char *msg = (char *)shm.GetAdder();
    int index = 0;
    for(char c = 'A'; c <= 'Z'; ++c)
    {
        // 向共享内存中写内容
        msg[index++] = c;
        msg[index++] = c;
        msg[index] = 0;

        // 写完了,向管道文件中写内容,通知server端可以读了
        f.wakeup();
        sleep(1);
    }
    ...

 看下图执行过程:

 

以上,我们就借助命名管道,实现了共享内存在通信过程的数据不一致问题。

5.共享内存的内核数据结构体 

shmctl是用来控制共享内存的,我们除了用它来关闭共享内存,也可以用它来获取该共享内存的数据结构体。里面包含了该共享内存的一些信息。

struct shmid_ds {
    struct ipc_perm shm_perm;    /* Ownership and permissions */
    size_t          shm_segsz;   /* Size of segment (bytes) */
    time_t          shm_atime;   /* Last attach time */
    time_t          shm_dtime;   /* Last detach time */
    time_t          shm_ctime;   /* Creation time/time of last
                                               modification via shmctl() */
    pid_t           shm_cpid;    /* PID of creator */
    pid_t           shm_lpid;    /* PID of last shmat(2)/shmdt(2) */
    shmatt_t        shm_nattch;  /* No. of current attaches */
    ...
};

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 */
};
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

我们可以定义一个shmid_ds结构体作为输出型参数拿出该共享内存的具体数据。要拿出该数据shmctl传入的cmd得是IPC_STAT.

四.System V消息队列

1.消息队列理解

为了进行进程间通信,我们得让不同的进程看到同一份资源,如管道文件,共享内存。除了这两种外,我们还可以让两个进程在内存中看到同一个队列。而对于这个队列来说,通信的两个进程都可以向该队列中插入数据块。

但是插入时就有一个问题产生了:A与B进程借助消息队列进行通信,都向该队列中插入数据块,那么A进程怎么确定它拿到的数据块就是B进程写的呢?B进程如何确定呢?

所以,不同的进程在插入数据块时要带有数据类型,表明该数据块是谁写的。比如在数据块中有一个整型属性type,用不同的整型值来表示不同的进程的消息。这样A与B在进程通信的时候,想要获取对方的消息时,就可以根据type来获取对方的数据块了。

进程间通信是很常见的行为,也就是说同一时间内内存中可能有多个进程都在利用该消息队列进行通信,有的正在使用,有的刚被创建,有的则准备被销毁。所以操作系统也要对消息队列进行管理——先描述,再组织。

有了上面的问题,从而就衍生出了一个新的问题:既然内存空间中同时可能会存在多个消息队列,那么A、B进程怎么确保看到的是同一个队列呢?

key值!!!与共享内存一样,我们在创建消息队列的时候,也要在用户层约定一个key值,该key值可以在内存标识消息队列的唯一性。

2.消息队列接口 

因为消息队列也是遵循System V标准的,所以其接口与共享内存的接口非常相似。

0x1.msgget

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg)

msgget获取一个消息队列,key值表示消息队列的唯一性,借助ftok函数获取。

msgflg与shmget的选项一样IPC_CREAT和IPC_EXCL。使用方法也一致,创建全新的队列是还得指定权限。

返回值:返回一个消息队列的标识符,用户层通过该标识符来访问消息队列。

int msgid = msgget(key, IPC_CREAT | IPC_EXCL | 0666);

我们可以使用ipcs -q来查看操作系统当中的所有消息队列:

0x2.msgctl

通过msgctl来对消息队列进行管理

msgctl释放消息队列

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

// msqid 指定的消息队列
// cmd   要执行的操作,常用的有IPC_RMIN(释放消息队列), IPC_STAT(获取该消息队列的内核数据结构)
// buf   根据第二步的操作进行选择,输出型参数
int n = msgctl(msgid, IPC_RMID, NULL);

通过msgctl获取指定消息队列的内核数据结构

如下结构所示,因为消息队列和共享内存都是System V标准的,所以它们在管理上也都采取同样的方式进程管理,而他们的内核数据结构中都有ipc_perm这个结构。

struct msqid_ds
{
    struct ipc_perm msg_perm; /* Ownership and permissions */
    time_t msg_stime;         /* Time of last msgsnd(2) */
    time_t msg_rtime;         /* Time of last msgrcv(2) */
    time_t msg_ctime;         /* Time of creation or last
                                 modification by msgctl() */
    unsigned long msg_cbytes; /* # of bytes in queue */
    msgqnum_t msg_qnum;       /* # number of messages in queue */
    msglen_t msg_qbytes;      /* Maximum # of bytes in queue */
    pid_t msg_lspid;          /* PID of last msgsnd(2) */
    pid_t msg_lrpid;          /* PID of last msgrcv(2) */
};

struct ipc_perm
{
    key_t __key;          /* Key supplied to msgget(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 */
    unsigned short __seq; /* Sequence number */
};
msqid_ds q;
int n = msgctl(msgid, IPC_STAT, &q);

0x3.msgsnd、msgrcv

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

msgsnd和msgrcv一个是向消息队列中写入数据块,一个则是从消息队列中读取数据块。

msqid

消息队列的标识符,向指定的消息队列中写/读内容

msgp

往消息队列中写时要写入数据块

struct msgbuf {
    long mtype;       /* message type, must be > 0 */
    char mtext[1];    /* message data */
};

而数据块中除了消息正文外,还要包含数据类型,用来区分不同的进程的消息。

msgsz

msgsz一般指定的正文的大小!

msgflg

写/读时的选项,我们传0即可。 

消息队列与共享内存不同的就在于,消息队列读/写数据时使用的是系统调用。

五.互斥与同步 

在进程间通信中,进程间看到同一份资源是前提,但是如果不对该资源进行保护,就有可能导致数据不一致。共享内存就有这样的问题。

多个进程看到的同一份资源就是共享资源。而我们需要对这些共享资源进行保护。

被保护起来的共享资源叫做临界资源。

在进程中涉及到临界资源的访问的程序段叫做临界区。说人话就是,代码中用来访问临界资源的那部分就是临界区,自然,没有访问的临界资源的就是非临界区了。

那么我们如何保证访问临界区时的数据安全呢?加锁!

 当我们需要访问临界资源时,先申请锁,只有申请成功了才可以访问临界资源,这样就保证了同一时刻只有一个进程访问临界资源。但是锁对于所有的进程来说也是共享的,也是资源。所以锁的安全也要保护。那么怎么保证锁的安全呢?申请锁必须是原子的!

而常见的保护方式分为同步互斥

同步:多个执行流同时访问该临界资源,但是访问的时候具有一定的顺序性,不会同时访问。这样就可以保证数据的一致性。我们之前使用管道文件进行通信的时候就具有同步性质,当我们从管道文件中read时,如果没有向管道文件中写,read是会阻塞的。

互斥:任何时刻,只允许一个执行流访问临界资源。而上面所说的临界区的保护机制就是互斥。

原子性:即做一件事,要么就做完,要么就不做。只有这两个选项。

六.System V信号量

1.理解信号量

信号量本质上就是一个计数器!用于表明临界资源中,资源数量的多少。比如电影院,就是一个临界资源。一个厅有50个座,那么该电影厅的信号量就是50.

当需要访问临界资源时,都得先访问信号量,判断是否还有临界资源剩余,本质上就是对资源的预定机制。

而对于信号量来说,它也是所有进程都可以看见的共享资源,所有信号量也要保证安全。这就要求申请信号量时是原子的。申请信号量,信号量--,P操作;使用完毕,则信号量++,V操作。

当一个资源对应的信号量只有1/0时,也就是说这个共享资源时被当作整体使用的,这个信号量就被叫做二元信号量,本质上就是互斥。

当一个资源的信号量很多时,则表明这个共享资源内部被分成了很多个小份,可以供很多进程同时访问,但不能同时访问同一个子资源。 

 2.信号量和通信有什么关系?

上面说了这么多,好像没看出信号量和通信有什么关系。但是我们要注意通信的范畴,不是说只有进程间通信才算通信,同步与互斥也算是通信。

所以信号量其实是一个管理资源的工具,通过互斥或者同步机制来使进程可以安全的访问临界资源。

所以,信号量是用来辅助进程间通信的。我们可以先申请信号量,再申请临界资源。当有进程访问临界资源时,需要先申请信号量,申请成功了就访问;失败了,则阻塞挂起到该信号量的等待队列中,有信号量了就可以申请并访问临界资源了。

七.内核对IPC资源的管理

我们不论是在获取共享内存、消息队列还是信号量的内核数据结构的时候,它们的第一个成员都是ipc_perm。并且,在操作系统内核中,有一个全局的ipc_ids结构,该结构里面包含了一个柔性数组,该柔性数组存储的都是一个一个指向ipc_perm的指针。

所以,对于操作系统来说,这三种资源本质上都是一样的。通过全局的ipc_ids就可以找到对应的资源。 

但是我们在申请指定类型的资源的时候,操作系统内部创建的都是如下的结构体,但是我们怎么通过全局的ipc_ids找到对应的数据结构呢?

这就是因为这三个数据结构的第一个成员变量了。首先我们知道,对于一个结构体对象来说,该对象的地址和第一个成员的地址是一样的。所以在操作系统内部,我们将申请的指定资源的类型强制类型转化为ipc_perm*,这样就可以都存储在那个柔性数组中了。

所以我们在获取资源的内核数据结构的时候,本质上都是获取ipc_perm*,然后根据获取的资源的类型进行强制类型转化为指定的类型。

 

 了解了内核对ipc资源的管理机制之后,我们仔细观察一下共享内存的内核数据结构中有一个file*的指针。

所以共享内存其实是借助文件系统实现的,那部分物理内存其实就是文件的缓冲区。 创建共享内存之后,有了struct file对象,然后进行虚拟地址和文件缓冲区之间的页表映射关系。这样我们就可以拿着虚拟地址访问对应的文件缓冲区了,也就是共享内存!


网站公告

今日签到

点亮在社区的每一天
去签到