Linux系统之进程间通信

1 介绍

1.1 进程间通信的概念

在Linux系统中,进程间通信(Inter-Process Communication,IPC)是不同进程之间进行数据交换和同步的一种机制

1.2 为什么需要进程间通信

进程间通信(IPC)是由于在计算机系统中,多个进程可能同时运行,而这些进程可能需要共享信息、协同工作或者进行数据交换,以下是一些常见的原因,解释了为什么需要进程间通信:

(1)资源共享: 进程间通信允许多个进程共享系统资源,如文件、设备、内存等。这种共享资源的方式使得系统可以更高效地利用资源,避免了每个进程都维护一份独立的资源拷贝;
(2)并发执行: 在多任务操作系统中,多个进程可能同时运行。通过进程间通信,这些进程可以同步执行,共享信息,以便更好地协调工作。
(3)模块化设计: 大型软件系统通常被分解成多个模块或组件。这些模块可能运行在独立的进程中,通过IPC进行通信,使得系统的设计更具模块化和可维护性。
(4)数据传递: 进程间通信提供了一种机制,允许进程之间传递数据,从而使得它们能够相互交流和共享信息。这对于实现分布式计算、网络通信等场景是至关重要的。

1.3 常见的进程间通信方式

1.3.1 管道

(1)匿名管道(Anonymous Pipes): 用于在具有亲缘关系的进程之间进行通信,是一种单向通信机制。
(2)命名管道(Named Pipes或FIFO): 允许无关的进程之间进行通信,是一种基于文件系统的命名通信机制。

1.3.2 System V IPC

这是一组在System V风格的UNIX操作系统中引入的进程间通信机制。

(1)System V 消息队列: 使用消息队列进行进程间的消息传递。
(2)System V 共享内存: 通过共享内存区域实现多个进程对相同数据的访问。
(3)System V 信号量: 通过信号量来进行对共享资源的访问控制。

1.3.2 POSIX IPC

这是一组遵循POSIX标准的IPC机制,可在不同的操作系统上使用。

(1)消息队列: 类似于System V消息队列,但遵循POSIX标准。
(2)共享内存: 类似于System V共享内存,但遵循POSIX标准。
(3)信号量: 类似于System V信号量,但遵循POSIX标准。
(4)互斥量(Mutex): 用于实现互斥,防止多个进程同时访问共享资源。
(5)条件变量: 用于实现线程间的条件同步。
(6)读写锁(Read-Write Lock): 允许多个进程/线程同时读取一个共享资源,但只有一个进程/线程能够写入。

1.4 进程间通信的原理

其实,在设计 IPC 方式时,其中的两个关键点就是共享与通信,这两个概念是进程间通信的基础,不同的通信方式只是在实现这两个概念时采用了不同的技术手段。

共享:共享其实是IPC设计的一个难点,因为进程在设计之初就具有独立性,共享的方式多种多样,其中一种常见的方式是共享内存。通过共享内存,多个进程可以直接读写同一块内存区域,实现了高效的数据共享。此外,共享还可以通过共享文件等方式实现,共享的本质就是让不同的进程看见同一份资源。

**通信:**通信是另一个 IPC 中不可或缺的概念,通信机制使得进程能够感知彼此的状态、协调行动,并确保数据的正确传递。通信机制不仅仅是信息传递,还包括了进程同步的概念。通过信号量、互斥量、条件变量等机制,可以实现对共享资源的安全访问,防止多个进程同时对同一资源进行写操作,确保数据的一致性。

2 管道

2.1 概念

管道想必大家都不陌生,它是Unix中最古老的进程间通信的形式,允许一个进程的输出成为另一个进程的输入。在操作系统中,管道是一种特殊的文件,常用于在父进程和子进程之间或者在两个独立的进程之间传递数据。

$ command1 | command2

在命令行中,我们常常用管道连接多个命令,形成了一个数据处理的流水线。

2.2 分类

一般而言,管道可以分为两类,分别是匿名管道和命名管道:

管道匿名管道(Anonymous Pipe)命名管道(Named Pipe)
关系用于有亲缘关系(父子进程关系)的进程之间通信。用于无亲缘关系的进程之间通信。
创建方式通过 pipe 系统调用创建。通过 mkfifo 系统调用创建,也称为FIFO文件。
流向是单向的,通常从一个进程的输出到另一个进程的输入。可以是单向或双向的,允许数据在两个方向上流动。
生命周期随着进程的创建而创建,随着进程的终止而关闭。持久存在于文件系统中,需要显式删除。

而我们常用的“|”(管道符号)用于连接两个命令,其实就是创建一个匿名管道,使得 command1 的输出被传输到 command2 的输入。

2.2.1 匿名管道

这里我们需要补充一点:在fork创建新进程式,父进程会将自己的文件描述符表一并复制到新的子进程中。出于这样的原因我们就可以通过在父进程中指定两个不同的fd指向同一份文件,其中一个代表读端,另一个代表写端,再调用fork()就实现了让两个进程共享同一份资源的工作。

具体流程我们可以参考下图:文件描述符。

img

(1)pipe函数创建匿名管道

而想要实现指定两个fd,分别代表读端与写端的话,我们可以调用pipe函数来实现,这里我们可以先使用man手册来查看pipe。

img

这里我们可以看到pipe函数的原型:

int pipe(int pipefd[2])

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

数组元素含义
pipefd[0]管道读端的文件描述符
pipefd[1]管道写端的文件描述符

**返回值:**pipe函数调用成功时返回0,调用失败时返回-1。

(2)匿名管道实现父子进程对话

**注意:**管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。这里我们简单实现一个父子进程通过匿名管道通信的程序:

#include <iostream>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
#include <cstdio>
#define buffer_size 1024 * 8
using namespace std;
int main()
{
    int pipefd[2] = {0};
    if (pipe(pipefd))
    {
        perror("pipe");
    }
    pid_t id = fork();
    if (id == -1)
    {
        perror("fork");
    }
    if (id == 0)
    {
        // 子进程读
        close(pipefd[1]);
        // 写入的一方,fd没有关闭,如果有数据,就读,没有数据就等
        // 写入的一方,fd关闭, 读取的一方,read会返回0,表示读到了文件的结尾!
        char get_buffer[buffer_size];
        const string message = "我是子进程,我收到了消息";
        while(1)
        {
            ssize_t s =read(pipefd[0],get_buffer,buffer_size-1);
            if(s>0)
            {
                get_buffer[s]=0;
                cout <<message <<'['<< getpid() << "]" << get_buffer << endl;
            }
            else
            {
                cout << "writer quit(father), me quit!!!" << endl;
                break;
            }
        }
    }
    else
    {
        // 父进程写
        close(pipefd[0]);
        const string message = "我是父进程,我在给你发消息";
        char send_buffer[buffer_size];
        int count = 0;
        while (1)
        {
            snprintf(send_buffer, sizeof send_buffer, "%d:%s|%d", getpid(), message.c_str(), count++);
            write(pipefd[1], send_buffer, strlen(send_buffer));
            if (count == 5)
            {
                cout << "writer quit(father)" << endl;
                break;
            }
            sleep(1);
        }
        close(pipefd[1]);
        pid_t ret = waitpid(id, nullptr, 0);
    }
    return 0;
}

运行结果如下:

img

(3)补充三种通讯方式

三种通讯方式特点
单工通信(Simplex Communication)单工通信是一种单向传输方式,通信的双方中,一方负责发送,另一方负责接收;
发送端和接收端之间的通信是单向的,类似于广播或单向广播。
半双工通信(Half Duplex Communication)半双工通信允许数据在一个信号载体的两个方向上传输,但不能同时进行;
在一段时间内,通信的一方可以发送数据,而另一方可以接收数据。然后它们可以交替改变角色。
全双工通信(Full Duplex Communication)全双工通信允许数据在两个方向上同时传输,即通信的双方都可以同时发送和接收数据;
这种方式的通信能力相当于两个单工通信方式的结合,可以实现双向的、瞬时的信号传输。

匿名管道是一种半双工通信方式,它允许数据在一个方向上传输,但不能同时进行双向传输。如果需要实现双方之间的双向通信,通常需要建立两个管道;例如,可以通过以下方式创建两个管道:

#include <unistd.h>
 
int main() {
    int fd1[2]; // 父进程写,子进程读
    int fd2[2]; // 父进程读,子进程写
 
    // 创建第一个管道
    if (pipe(fd1) == -1) {
        // 处理错误
    }
 
    // 创建第二个管道
    if (pipe(fd2) == -1) {
        // 处理错误
    }
 
    // 进行fork,创建子进程
    pid_t pid = fork();
 
    if (pid == -1) {
        // 处理错误
    } else if (pid == 0) {
        // 子进程,关闭不需要的管道端
        close(fd1[1]); // 关闭写入端
        close(fd2[0]); // 关闭读取端
 
        // 子进程可以使用fd1[0]读取,使用fd2[1]写入
    } else {
        // 父进程,关闭不需要的管道端
        close(fd1[0]); // 关闭读取端
        close(fd2[1]); // 关闭写入端
 
        // 父进程可以使用fd1[1]写入,使用fd2[0]读取
    }
 
    // 其他逻辑...
    return 0;
}

(4)匿名管道的特点总结

i.管道内部自带同步与互斥机制

临界资源是指在多个进程或线程中被共享的一种资源,管道是一种临界资源,为了避免同时读写、交叉读写以及读取到的数据不一致等问题,所以同一时刻只允许一个进程对其进行写入或读取操作。内核会对管道操作进行同步与互斥,确保两个或多个进程在运行过程中按照预定的先后次序进行操作。
同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。

实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。

**ii.管道的生命周期随进程:**管道本质上是通过文件进行通信的,因此管道的生命周期随着相关进程的结束而结束。当所有打开该管道的进程退出时,相关的文件资源会被释放。这也意味着管道是局限于本地父子进程通信的一种方式。

iii.管道提供的是流式服务:
管道提供的是流式服务,这意味着数据在管道中没有明确的分割,一次可以拿取任意数量的数据。底层只是提供一个数据通信的信道,而不关心数据的格式和细节。这被称为面向字节流。
流式服务: 数据没有明确的分割,一次拿多少数据都行;
数据报服务: 数据有明确的分割,拿数据按报文段拿。

**iv.管道是半双工通信的:**管道是半双工通信的,意味着数据只能在一个方向上传输。如果需要双方通信,通常需要建立两个管道,一个用于一个方向的通信。

(5)匿名管道的4中特殊情况:

i.写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被阻塞挂起,直到管道里面有数据后,读端进程才会被唤醒。
ii.读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被阻塞挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
iii.写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起
iv.读端进程将读端关闭,而写端进程还在一直向管道写入数据,没有进程读取,那么写入的数据就没有意义,那么操作系统会将写端进程杀掉

2.2.2 命名管道

(1)命名管道的作用

匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,而命名管道可以实现两个毫不相关进程之间的通信,命名管道在磁盘上有一个简单的映像,通过一个特定的文件名标识。这种映像使得进程可以通过文件系统的方式访问命名管道,而不需要直接操作内存;相较于使用普通文件进行通信,命名管道提供了更安全的通信机制。由于管道是一种特殊的文件,而且其映像大小永远为0,因此通信数据不会被刷新到磁盘,减少了对数据的持久性存储,有助于提高通信的安全性。

(2)mkfifo命令创建命名管道

img

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

mkfifo pipe

img

这里的p类型,指的就是我们的命名管道文件类型;在此基础上我们可以使用这个命名管道文件实现两个进程之间的通信:我们在一个进程(进程A)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程B)当中用cat命令从命名管道当中进行读取。

while :; do echo "pipe test" ;sleep 1; done >namedpipe

img

使用cat命令的读进程主动退出,另一个写进程就被杀掉了; 因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。(当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉)

img

(3)mkfifo函数创建命名管道

img

mkfifo的函数原型:

int mkfifo(const char *pathname, mode_t mode)

参数:pathname 是一个包含文件路径的字符串,指定了要创建的命名管道的名称;mode 是一个表示权限的参数,指定了创建的文件的访问权限,类似于 chmod 函数。

**返回值:**如果 mkfifo 成功创建了命名管道,则返回值为 0;如果 mkfifo 失败,返回值为 -1,并且 errno 会被设置为指示具体错误的整数值

(4)命名管道的打开规则

在我们创建好一个命名管道后,我们需要使用之前在提到open()来打开,这里在打开文件时,我们需要考虑open的第二个参数flags的影响,在默认情况下,命名管道(FIFO)的打开是阻塞的。

读操作:
i.O_NONBLOCK 未启用(阻塞): 如果当前打开操作是为读而打开 FIFO,而 O_NONBLOCK 未启用,那么读操作将一直阻塞,直到有相应的进程为写而打开该 FIFO;
ii.O_NONBLOCK 启用: 如果 O_NONBLOCK 启用,读操作将立即返回成功。如果 FIFO 中没有数据可读,返回的读操作结果可能是空。

写操作:
i.O_NONBLOCK 未启用(阻塞): 如果当前打开操作是为写而打开 FIFO,而 O_NONBLOCK 未启用,那么写操作将一直阻塞,直到有相应的进程为读而打开该 FIFO。
ii.O_NONBLOCK 启用: 如果 O_NONBLOCK 启用,写操作将立即返回失败,错误码为 ENXIO。这是因为没有相应的进程为读而打开该 FIFO。

演示如下:

#include <fcntl.h>
#include <unistd.h>
 
int main() {
    const char *fifoPath = "/tmp/myfifo";
 
    // 打开命名管道,使用 O_NONBLOCK 标志启用非阻塞模式
    int fd = open(fifoPath, O_RDONLY | O_NONBLOCK);
 
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }
 
    // 在这里可以进行读操作,不会阻塞
 
    close(fd);
 
    return 0;
}

除此之外,我们还使用 fcntl 函数在运行时更改属性:

#include <fcntl.h>
#include <unistd.h>
 
int main() {
    const char *fifoPath = "/tmp/myfifo";
 
    // 打开命名管道
    int fd = open(fifoPath, O_RDONLY);
 
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }
 
    // 使用 fcntl 函数启用非阻塞模式
    int flags = fcntl(fd, F_GETFL);
    if (flags == -1) {
        perror("fcntl");
        close(fd);
        exit(EXIT_FAILURE);
    }
 
    flags |= O_NONBLOCK;
    if (fcntl(fd, F_SETFL, flags) == -1) {
        perror("fcntl");
        close(fd);
        exit(EXIT_FAILURE);
    }
 
    // 在这里可以进行读操作,不会阻塞
 
    close(fd);
 
    return 0;
}

(5)命名管道实现server&client

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

#include "Common.hpp"
#include <sys/wait.h>
 
static void getMessage(int fd)
{
    char buffer[SIZE];
    while (true)
    {
        memset(buffer, '\0', sizeof(buffer));
        ssize_t s = read(fd, buffer, sizeof(buffer) - 1);
        if (s > 0)
        {
            cout <<"["  << getpid() << "] "<< "client say> " << buffer << endl;
        }
        else if (s == 0)
        {
            // end of file
            cerr <<"["  << getpid() << "] " << "read end of file, clien quit, server quit too!" << endl;
            break;
        }
        else
        {
            // read error
            perror("read");
            break;
        }
    }
}
 
int main()
{
    // 1. 创建管道文件
    if (mkfifo(ipcPath.c_str(), MODE) < 0)
    {
        perror("mkfifo");
        exit(1);
    }
 
    Log("创建管道文件成功", Debug) << " step 1" << endl;
 
    // 2. 正常的文件操作
    int fd = open(ipcPath.c_str(), O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        exit(2);
    }
    Log("打开管道文件成功", Debug) << " step 2" << endl;
 
    int nums = 3;
    for (int i = 0; i < nums; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            // 3. 编写正常的通信代码了
            getMessage(fd);
            exit(1);
        }
    }
    for(int i = 0; i < nums; i++)
    {
        waitpid(-1, nullptr, 0);
    }
    // 4. 关闭文件
    close(fd);
    Log("关闭管道文件成功", Debug) << " step 3" << endl;
 
    unlink(ipcPath.c_str()); // 通信完毕,就删除文件
    Log("删除管道文件成功", Debug) << " step 4" << endl;
 
    return 0;
}

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

#include "Common.hpp"
 
int main()
{
    // 1. 获取管道文件
    int fd = open(ipcPath.c_str(), O_WRONLY);
    if(fd < 0)
    {
        perror("open");
        exit(1);
    }
 
    // 2. ipc过程
    string buffer;
    while(true)
    {
        cout << "Please Enter Message Line :> ";
        std::getline(std::cin, buffer);
        write(fd, buffer.c_str(), buffer.size());
    }
 
    // 3. 关闭
    close(fd);
    return 0;
}

对于如何让客户端和服务端使用同一个命名管道文件,这里我们可以让客户端和服务端包含同一个头文件,该头文件当中提供这个共用的命名管道文件的文件名,这样客户端和服务端就可以通过这个文件名,打开同一个命名管道文件,进而进行通信了。

共用头文件的代码如下:

#pragma once
 
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "Log.hpp"
 
using namespace std;
 
#define MODE 0666
#define SIZE 128
 
string ipcPath = "./fifo.ipc";

我们再定义了一个简单的日志系统,其中包括不同级别的日志输出,如Debug、Notice、Warning和Error,具体实现如下:

#pragma once
 
#include <iostream>
#include <ctime>
 
#define Debug   0
#define Notice  1
#define Warning 2
#define Error   3
 
 
const std::string msg[] = {
    "Debug",
    "Notice",
    "Warning",
    "Error"
};
 
std::ostream &Log(std::string message, int level)
{
    std::cout << " | " << (unsigned)time(nullptr) << " | " << msg[level] << " | " << message;
    return std::cout;
}

最后我们先将服务器程序运行起来,再运行客服端程序,看一下最后的效果:

img

最后我们可以看到客服端输入的文字在服务器上都成功显示,并且当客服端退出时,服务器也完成了关闭和删除管道文件的工作。

3 system V共享内存

3.1 System V共享内存简介

前面我们提到过进程间通信为了实现资源共享,有共享内存与共享文件两种常见的解决思路,其中我们上面讲到的匿名管道和命名管道都是基于共享文件而得到的解决方案,而这里我们将要谈到的System V共享内存则顾名思义是基于共享内存的一种解决方案。

System V共享内存是一种IPC机制,属于System V IPC的一部分。它允许不同的进程共享同一块物理内存区域,以实现数据的高效传递。相较于其他IPC方式,System V共享内存提供了直接的内存访问,因此在性能上具有优势。

3.2 共享内存的基本原理

共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。

img

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

3.3 共享内存的内核数据结构

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

共享内存的内核数据结构如下:位于/usr/include/linux/shm.h

struct shmid_ds {
    struct ipc_perm     shm_perm;       /* 操作权限 */
    int                 shm_segsz;      /* 段的大小(字节) */
    __kernel_time_t     shm_atime;      /* 上次附加时间 */
    __kernel_time_t     shm_dtime;      /* 上次分离时间 */
    __kernel_time_t     shm_ctime;      /* 上次更改时间 */
    __kernel_ipc_pid_t  shm_cpid;       /* 创建者的进程ID */
    __kernel_ipc_pid_t  shm_lpid;       /* 上一次操作的进程ID */
    unsigned short      shm_nattch;     /* 当前的附加次数 */
    unsigned short      shm_unused;     /* 兼容性 */
    void                *shm_unused2;   /* 同上 - DIPC使用 */
    void                *shm_unused3;   /* 未使用 */
};

当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都有一个key值,这个key值用于标识系统中共享内存的唯一性。

可以看到上面共享内存数据结构的第一个成员是shm_perm,shm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:位于/usr/include/linux/ipc.h。

struct ipc_perm {
    __kernel_key_t  key;    /* 键值 */
    __kernel_uid_t  uid;    /* 所有者的用户ID */
    __kernel_gid_t  gid;    /* 所有者的组ID */
    __kernel_uid_t  cuid;   /* 创建者的用户ID */
    __kernel_gid_t  cgid;   /* 创建者的组ID */
    __kernel_mode_t mode;   /* 权限掩码 */
    unsigned short  seq;    /* 序列号 */
};

3.4 共享内存的基本操作

(1)shmget申请共享内存

使用 shmget 函数创建或获取一个已存在的共享内存区域,需要指定内存大小和一些标志位。

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

参数: **key-**用于标识共享内存的唯一键值;size-共享内存的大小;**shmflg-**创建共享内存的方式。

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

**注意:**对于shmflg,在这里只说两个IPC_CREAT 和 IPC_EXCL。

img

i.IPC_CREAT:单独使用,或者shmflg为0,则表示如果不存在为key的共享内存,就会直接创建,如果存在了,则直接返回当前已经存在的共享内存(基本不会空手而归);
ii.IPC_EXCL:单独使用没有意义;
iii.IPC_CREAT | IPC_EXCL:如果不存在为key的共享内存,则创建。反之则报错。(意义:如果我调用成功,得到的一定是一个最新的,没有被别人使用过的共享内存!)

key相当于唯一标识符ID,需要用户自己填入。理论来讲,用户可以随便填什么值,具体是几并不重要,重要的是它和其他key不一样。但难免会填写的值与其他的key冲突,所以我们一般使用ftok()函数获取key,ftok 是一个函数,通常用于生成一个用于标识 System V IPC 对象的键值(key)。它的原型如下:

key_t ftok(const char *pathname, int proj_id)

参数:pathname:一个指向存在的文件的路径名的指针,可以是任何有效的路径。proj_id:一个用户定义的整数,用于在特定路径下创建唯一的 IPC 键。

**返回值:**如果成功,返回一个生成的键值;如果失败,返回 -1,并设置 errno 来指示错误原因。

这里我们演示一下内存申请:

#include<stdio.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#define PATH_NAME "./"
#define PROJ_ID 0X777
#define SIZE 4096
int main()
{
    key_t key = ftok(PATH_NAME, PROJ_ID);
    if(key < 0)
    {
        perror("ftok");
        return 1;
    }
    int shmid = shmget(key, SIZE, IPC_CREAT|IPC_EXCL);
    if(shmid < 0)
    {
        perror("shmget");
        return 2;
    }
    printf("key: %u, shmid: %d\n", key, shmid);
    return 0;
}

运行程序:

img

第一次执行之后,成功打印了key和shimd,我们发现shimd默认是从0开始的;后面程序执行为什么会打印"shmget:file exists"呢?此时说明共享内存已经被创建出来了。通过指令ipcs -m可以查看被创建出来的共享内存。

img

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

  • -q:显示消息队列的信息
  • -s:显示信号量集的信息
  • -m:显示共享内存段的信息
  • -t:以可读格式显示时间戳

img

ipcs命令输出的每列信息的含义如下:

标题含义
key系统区别各个共享内存的唯一标识
shmid共享内存的用户层id(句柄)
owner共享内存的拥有者
perms共享内存的权限
bytes共享内存的大小
nattch关联共享内存的进程数
status共享内存的状态

(2)shmat映射共享内存

使用 shmat 函数将共享内存映射到进程的地址空间:

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

参数: shmid-共享内存的标识符;shmaddr-映射的地址,通常设为 NULL 由系统选择;shmflg-标志位,通常设置为 0

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

下面是在加上shmat后的代码:

#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/shm.h>
 
#define PATH_NAME "./"
#define PROJ_ID 0X777
#define SIZE 4096
 
int main() {
    key_t key = ftok(PATH_NAME, PROJ_ID);
    if (key < 0) {
        perror("ftok");
        return 1;
    }
 
    int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL);
    if (shmid < 0) {
        perror("shmget");
        return 2;
    }
 
    printf("key: %u, shmid: %d\n", key, shmid);
 
    // Attach the shared memory segment
    void *shm_addr = shmat(shmid, NULL, 0);
    if (shm_addr == (void *)-1) {
        perror("shmat");
        return 3;
    }
 
    // Use the shared memory...
 
    // Detach the shared memory segment
    if (shmdt(shm_addr) == -1) {
        perror("shmdt");
        return 4;
    }
 
    return 0;
}

运行后:

img

原来: 代码运行后发现关联失败,主要原因是我们使用shmget函数创建共享内存时,并没有对创建的共享内存设置权限,因此进程没有权限关联该共享内存。我们应该在使用shmget函数创建共享内存时,在其第三个参数处设置共享内存创建后的权限。

int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666)

(3)shmdt解除映射

使用 shmdt 函数解除共享内存与进程地址空间的映射:

int shmdt(const void *shmaddr)

**参数:**待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。

返回值:shmdt调用成功,返回0;shmdt调用失败,返回-1。

(4)shmctl删除共享内存

通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的;此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。

使用ipcrm -m shmid命令释放指定id的共享内存资源:

img

使用 shmctl 函数删除共享内存:

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

**参数:**shmid-共享内存的标识符;cmd-控制命令,通常设为 IPC_RMID 表示删除共享内存;buf-共享内存信息结构体。

**返回值:**成功返回0;失败返回-1。

3.5 共享内存实现server&client

代码和命名管道实现server&client类似,实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,我们需要让服务端运行后创建一个内存空间,之后服务端就可以从该内存空间当中 取客户端发来的通信信息了。

#include "comm.hpp"
Init init;
string TransToHex(key_t k)
{
    char buffer[32];
    snprintf(buffer, sizeof buffer, "0x%x", k);
    return buffer;
}
int main()
{
    
    // 1. 创建公共的Key值
    key_t k = ftok(PATH_NAME, PROJ_ID);
    assert(k != -1);
 
    Log("create key done", Debug) << " server key : " << TransToHex(k) << endl;
 
    // 2. 创建共享内存 -- 建议要创建一个全新的共享内存 -- 通信的发起者
    int shmid = shmget(k, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666); //
    if (shmid == -1)
    {
        perror("shmget");
        exit(1);
    }
    Log("create shm done", Debug) << " shmid : " << shmid << endl;
 
    // sleep(10);
    // 3. 将指定的共享内存,挂接到自己的地址空间
    char *shmaddr = (char *)shmat(shmid, nullptr, 0);
    Log("attach shm done", Debug) << " shmid : " << shmid << endl;
    // sleep(10);
    int fd = OpenFIFO(FIFO_NAME, READ);
    for (;;)
    {
        Wait(fd);
 
        // 临界区
        printf("%s\n", shmaddr);
        if (strcmp(shmaddr, "quit") == 0)
            break;
        // sleep(1);
    }
    // 4. 将指定的共享内存,从自己的地址空间中去关联
    int n = shmdt(shmaddr);
    assert(n != -1);
    (void)n;
    Log("detach shm done", Debug) << " shmid : " << shmid << endl;
    // sleep(10);
 
    // 5. 删除共享内存,IPC_RMID即便是有进程和当下的shm挂接,依旧删除共享内存
    n = shmctl(shmid, IPC_RMID, nullptr);
    assert(n != -1);
    (void)n;
    Log("delete shm done", Debug) << " shmid : " << shmid << endl;
    CloseFifo(fd);
    return 0;
}

客户端的代码如下:

#include "comm.hpp"
int main()
{
    Log("child pid is : ", Debug) << getpid() << endl;
    key_t k = ftok(PATH_NAME, PROJ_ID);
    if (k < 0)
    {
        Log("create key failed", Error) << " client key : " << k << endl;
        exit(1);
    }
    Log("create key done", Debug) << " client key : " << k << endl;
    int shmid = shmget(k, SHM_SIZE, 0);
    if (shmid < 0)
    {
        Log("create shm failed", Error) << " client key : " << k << endl;
        exit(2);
    }
    Log("create shm success", Error) << " client key : " << k << endl;
    char *shmaddr = (char *)shmat(shmid, nullptr, 0);
    if (shmaddr == nullptr)
    {
        Log("attach shm failed", Error) << " client key : " << k << endl;
        exit(3);
    }
    Log("attach shm success", Error) << " client key : " << k << endl;
    int fd = OpenFIFO(FIFO_NAME, WRITE);
    while (true)
    {
        ssize_t s = read(0, shmaddr, SHM_SIZE - 1);
        if (s > 0)
        {
            shmaddr[s - 1] = 0;
            Signal(fd);
            if (strcmp(shmaddr, "quit") == 0)
                break;
        }
    }
    CloseFifo(fd);
    int n = shmdt(shmaddr);
    assert(n != -1);
    Log("detach shm success", Error) << " client key : " << k << endl;
    return 0;
}

对于如何让客户端和服务端使用同内存空间,这里我们可以让客户端和服务端包含同一个头文件,该头文件当中提供这个共用PROJ_ID和PATH_NAME,这样客户端和服务端通过ftok才会生成同样的值,也就能实现看到同一处内存资源,共用头文件的代码如下:(注意:此处的PATH_NAME改成自己的地址)

#pragma once
 
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <cassert>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "Log.hpp"
 
using namespace std; 
 
#define PATH_NAME "/home/Chris" //改成自己的地址
#define PROJ_ID 0x66
#define SHM_SIZE 4096 // 共享内存的大小,最好是页(PAGE: 4096)的整数倍
 
#define FIFO_NAME "./fifo"
 
class Init
{
public:
    Init()
    {
        umask(0);
        int n = mkfifo(FIFO_NAME, 0666);
        assert(n != 0);
        (void)n;
        Log("create fifo success", Notice) << "\n";
    }
    ~Init()
    {
        unlink(FIFO_NAME);
        Log("remove fifo success", Notice) << "\n";
    }
};
 
#define READ O_RDONLY
#define WRITE O_WRONLY
 
int OpenFIFO(std::string pathname, int flags)
{
    int fd = open(pathname.c_str(), flags);
    assert(fd >= 0);
    return fd;
}
 
void Wait(int fd)
{
    Log("等待中....", Notice) << "\n";
    uint32_t temp = 0;
    ssize_t s = read(fd, &temp, sizeof(uint32_t));
    assert(s == sizeof(uint32_t));
    (void)s;
}
 
void Signal(int fd)
{
    uint32_t temp = 1;
    ssize_t s = write(fd, &temp, sizeof(uint32_t));
    assert(s == sizeof(uint32_t));
    (void)s;
    Log("唤醒中....", Notice) << "\n";
}
 
void CloseFifo(int fd)
{
    close(fd);
}

我们再定义了一个简单的日志系统,其中包括不同级别的日志输出,如Debug、Notice、Warning和Error,具体实现如下:

#ifndef _LOG_H_
#define _LOG_H_
 
#include <iostream>
#include <ctime>
 
#define Debug   0
#define Notice  1
#define Warning 2
#define Error   3
 
 
const std::string msg[] = {
    "Debug",
    "Notice",
    "Warning",
    "Error"
};
 
std::ostream &Log(std::string message, int level)
{
    std::cout << " | " << (unsigned)time(nullptr) << " | " << msg[level] << " | " << message;
    return std::cout;
}
 
 
#endif

4 共享内存和管道的比较

比较维度共享内存管道
速度一旦共享内存被创建,进程可以直接对地址空间进行操作,不再需要系统调用,因此是所有进程间通信方式中最快的一种。操作是在用户层进行的,没有涉及内核态到用户态的转化。管道的通信需要使用系统调用接口进行read、write等操作,相对而言速度较慢
数据拷贝过程写进程直接将数据写到共享内存中,而读进程直接从共享内存中读数据,拷贝次数相对较少。read 操作将数据从内核缓冲区复制到进程缓冲区,write 操作将进程缓冲区复制到内核缓冲区。涉及两次拷贝。
同步与互斥机制不提供任何保护机制,包括同步与互斥;需要程序员自己实现同步与互斥。自带同步与互斥机制,通过管道的阻塞特性来实现进程之间的同步。
优缺点优点:通信速度快,拷贝次数少。
缺点:没有提供同步与互斥机制,程序员需要自己实现。
优点:提供了同步与互斥机制,相对简单易用。
缺点:通信速度相对较慢,涉及较多的数据拷贝过程。

总体而言,共享内存在通信速度和拷贝次数上具有优势,但缺乏同步与互斥机制,而管道提供了这些机制但相对慢一些。选择使用哪种通信方式通常取决于具体的应用场景和需求。

文章已获作者授权转载,版权归原作者所有,如有侵权,与本账号无关,可联系删除。 原文作者:Chris在Coding
原文链接:https://chris-coding.blog.csdn.net/article/details/135078313#t1

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值