Linux 进程间通信

1. 匿名管道

背景:Linux 2.6

所谓通信,就是让一方能够看到另一方发送的数据,也就是,两个进程间要通信,首先要让这两个进程能够访问同一份空间,而访问的空间的不同也就决定了通信方式的不同

1.1 前言

在这里插入图片描述

匿名管道,是一种半双工的通信机制。可以把管道想象成水管,而水在水管中的流动都是单向的,管道也一样

  • 半双工:只允许数据在两个方向之间进行单向传输,同一时刻,不同方向的数据不能同时传输

并且匿名管道只能用于具有父子关系或者具有共享父进程的进程之间的通信。

1.2 pipe

先来认识创建匿名管道的接口 int pipe(int pipefd[2])

  • 参数int pipefd[2] 是一个输入输出型参数,使用的时候,创建一个 int[2] 参入,当函数执行完成的时候,返回值不为 0 说明管道创建失败
  • 否则进程会在内核打开一个文件,然后int[2] 会被赋值,pipe[0] 表示该文件读端的文件描述符,pipe[1] 表示该文件写的端文件描述符。⭐下标 0 固定表示读端,下标 1 固定表示写端
  • 也就是说,该进程会打开一个文件,并且进程的文件描述符表中多个两个元素,下标分为是 pipe[0]pipe[1]

使用的时候通常是用于父子进程间的通信,父进程创建完管道之后,创建子进程,这时候会发生子进程的拷贝,而子进程也会拷贝父进程的文件描述符表Important

所以父子间匿名管道通信的时候,只需要让一方关闭读端,另一方关闭写端,就可以实现进程的单向通信

放一张图片理解一下匿名管道的大致(来源:Bing 图片

在这里插入图片描述

以下是简单的父子进程利用匿名管道通信的代码,
📃任务:子进程给父进程发送三次 “阿巴阿巴阿巴”
代码加了详细注释:

int main()
{
    // 进程间通信 —— 匿名管道   
    // 场景:子进程一直给父进程发送 : “阿巴阿巴阿巴”
    // 1. 创建管道
    int pipefd[2] = {0};
    if (pipe(pipefd) != 0) {    // 不为 0 说明创建管道失败
        return 1;
    }   // 否则创建成功

    // 2. 创建子进程
    int pid = fork();
    if (pid == 0) {             // 子进程
        close(pipefd[0]);       // 子进程要写,那么关闭读端
        char* input = "阿巴阿巴阿巴";
        int cnt = 3; // 发送 3 次
        while (cnt -- > 0) {
            write(pipefd[1], input, strlen(input));
        }
        cout << "son write over over" << endl;    // 发送完成
        close(pipefd[1]);       // 执行完毕,关闭写端口
        exit(0);
    }
    else {                      // 父进程
        close(pipefd[1]);       // 父进程要读,那么关闭写端
        char output[1024];      // 接收收到的数据
        while (true) {
            // 向该文件中读取, 第二个参数表示读取到的数据放在哪, 
            //     第三个数据表示预期读取多少个字, 返回值表示实际读取到的字节
            ssize_t rd = read(pipefd[0], output, sizeof(output) - 1);   
            if (rd > 0) {       // 说明有读取到数据, 直接打印
                output[rd] = '\0'; // 人工添加结束符
                cout << output << endl;
            }
            else if (rd == 0) { // 写端关闭,读取结束
                // 读取结束
                cout << "father read over over" << endl;
                break;
            }
        }

        close(pipefd[0]);       // 执行完毕,关闭读窗口
    }
    waitpid(pid, nullptr, 0);   // 等待子进程, 回收相关资源
    return 0;
}

执行程序,执行结果如下:

在这里插入图片描述

  • 需要注意,read 读取接口是阻塞式等待,有数据就读取,没数据就阻塞,而当文件对应的写端关闭的时候,read 就会返回 0 ,表示该文件不会再写入数据了,那么读端也就可以关闭了

1.3 底层原理

接下来讲讲匿名管道在底层是如何实现通信的

  • 首先 pipe(int pipefd[]) 接口会创建一个 " 管道文件 ",但是这个文件并不是实体文件,在该文件中进行通信的数据更不会写入到磁盘中,可以理解成内存级的,并且在程序结束之后,空间就会被释放
  • 当执行 pipe(int pipefd[]) 的时候,会在操作系统内核创建两个 struct file 对象,一个是用于读端,一个用于写端,然后在该进程的文件描述符表中分配两个文件描述符,这也是 pipefd[] 大小为 2 的原因
  • struct file 中有个结构体指针 struct file_operations* f_op,其中包含了很多对文件进行操作的函数指针,也可以对管道进行读写操作(铺垫)
  • 而执行 pipe(int pipefd[]) 的时候,还会创建个结构体 struct pipe_inode_info这个结构体就是匿名管道的关键数据结构
  • 再然后,管道结构体中还有一个成员 struct pipe_buffer bufs[],这个就是缓冲区了,可以看到这里实际上是缓冲区数组,很重要
  • ⭐所以,对于这两个读端和写端的 struct file,实际上都引用 / 绑定了同一个管道的缓冲区,他们最终都是调用读写函数来对 struct pipe_inode_info 中的缓冲区数组进行写入和删除操作的,一方来写,另一方来读,最终完成了匿名管道的通信
    在这里插入图片描述

1.4 总结

总结一下:

  • 匿名管道大多用于父子间通信,并且只能用于有血缘关系的进程通信
  • 管道中的数据不会持久化,程序结束,管道也就被释放了
  • 同一时刻,只能单向通信,并且是面向字节流的,会有粘包问题
  • 匿名管道还自带同步机制(上文没有体现),当缓冲区满的时候,写入端会等待;缓冲区为空的时候,读取端等待
  • 不会创建实体文件

2. 命名管道

2.1 前言

有匿名管道,当然有命名管道,匿名管道只能 [ 亲戚间 ] 单向通信,而命名管道用于任何进程之间通信,当然,单向的

这个和匿名管道是很相似的,只是有没有创建实体文件而已

2.2 mkfifo

在这里插入图片描述
认识接口 int mkfifo(const char* pathname, mode_t mode)

这个接口的任务就是创建一个特殊的文件 —— 管道文件,并且指定这个文件的权限,这个接口创建的是实体文件

  • 这个文件虽然是实体文件,但是它在工作的时候也并不会将数据刷新到磁盘上,所以可以理解成这个管道文件就是一个特殊标识,还是和匿名管道一样,在内核中维护一个缓冲区

比如我们就只执行 mkfifo("./.fifo, 0600"),如图

在这里插入图片描述

然后命名管道的使用也是很简单的,对于读端和写端就只需要以读和写的方式分别打开这个 管道文件 就好了

比如实现一个服务进程和一个客户进程进行通信:客户进程给服务进程发送信息,服务进程打印出收到的信息,代码如下

客户端:

int main()
{
    // 写端打开
    int writefd = open("./.fifo", O_WRONLY);
    char tosend[1024] = {0};
    while (true)
    {
        cout << "客户端输入数据:";
        fflush(stdout);                             // 刷新数据到屏幕上
        // 从键盘中获取数据发送给服务器
        if (fgets(tosend, 1023, stdin) == NULL) {   // 如果读取失败,返回 NULL
            return 1;
        }   
        int len = strlen(tosend);
        tosend[len] = '\0';                         // 手动添加结束符
        write(writefd, tosend, len);                // 写入数据
    }
    return 0;
}

服务端:

int main()
{
    // 打开管道的读端
    int readfd = open("./.fifo", O_RDONLY);
    char data[1024] = {0};
    while (true)
    {
        int len = read(readfd, data, 1024);         // len 为实际读取到的数据
        if (len > 0) {                              // 读取到数据了
            data[len] = '\0';
            cout << "服务端收到数据:" << data << endl;
        }
        else if (len == 0) {                        // 写端关闭, 那么读端关闭
            cout << "客户端跑路了,我也跑路了" << endl;
            return 1;
        }
    }
    return 0;
}

执行结果如下:

在这里插入图片描述

2.3 原理

命名管道的实现原理和匿名管道是很相似的,除了创建文件的方式不同

  • 匿名管道是通过 pipe() 接口来直接创建内核的数据机构
  • 命名管道是通过 mkfifo() 来创建实体文件,但是这个实体文件并不会将数据写入磁盘,然后后续有两个进程再创建写端和读端的 struct file 之后,原理基本就和匿名管道差不多了

3. 共享内存

3.1 实现原理

共享内存,就是在物理内存上开辟一块空间,然后进程之间可以直接对这块物理内存进行访问,那就可以进行通信了

而多个进程要想访问同一块物理内存,直接访问地址肯定行不通,操作系统不允许外界直接对物理内存作访问。所以想要多个进程看到同一个物理内存,就需要依靠虚拟地址空间

进程的虚拟地址空间都是独立,互不干扰的,所以只需要保证每个进程都可以将某个虚拟地址映射到同一个物理内存上就好了,这也就是实现原理了,画图如下:

在这里插入图片描述

3.2 接口

共享内存的接口比较多,一个一个看看

3.2.1 shmget

在这里插入图片描述申请获取共享内存

  • key:用来表示共享内存端的唯一值,不同的进程可以通过相同的 key 来获取同一块共享内存

  • size:申请的共享内存的大小,物理内存被 4KB 划分成了一个个单位,所以这里 size 尽量是 4KB 的倍数

  • shmflg:标志位,主要有两个标志位:IPC_CREATIPC_EXEL
    IPC_CREAT:如果 key 对应的共享内存存在,那么就获取;不存在,那么创建
    IPC_EXEL:一般都是和 IPC_CREAT 搭配使用,如果不存在 key 共享内存,那么创建;如果存在,那么表示申请出错,直接返回。说人话就是,一定要申请到一个全新的物理内存
    ③ 同时也可以指定这个共享内存对 Linux 用户的访问权限,比如 0666,表示这个共享内存对所有用户都允许读取和写入

  • 返回值称为 shmid,如果申请成功,那么就返回shmid,也就是共享内存的 id

需要区分一下 keyshmidkey 是标识共享内存端的唯一标识,可以用来确保多个进程都获取到同一个物理内存;而 shmid 是申请成功共享内存后的标识符,它可以保证多个进程对同一个共享内存的访问

3.2.2 ftok

在这里插入图片描述获取唯一 key

  • 可以根据传入的路径和数字,来获取一个唯一的 key ,便于多个进程来获取同一块物理内存
    (路径和数字自己定,保持唯一性即可)

3.2.3 shmat

在这里插入图片描述将共享内存挂靠到当前进程的虚拟地址空间上(shared memory attach

  • shmid :上边说过了,这里换个简单的说法:已经申请成功的共享内存的 id
  • shmaddr:想要将共享内存挂靠到虚拟地址的哪个位置,一般填为 nullptr 就够了
  • shmflg:可以设置共享内存的挂靠方式和访问权限,填 0 表示有读写权限
  • 返回值:虚拟地址空间中,和共享内存挂靠的区域的起始地址,类型是 void*

3.2.4 shmdt

在这里插入图片描述
删除当前进程和共享内存的关系(shared memory detach

  • shmaddr:共享内存的虚拟地址
  • 返回值:成功返回 0 ,失败返回 -1

3.2.5 shmctl

在这里插入图片描述删除申请的共享内存

  • shmid:和上面一样
  • cmd:要对这个共享内存作什么操作,IPC_RMID 表示立即删除,如果删除,那么 buf 为空就好了
  • 返回值:-1 表示删除失败

注意了,shmdt 只是让当前进程的虚拟地址空间和共享内存取消映射,但是共享内存还在,IPC_RMID才是彻底删除

3.3 小试牛刀

代码任务:客户端给服务器发送消息,服务端将收到的消息打印出来,代码都加了详细注释

服务端

int main()
{
    // 1. 生成 Key , 并且参数和客户端一样, 就可以保证两者可以获取到同一份共享内存
    key_t key = ftok("/home/shit", 8888888);
    // 2. 申请共享内存的空间, 并且一定要申请一个新的共享内存,并设定共享内存的权限是 0666
    int shmid = shmget(key, 2048, IPC_CREAT | 0666 | IPC_EXCL);
    // 3. 挂靠到自己的虚拟地址空间上, 并返回这个虚拟地址
    char* address = (char*) shmat(shmid, nullptr, 0); // 0 表示默认读写权限
    // 4. 开始通信,接收客户发送的数据
    while (true) {
        // 写端是直接往这个虚拟地址上写入数据的, 服务端也就直接从这个地址开始读取数据了
        if (address != NULL && address[0] != '\0') {
            cout << "收到客户发送的数据:" << address << endl;
        }
        sleep(1);
    }

    // 5. 取消挂靠
    shmdt((void*) address);
    // 6. 服务器即将关闭, 将共享内存释放了
    shmctl(shmid, IPC_RMID, nullptr);
    return 0;
}

客户端

int main()
{
    // 实现贡献内存, 客户给服务端打印消息, 服务端打印收到的消息
    // 1. 先获取一个共享内存段的唯一标识 key
    key_t key = ftok("/home/shit", 8888888);
    // 2. 再申请共享内存, 返回这块共享内存的 id
    int shmid = shmget(key, 2048, IPC_CREAT);       // 申请的大小尽量是 4KB 的倍数
    // 3. 和当前进程的虚拟地址空间建立关系
    //           nullptr 表示不关心挂靠的虚拟地址的位置, 0 表示默认读写权限
    //    转化成 char* 类型
    char* address = (char*) shmat(shmid, nullptr, 0);
    // 4. 开始通信
    while (true) {
        // 从键盘中获取数据, 也就是 0 号文件描述符
        // 返回实际上读取到的数据, 参数的 2048 表示:读取到的数据最多不超过 2048 字节
        cout << "请输入数据:" ;
        fflush(stdout);
        if (fgets(address, 2048, stdin) != NULL) {   // 读取正常
            cout << "客户写入数据成功" << endl;
        }
    }

    // 5. 取消挂靠
    shmdt((void*) address);
    return 0;
}

需要注意的是,使用共享内存进行通信的时候,是直接在共享内存的地址上读取和写入数据的,不像管道,中间有一层缓冲区。现在只需要将拿到的地址address来通信就好了

3.4 小结

  • 不像匿名管道,这种通信方式是直接访问共享内存的地址,中间并没有像管道那样的内核数据结构,需要进入内核态。相反,利用共享内存进行数据交流的过程中,进程都是在自己的用户空间上进行的,无需进入内核态,就像访问自己的家一样。⭐所以共享内存的访问速度更快,是一种很高效的通信方式,避免了内核态的切换
  • 像管道这样的通信方式,由于有 read / write 这样的阻塞式文件操作接口,实现了管道的同步机制。而共享内存并没有同步机制,需要使用其他手段来保证同步,比如锁,信号量…
  • 实时性很强,对于进程之间来说,读写操作就像实时直播,一方写,另一方可以马上读取