14.【Linux系统编程】进程间通信详解(管道通信、System V共享内存、消息队列、信号量)

目录

  • [1. 进程间通信介绍](#1. 进程间通信介绍)
    • [1.1 进程间通信目的](#1.1 进程间通信目的)
    • [1.2 怎样通信(进程间通信的本质)](#1.2 怎样通信(进程间通信的本质))
    • [1.3 进程间通信发展及分类](#1.3 进程间通信发展及分类)
  • [2. 管道](#2. 管道)
  • [3. 匿名管道](#3. 匿名管道)
    • [3.1 匿名管道创建指令](#3.1 匿名管道创建指令)
    • [3.2 实例代码](#3.2 实例代码)
    • [3.3 用 fork 来共享管道原理](#3.3 用 fork 来共享管道原理)
    • [3.4 站在文件描述符角度-深度理解管道](#3.4 站在文件描述符角度-深度理解管道)
    • [3.5 站在内核角度-管道本质](#3.5 站在内核角度-管道本质)
    • [3.6 匿名管道样例(子进程写入,父进程读取)](#3.6 匿名管道样例(子进程写入,父进程读取))
    • [3.7 匿名管道特点(5种)](#3.7 匿名管道特点(5种))
    • [3.8 管道通信的4种情况](#3.8 管道通信的4种情况)
  • [4. 命名管道](#4. 命名管道)
    • [4.1 命名管道的概念及特点](#4.1 命名管道的概念及特点)
    • [4.2 命名管道创建指令](#4.2 命名管道创建指令)
      • [4.2.1 终端创建命名管道](#4.2.1 终端创建命名管道)
      • [4.2.2 C程序中创建命名管道](#4.2.2 C程序中创建命名管道)
      • [4.2.3 C程序中删除命名管道](#4.2.3 C程序中删除命名管道)
    • [4.3 匿名管道与命名管道的区别](#4.3 匿名管道与命名管道的区别)
    • [4.4 命名管道的打开规则](#4.4 命名管道的打开规则)
    • [4.5 举例](#4.5 举例)
      • [4.5.1 实例1用命名管道实现server&client通信](#4.5.1 实例1用命名管道实现server&client通信)
      • [4.5.2 用命名管道实现文件拷贝](#4.5.2 用命名管道实现文件拷贝)
  • [5. System V IPC进程间通信标准框架](#5. System V IPC进程间通信标准框架)
    • [5.1 从System V标准到Linux IPC框架(了解)](#5.1 从System V标准到Linux IPC框架(了解))
    • [5.2 IPC的本质](#5.2 IPC的本质)
    • [5.3 IPC的三种核心通信机制](#5.3 IPC的三种核心通信机制)
    • [5.4 查看及删除IPC资源的命令](#5.4 查看及删除IPC资源的命令)
    • [5.5 System V共享内存(重点)](#5.5 System V共享内存(重点))
      • [5.5.1 共享内存的原理](#5.5.1 共享内存的原理)
      • [5.5.2 共享内存的创建(shmget函数)](#5.5.2 共享内存的创建(shmget函数))
      • [5.5.3 共享内存的挂载(shmat函数)](#5.5.3 共享内存的挂载(shmat函数))
      • [5.5.4 共享内存的解挂(shmdt函数)](#5.5.4 共享内存的解挂(shmdt函数))
      • [5.5.5 共享内存的删除&状态获取(shmctl函数)](#5.5.5 共享内存的删除&状态获取(shmctl函数))
      • [5.5.6 总结+管道VS共享内存](#5.5.6 总结+管道VS共享内存)
    • [5.6 System V消息队列(了解)](#5.6 System V消息队列(了解))
    • [5.7 System V信号量(了解)](#5.7 System V信号量(了解))
      • [5.7.1 并发编程,概念铺垫](#5.7.1 并发编程,概念铺垫)
      • [5.7.2 信号量](#5.7.2 信号量)
    • [5.8 IPC总结](#5.8 IPC总结)
    • [5.9 内核时如何组织管理IPC资源的](#5.9 内核时如何组织管理IPC资源的)

1. 进程间通信介绍

1.1 进程间通信目的

  • 数据传输:一个进程需要将它的数据发送给另一个进程

  • 资源共享:多个进程之间共享同样的资源。

  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进 程终止时要通知父进程)。

  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够 拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

1.2 怎样通信(进程间通信的本质)

  • 进程间通信的概念:进进程间通信(Inter-Process Communication,简称 IPC)是操作系统中两个或多个独立进程打破内存隔离限制,实现数据交换、信号传递或行为同步的机制。每个进程默认拥有独立的地址空间,无法直接访问其他进程的内存,这种隔离保障了系统的安全性和稳定性,而 IPC 机制则为进程间的协同工作提供了通道,既支持同一主机内进程的本地通信(如管道、共享内存),也可实现不同主机间进程的跨网络通信(如套接字、RPC),满足不同场景下的数据传递和行为协调需求。

  • 怎样通信:进程间通信的本质:是先让不同的进程先看到同一份资源【"内存"】。(然后才有通信的条件)

1.3 进程间通信发展及分类

管道:匿名管道pipe(通常用来做父子通信)、 命名管道
System V IPC(本机通信):共享内存、消息队列、信号量
POSIX IPC: 消息队列、共享内存、信号量、互斥量、条件变量、读写锁

2. 管道

  • 地位:管道是Unix中最古老的进程间通信的形式。

  • 概念:我们把从一个进程连接到另一个进程的一个数据流称为一个"管道"

3. 匿名管道

3.1 匿名管道创建指令

匿名管道的背景:基于已有的技术,直接进行通信。

  1. pipe函数
cpp 复制代码
#include <unistd.h>
//功能:创建一无名管道
//原型
    int pipe(int fd[2]);
//参数
//	fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
//	返回值:成功返回0,失败返回错误代码
  1. 管道的管理和访问
    • 由pipe函数生成写端和读端的文件描述符。
    • 像访问文件一样,使用pipe创建的文件描述符来对管道进行读写操作。
cpp 复制代码
// 1.创建管道
int fds[2] = {0}; // fds[0]:读端  fds[1]:写端
int n = pipe(fds);

if (n < 0)
{
    std::cerr << "pipe erroe" << std::endl;
    return 1;
}

3.2 实例代码

cpp 复制代码
// 子进程写入,父进程读取
#include<iostream>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>

void ChildWrite(int wfd)
{
    char buffer[1024];
    int cnt = 0;
    while(true)
    {
        snprintf(buffer, sizeof(buffer), "I am child, pid:%d, cnt:%d",getpid(), cnt++);
        write(wfd, buffer, strlen(buffer));
        sleep(1);
    }
}

void FatherRead(int rfd)
{
    char buffer[1024];
    while(true)
    {
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if(n > 0)
        {
            buffer[n] = 0;
            std::cout << "child say: " << buffer << std::endl;
        }
    }
}

int main()
{
    // 1.创建管道
    int fds[2] = {0};   // fds[0]:读端  fds[1]:写端
    int n = pipe(fds);

    if(n < 0)
    {
        std::cerr << "pipe erroe" << std::endl;
        return 1;
    }

    std::cout << "fds[0]:" << fds[0] << std::endl;
    std::cout << "fds[1]:" << fds[1] << std::endl;

    // 2.创建子进程
    pid_t id = fork();
    if(id == 0)
    {
        // child
        // code
        // 3.关闭不需要的读写端,形成通信信道
        // f -> r, c -> w
        close(fds[0]);
        ChildWrite(fds[1]);	// 子进程对管道进行写入
        close(fds[1]);
        exit(0);
    }

    // 3.关闭不需要的读写端,形成通信信道
    // f -> r, c-> w
    close(fds[1]);
    FatherRead(fds[0]);		// 父进程读取管道内部的数据
    waitpid(id, nullptr, 0);
    close(fds[0]);

    return 0;
}

3.3 用 fork 来共享管道原理

3.4 站在文件描述符角度-深度理解管道

  • 为什么叫匿名管道?

    答:管道是被OS单独设计的,要配上单独的系统调用来使用,即int pipe(int fd[2]);,不需要文件路径,内存级的管道,所以叫做"匿名管道"。

  • 怎么保证两个进程打开的是同一个管道文件?

    答:子进程继承文件描述符表

3.5 站在内核角度-管道本质

  • 所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了"Linux一切皆文件思想"

3.6 匿名管道样例(子进程写入,父进程读取)

子进程写入,父进程读取。

cpp 复制代码
// testPipe.cc
#include<iostream>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>

void ChildWrite(int wfd)
{
    char buffer[1024];
    int cnt = 0;
    while(true)
    {
        snprintf(buffer, sizeof(buffer), "I am child, pid:%d, cnt:%d",getpid(), cnt++);
        write(wfd, buffer, strlen(buffer));
        sleep(1);
    }
}

void FatherRead(int rfd)
{
    char buffer[1024];
    while(true)
    {
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if(n > 0)
        {
            buffer[n] = 0;
            std::cout << "child say: " << buffer << std::endl;
        }
    }
}

int main()
{
    // 1.创建管道
    int fds[2] = {0};   // fds[0]:读端  fds[1]:写端
    int n = pipe(fds);

    if(n < 0)
    {
        std::cerr << "pipe erroe" << std::endl;
        return 1;
    }

    std::cout << "fds[0]:" << fds[0] << std::endl;
    std::cout << "fds[1]:" << fds[1] << std::endl;

    // 2.创建子进程
    pid_t id = fork();
    if(id == 0)
    {
        // child
        // code
        // 3.关闭不需要的读写端,形成通信信道
        // f -> r, c -> w
        close(fds[0]);
        ChildWrite(fds[1]);
        close(fds[1]);
        exit(0);
    }

    // 3.关闭不需要的读写端,形成通信信道
    // f -> r, c-> w
    close(fds[1]);
    FatherRead(fds[0]);
    waitpid(id, nullptr, 0);
    close(fds[0]);

    return 0;
}
makefile 复制代码
# Makefile
testPipe:testPipe.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f testPipe

3.7 匿名管道特点(5种)

  1. 匿名管道:只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进

    程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。

  2. 一般而言,内核会对管道操作进行同步与互斥。

  3. 管道提供流式服务:管道是面向字节流的

  4. 管道是单向通信的:属于半双工的一种特殊情况。需要双方通信时,需要建立起两个管道

    半双工:任何一个时刻,一个发,一个收;

    全双工:任何一个时刻,可以同时发收

  5. (管道)文件的生命周期,是随进程的。

3.8 管道通信的4种情况

  1. 写慢,读快:读端进程阻塞(等待写入,同步机制)
  2. 写快,读慢:满了的时候,写就要阻塞等待
  3. 写关,读继续:read读到返回值0,表示读到了文件结尾
  4. 读关,写继续:写端再写入,没有任何意义。OS不会做没有意义的事情,OS会杀掉写端进程。(发送异常信号 13)SIGPIPE )

示例验证4:

cpp 复制代码
// testPipe.cc
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>

void ChildWrite(int wfd)
{
    char buffer[1024];
    int cnt = 0;
    while (true)
    {
        snprintf(buffer, sizeof(buffer), "I am child, pid:%d, cnt:%d", getpid(), cnt++);
        write(wfd, buffer, strlen(buffer));
        sleep(2);
    }
}

void FatherRead(int rfd)
{
    char buffer[1024];
    while (true)
    {
        // sleep(5)
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "child say: " << buffer << std::endl;
        }
        else if (n == 0)
        {
            std::cout << "n : " << n << std::endl;
            std::cout << "child 退出,我也退出";
            break;
        }
        else
        {
            break;
        }
        break;
    }
}

int main()
{
    // 1.创建管道
    int fds[2] = {0}; // fds[0]:读端  fds[1]:写端
    int n = pipe(fds);

    if (n < 0)
    {
        std::cerr << "pipe erroe" << std::endl;
        return 1;
    }

    std::cout << "fds[0]:" << fds[0] << std::endl;
    std::cout << "fds[1]:" << fds[1] << std::endl;

    // 2.创建子进程
    pid_t id = fork();
    if (id == 0)
    {
        // child
        // code
        // 3.关闭不需要的读写端,形成通信信道
        // f -> r, c -> w
        close(fds[0]);
        ChildWrite(fds[1]);
        close(fds[1]);
        exit(0);
    }

    // 3.关闭不需要的读写端,形成通信信道
    // f -> r, c-> w
    close(fds[1]);
    FatherRead(fds[0]);
    close(fds[0]);

    sleep(5);

    int status = 0;
    int ret = waitpid(id, &status, 0);
    if(ret > 0)
    {
        printf("exit code: %d, exit signal : %d\n", (status>>8)&0xFF,status&0x7F); 
        sleep(2);
    }
    return 0;
}
bash 复制代码
# 打开两个终端
# 第一个终端查看进程状态
while :; do ps ajx | head -1;ps ajx | grep testPipe; sleep 1; done

# 第二个终端执行进程
./testPipe

4. 命名管道

4.1 命名管道的概念及特点

  • 管道应用的一个限制就是只能在具有亲缘关系的进程间通信(常用于父与子)。

  • 如果我们想在不相关的进程之间交换数据 ,可以使用FIFO文件来做这项工作,它经常被称为命名管道

  • 命名管道是一种特殊类型的文件

命名管道的本质:让不同进程访问到同一份「内核通信资源」------ 通过打开文件系统中同一个路径下的 "管道文件" (该文件仅为路径标识,不存磁盘数据),不同进程对这个 "标识对应的内核缓冲区" 进行读写,从而实现通信;因这份 "通信资源" 有明确的路径和名字,故称为命名管道。

命名管道文件特性:不需要刷新(原因:命名管道的数据仅存储在内存的内核缓冲区中,从未落地磁盘;而 "刷新" 的核心目的是将内存数据同步到磁盘,因此对命名管道而言,刷新毫无意义)。

4.2 命名管道创建指令

4.2.1 终端创建命名管道

  • 命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
bash 复制代码
mkfifo filename

4.2.2 C程序中创建命名管道

  • 命名管道也可以从程序里创建,相关函数有:
cpp 复制代码
#include <sys/stat.h>   // 必需头文件(包含 mode_t 类型、权限宏)
#include <sys/types.h>  // 辅助头文件(部分系统要求)

int mkfifo(const char *pathname, mode_t mode);
// 参数
	pathname:命名管道文件的路径 + 文件名
	mode:指定创建的命名管道文件的"访问权限"(受到umask权限掩码的影响)
// 返回值
	成功:返回 0,命名管道的路径会出现在文件系统中(可通过 ls -l 查看,类型标识为 p)
	失败:返回 -1,并设置全局变量 errno 标识错误原因。

4.2.3 C程序中删除命名管道

  • 删除命名管道的函数
cpp 复制代码
#include <unistd.h>  // POSIX 标准头文件,必须包含

int unlink(const char *pathname);

//参数
	pathname:命名管道的「文件系统路径」
//返回值
	成功:返回 0,命名管道的路径从文件系统中删除;
	失败:返回 -1,并设置全局变量 errno(错误码)

4.3 匿名管道与命名管道的区别

  • 匿名管道由pipe函数创建并打开。
  • 命名管道由mkfifo函数创建 ,用open打开

FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。

4.4 命名管道的打开规则

  • 如果当前打开操作是为读而打开FIFO时

    • O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
    • O_NONBLOCK enable:立刻返回成功
  • 如果当前打开操作是为写而打开FIFO时

    • O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
    • O_NONBLOCK enable:立刻返回失败,错误码为ENXIO

4.5 举例

4.5.1 实例1用命名管道实现server&client通信

举例:创建命名管道,并完成两个进程间的通信(四个文件):

  1. 编写comm.hpp文件:宏定义命名管道名称
cpp 复制代码
#pragma once

#define FIFO_FILE "fifo"
  1. 实现server.cc服务端:mkfifo创建命名管道,open打开命名管道,read系统调用等待读取,close关闭管道,unlink(fifo)删除命名管道。
cpp 复制代码
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "comm.hpp"

int main()
{
    umask(0);
    // 新建命名管道
    int n = mkfifo(FIFO_FILE, 0666);
    if(n != 0)
    {
        std::cerr << "mkdir fifo error" << std::endl;
        return 1;
    }
    std::cout << "kmfifo success" << std::endl;

    // 打开管道文件
    // write 方没有执行open的时候,read方,就要在open内部进行"阻塞",直到有人把管道文件打开了,open才会返回
    int fd = open(FIFO_FILE, O_RDONLY);
    if(fd < 0)
    {
        std::cerr << "open fifo error" << std::endl;
        return 2;
    }
    std::cout << "open fifo success" << std::endl;

    // read
    while(true)
    {
        char buffer[1024];
        int number = read(fd, buffer, sizeof(buffer)-1);
        if(number > 0)
        {
            buffer[number] = 0;
            std::cout << "Client Says:\"" << buffer << "\"" << std::endl;
        }
        else if(number == 0)
        {
            // TODO
            std::cout << "client quit! me too!" << std::endl;
            break;
        }
        else
        {
            std::cerr << "read error" << std::endl;
            break;
        }
    }

    close(fd);

    // 删除管道
    n = unlink(FIFO_FILE);
    if(n == 0)
    {
        std::cout << "remove fifo success" << std::endl;
    }
    else
    {
        std::cout << "remove fifo failed" << std::endl;
    }
    return 0;
}
  1. 实现client.cc客户端,open打开命名管道,write系统调用写入管道,close关闭管道。
cpp 复制代码
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "comm.hpp"

int main()
{
    // 以write方式打开刚刚创建得管道文件
    int fd = open(FIFO_FILE, O_WRONLY);
    if(fd < 0)
    {
        std::cerr << "open fifo error" << std::endl;
        return 1;
    }
    std::cout << "open fifo success" << std::endl;

    // 写入操作
    std::string message;
    int cnt = 0;
    pid_t id = getpid();
    while(true)
    {
        std::cout << "Please Enter: " ;
        std::getline(std::cin, message);
        message += ", message number:" + std::to_string(cnt++) + "[" + std::to_string(id) + "]";
        write(fd, message.c_str(), message.size());
    }

    close(fd);
    return 0;
}
  1. 编写Makefile文件
makefile 复制代码
.PHONY:all
all:client server
client:client.cc
	g++ -o $@ $^ -std=c++11
server:server.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f client server

4.5.2 用命名管道实现文件拷贝

5. System V IPC进程间通信标准框架

5.1 从System V标准到Linux IPC框架(了解)

  1. System V标准的起源

System V是Unix为了解决早期 Unix 中 IPC 接口混乱、功能有限的问题,Unix System V 首次将「共享内存、消息队列、信号量」整合为一套统一的 IPC 框架,定义了:

  • 统一的资源定位方式(键值 Key → 标识符 ID);
  • 标准化的系统调用接口(如 msgget/shmget/semget 等);
  • 内核级资源管理规则(权限控制、生命周期管理)。

这套框架后来被固化为System V IPC 标准,成为 Unix 生态中公认的 IPC 规范,被众多 Unix-like 系统(如 Solaris、HP-UX)采纳。,Linux 为了兼容 Unix 生态、支持标准化的进程间通信,才在 kernel 中设计了对应的 IPC 模块来实现这套标准

  1. Linux的IPC设计

由于 System V IPC 已成为 Unix 生态的主流 IPC 标准,Linux 内核在开发过程中,专门设计了独立的 IPC 模块,严格遵循 System V 标准的接口规范和底层原理,实现了共享内存、消息队列、信号量这三类通信机制:

  • 接口完全对齐:Linux 的 msgget/shmget/semget 等系统调用,参数、返回值、错误码都与 System V 标准一致;
  • 原理保持一致:仍以 "键值 - 标识符" 为资源定位核心,以内核作为中间媒介管理共享资源;
  • 仅做适配优化:Linux 并未修改 System V 标准的核心逻辑,仅在底层调度(如内存分配、进程同步)上做了符合自身内核架构的优化。

总结:System V 是源于 Unix System V 分支的通信标准(该分支同步实现了对应的 IPC 框架),Linux 为兼容 Unix 生态,并未重新设计新框架,而是严格遵循 System V 标准,适配自身内核架构,实现了这套 IPC 框架,让原有 Unix 程序能无缝运行。

5.2 IPC的本质

IPC(进程间通信)的本质,是打破进程地址空间的隔离性,通过内核提供的 "公共资源 / 通道" 或直接映射的共享内存,让不同进程安全、高效地交换数据或协同工作------ 核心是 "跨进程共享可访问的资源",所有 IPC 机制都围绕这个核心展开,只是共享资源的形式、访问方式不同。

总结:IPC本质,让不同的进程先看到同一份资源。

5.3 IPC的三种核心通信机制

System V IPC 的核心特征的是:统一采用「键值(Key)→ 标识符(ID)」的资源定位方式,共享「创建 / 获取 - 操作 - 控制 - 销毁」的接口范式,底层资源由内核全权管理(生命周期独立于进程)。其下包含三种功能互补的核心机制,分别对应不同的 IPC 场景。

机制 核心用途 速度 关键特性 典型场景
共享内存 高速数据传输 最快 无同步、大数据量 文件传输、实时数据处理
消息队列 有序消息交换 中等 按类型接收、自带缓存 服务端 - 客户端通信、异步通知
信号量 进程同步 / 互斥 -(无数据) 原子操作、资源控制 共享内存读写同步、临界资源保护

三者均基于 System V 标准,通过键值定位资源、内核管理生命周期,共同构成了完整的 System V IPC 解决方案 ------ 实际开发中常组合使用(如「共享内存 + 信号量」:共享内存传数据,信号量保证读写同步)。

5.4 查看及删除IPC资源的命令

  1. 查看IPC资源
bash 复制代码
ipcs [选项]	# 无选项时,默认列出所有 System V IPC 资源

# 循环查看IPC资源状态
while :; do ipcs -m; sleep 1; done

相关选项:

选项 功能说明 对应 IPC 机制
-a 列出所有 IPC 资源(默认行为,等价于 -m -q -s 共享内存、消息队列、信号量
-m 仅列出 共享内存(System V) System V 共享内存
-q 仅列出 消息队列(System V) System V 消息队列
-s 仅列出 信号量(System V) System V 信号量集
-p 显示资源的「创建者 PID」和「最后操作 PID」 所有 System V IPC 资源
-t 显示资源的「创建时间」「最后访问时间」「最后修改时间」 所有 System V IPC 资源
-l 显示 IPC 资源的「系统限制」(如最大共享内存大小、最大消息队列长度) 所有 System V IPC 资源
-u 显示 IPC 资源的「统计信息」(如已使用数量、空闲数量) 所有 System V IPC 资源
  1. 删除IPC资源
bash 复制代码
# 方式 1:通过资源 ID 删除(推荐,精准无冲突)(shmid)
ipcrm -m <shmid>  # 删除共享内存(-m 对应共享内存)
ipcrm -q <msqid>  # 删除消息队列(-q 对应消息队列)
ipcrm -s <semid>  # 删除信号量集(-s 对应信号量)

# 方式 2:通过 key 值删除(需确保 key 唯一)(非重点)
ipcrm -M <key>  # 按 key 删除共享内存
ipcrm -Q <key>  # 按 key 删除消息队列
ipcrm -S <key>  # 按 key 删除信号量集

5.5 System V共享内存(重点)

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

5.5.1 共享内存的原理

原理即下图:

  1. 我们通过使用系统调用来完成以上步骤
  2. 进程A和进程B通过各自的虚拟地址访问同一块物理内存,即共享内存来实现进程间通信。
  3. 共享内存的释放:①.取消关联关系(通过引用计数判断当前是否还有进程访问共享内存,以便对共享内存进行管理);②.释放共享内存
  4. 可能同时存在多组进程,都在使用不同的共享内存来进行通信。
    • OS内,多个进程同事存在怎么管理?答:先描述,再组织。即,共享内存:一定要有对应的描述共享内存的内核结构体对象+物理内存
    • 进程和共享内存的关系------即,内核数据结构之间的关系
  • 共享内存的完整流程

    1. 第一步:shmget() → 创建/获取共享物理内存(内核分配物理内存,返回 shmid)(无虚拟地址参与)

    2. 第二步:shmat() → 进程挂载(建立物理内存→进程虚拟地址的映射,返回 shmaddr)

    3. 第三步:进程操作 → 通过 shmaddr 直接读写共享内存(核心通信步骤)

    4. 第四步:shmdt() → 进程解挂(断开映射,nattch 计数-1,进程无法再访问)

    5. 第五步:shmctl(shmid, IPC_RMID, NULL) → 标记删除共享内存(内核等待所有进程解挂后,回收物理内存)

  • 共享内存数据结构如下struct shmid_ds

cpp 复制代码
 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 {
    //我们shmget(key,)中设置的key,就会被设置到共享内存的描述结构体中。由此__key来判断是否访问的同一个共享内存。
	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 */
};

5.5.2 共享内存的创建(shmget函数)

cpp 复制代码
// 功能:用来创建共享内存
// 原型
int shmget(key_t key, size_t size, int shmflg);
// 参数:
	key:这个共享内存段名字
	size:共享内存大小
	shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的。
        取值为IPC_CREAT:共享内存不存在,创建并返回;共享内存已存在,获取并返回。
        取值为IPC_CREAT | IPC_EXCL:共享内存不存在,创建并返回;共享内存已存在,出错返回。(单独使用IPC_EXCL无意义)
// 返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1

key参数详解:

  • 怎么评估,共享内存,存在还是不存在?

  • 怎么保证,两个不同的进程拿到的就是同一个共享内存?

    答:key参数(作用如下)

    • 不同的进程,shm来进行通信,标识共享内存的唯一性
    • key不是内核直接形成的,而是在用户层构建并传入给OS的,内核会将key映射为唯一的「标识符(shmid)」(内核态内部标识),进程最终通过 shmid 操作共享内存;
  • key参数的作用,类似命名管道中的 "管道文件路径+文件名"

key参数的生成方式:

  1. 由ftok()函数生成(最常用)
cpp 复制代码
#include <sys/ipc.h>

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

// pathname:必须是「已存在且进程可访问(有读权限)」的文件路径(如 /tmp/test.txt);
// proj_id:1~255 之间的非 0 整数(仅低 8 位有效),不同 proj_id 可让同一文件生成不同 key;
// 返回值:成功返回生成的 key(非负整数),失败返回 -1(错误码如 ENOENT:文件不存在,EACCES:权限不足)。

使用实例:

cpp 复制代码
// 以 /tmp/ipc.key 文件 + 项目ID=66 生成 key
key_t key = ftok("/tmp/ipc.key", 66);
if (key == -1) {
    perror("ftok failed");
    exit(1);
}
// 用生成的 key 创建共享内存
int shmid = shmget(key, 4096, IPC_CREAT | 0644);

size参数详解

  • size参数的大小必须是4KB的整数倍,如果写4096,就是4KB;如果写4097,就是4KB*2。(自动向上补齐,操作系统给申请的内存是遵循此规则,但是用户使用的时候,仍然只能使用4097个字节)

shmflg参数详解:

shmflg 参数是 标志位组合 ,核心作用是控制共享内存的「创建 / 获取规则」+「访问权限」,由两部分构成:权限位(低 9 位) + 功能控制标志(高 23 位),各标志通过「按位或(|)」组合使用。以下是其所有常用 / 标准标志及详细解释(含 Linux 扩展标志)、

  1. 权限位(低 9 位,核心用于控制进程访问权限)

和文件权限(rwx)规则相同,但共享内存无「执行权限(x)」(设置 x 位无效),仅关注「读(r)」和「写(w)」:

权限值(八进制) 含义(所有者 + 组 + 其他用户) 对应二进制 说明
0400 所有者(owner)可读 100 000 000 仅创建者可读取共享内存
0200 所有者(owner)可写 010 000 000 仅创建者可写入共享内存
0600 所有者可读可写 110 000 000 常用:仅创建者访问
0444 所有者 + 组 + 其他用户均可读 100 100 100 只读共享,多进程只读访问
0644 所有者可读可写,组 + 其他用户可读 110 100 100 常用:创建者可读写, others 只读
0666 所有者 + 组 + 其他用户均可读可写 110 110 110 开放权限(慎用,安全性低)

注意:权限位仅限制「进程对共享内存的访问权限」,不影响内核对共享内存的管理(如销毁、查询)。

  1. 核心控制标志(标准 POSIX 标志,所有 Unix-like 系统兼容)

这是 shmflg 最常用的部分,控制共享内存的「创建 / 获取逻辑」:

标志名 取值(十六进制) 核心作用 使用场景 & 注意事项
IPC_CREAT 00001000 「创建或获取」:若 Key 对应的共享内存不存在,则创建;若已存在,则直接获取其 ID 单独使用时,无法区分「新创建」和「已存在获取」(返回的 shmid 相同);需配合 IPC_EXCL 实现 "仅创建新的"
IPC_EXCL 00002000 「排他创建」:仅和 IPC_CREAT 组合使用才有效,确保创建 "全新" 的共享内存 组合使用 IPC_CREAT IPC_EXCL时:若 Key 不存在:创建成功,返回新 shmid;若 Key 已存在:直接返回-1(错误码 EEXIST),避免覆盖已有资源
IPC_PRIVATE 00000000(特殊) 「创建私有共享内存」:忽略 Key 参数,强制创建一个 "仅父子进程可见" 的共享内存 1. Key 参数需设为 IPC_PRIVATE(而非自定义 Key);2. 共享内存仅能通过「亲缘关系」(如 fork 后的子进程)继承访问,无亲缘的进程无法通过 Key 获取;3. 无需配合 IPC_CREAT,单独使用即可
IPC_NOWAIT 00004000 「非阻塞模式」:仅用于获取已存在的共享内存时,避免进程阻塞 场景:当共享内存被设置为「需等待权限」(如 SHM_LOCK 锁定)时,默认会阻塞进程;加此标志后,直接返回 -1(错误码 EAGAIN),不阻塞

5.5.3 共享内存的挂载(shmat函数)

shmat(shm attach)函数的作用:将内核中已分配的共享物理内存,映射到 当前进程的虚拟地址空间中。

cpp 复制代码
#include <sys/types.h>
#include <sys/shm.h>
// 功能:将共享内存段连接到进程地址空间
// 原型
void *shmat(int shmid, const void *shmaddr, int shmflg);
// 参数
    shmid: 共享内存标识
    shmaddr:指定连接的地址(虚拟地址,固定地址挂载。)
    shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
// 返回值:成功返回一个指针,指向共享内存第一个节(共享内存起始虚拟地址);失败返回-1

说明:

shmaddr为NULL,核心自动选择一个地址(shmaddr一般nullptr,即使用默认设置)

shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。

shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)

shmflg=SHM_RDONLY,表示连接操作用来只读共享内存。(shmflg一般给"0",即使用默认设置)

5.5.4 共享内存的解挂(shmdt函数)

cpp 复制代码
// 功能:将共享内存段与当前进程脱离
// 原型
int shmdt(const void *shmaddr);
// 参数
	shmaddr: 由shmat所返回的指针
// 返回值:成功返回0;失败返回-1
// 注意:将共享内存段与当前进程脱离不等于删除共享内存段

5.5.5 共享内存的删除&状态获取(shmctl函数)

  • 共享内存的资源,生命周期随内核!!如果没有显示的删除,即便进程推出了,IPC资源依旧被占用。

命令级的删除见 5.4 小节

  • 代码级删除shmctl(shm control)函数如下:
cpp 复制代码
// 功能:用于控制共享内存
// 原型
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// 参数
    shmid:由shmget返回的共享内存标识码
    cmd:将要采取的动作(有三个可取值)
    buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
// 返回值:成功返回0;失败返回-1

5.5.6 总结+管道VS共享内存

共享内存 管道
写入读取方式 直接对内存操作 原因:共享内存映射成功之后,共享内存的虚拟地址在堆和栈之间,堆栈之间的部分属于用户,即:共享区---属于用户空间,用户可以直接使用。 需要使用系统调用write和read 原因:管道使用的是内核级的缓冲区,属于操作系统,所以要向管道内写入或者读取,需要使用系统调用。
  • 共享内存的优点: 进程通信方式中速度最快(原因:1.映射之后,读写直接被对方看到2.不需要进行系统调用获取或则和写入内容,直接使用指针操作。)

  • 共享内存的缺点:通信双方没有"同步机制"!数据不一致!(即:共享内存没有保护机制【这也是快的原因之一】)

    改进:通过命名管道,给共享内存添加保护机制。

5.6 System V消息队列(了解)

  • 消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法

  • OS怎样对消息队列进行管理:先描述,再组织!

  • 两个进程,怎样保证自己看到的是同一个消息队列?和共享内存一样使用key

  • 每个数据块都被认为是一个类型,接收者进程接收的数据块可以有不同的类型值

  • 特性方面:IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核

  • 消息队列的生命周期随内核

5.7 System V信号量(了解)

信号量主要用于同步和互斥的,下面先来看看什么是同步和互斥。

5.7.1 并发编程,概念铺垫

  1. 共享资源

多个执行流(如进程、线程)能够共同访问的同一份公共资源(如共享内存、文件、硬件设备等)。

  1. 临界资源(又称互斥资源)

共享资源中一次仅允许一个执行流使用的资源(若多个执行流同时访问,会导致数据错乱或逻辑冲突)。→ 本质:共享资源的 "需保护子集",需通过特定机制限制访问规则。

  1. 临界区与非临界区
  • 临界区 :进程 / 线程中直接访问临界资源的代码段(是需要被保护的核心代码);
  • 非临界区:进程 / 线程中不涉及临界资源访问的代码段(无需保护,可并发执行);
  • 核心关系:进程代码 = 临界区(访问临界资源) + 非临界区(不访问临界资源)
  • 保护本质:对共享资源的保护,最终落地为对 "访问该资源的临界区代码" 的保护。
  1. 原子性(保护临界区的基础特性)

一个操作或一组操作在执行过程中,要么完整执行完毕 ,要么完全不执行,不会出现 "执行一半" 的中间状态,且执行过程中不会被其他操作打断。→ 作用:确保临界区代码的 "不可分割性",是实现互斥与同步的前提。

  1. 互斥(临界区保护的核心方式之一)

多个执行流访问临界资源时,任何时刻仅允许一个执行流进入临界区,其他执行流需等待直至当前执行流释放资源。→ 核心目标:避免多个执行流同时操作临界资源,解决 "并发冲突" 问题。

  1. 同步(临界区保护的核心方式之二)

多个执行流访问临界资源时,需遵循预先约定的顺序执行(如 "先生产后消费""先申请后使用"),确保操作逻辑的合理性。→ 核心目标:协调执行流的执行顺序,解决 "逻辑先后" 问题。

关键补充:同步与互斥的关系

  • 互斥是同步的特例:互斥本质是 "顺序为任意,但同一时刻仅一个执行" 的同步;
  • 二者核心作用:都是为了保证临界区代码安全执行,避免并发访问导致的异常。

5.7.2 信号量

  1. 信号量的定义与本质
  • 定义:信号量是一个内核维护的计数器,用于标识临界资源中 "可用资源的数量";
  • 本质:一种 "资源预订机制"------ 进程访问临界资源前,需先通过信号量 "预订" 资源,预订成功后才能访问,访问结束后释放预订,确保资源有序使用。
  1. 信号量的核心作用
  • 保护临界区:通过控制信号量的计数,限制进入临界区的执行流数量;
  • 实现互斥与同步:通过信号量的计数变化,协调多个执行流的访问顺序和并发数量。
  1. 信号量的分类(按计数范围)
  • 二元信号量(互斥信号量) :信号量的计数仅为 01
    • 适用场景:临界资源为 "整体使用"(如单个共享内存块、一个硬件设备),对应 "互斥" 需求;
    • 逻辑:1 表示资源可用,0 表示资源已被占用。
  • 多元信号量(计数信号量) :信号量的计数可大于 1(如 25 等);
    • 适用场景:临界资源可拆分为 "多个独立小块"(如共享内存被划分为 3 个数据区),允许同时有多个执行流访问不同小块;
    • 逻辑:计数为 n 表示有 n 个资源块可用,每申请一个计数减 1,释放一个计数加 1
  1. 信号量的核心操作:PV 操作(实现预订与释放)

信号量的所有操作均为原子操作(确保计数变化的完整性),核心操作包括:

  • P 操作(申请资源 / 预订资源)
    1. 将信号量计数器的值减 1
    2. 若减后计数 ≥ 0:申请成功,进程可进入临界区访问资源;
    3. 若减后计数 < 0:申请失败,进程被阻塞,放入信号量的等待队列中。
  • V 操作(释放资源 / 取消预订)
    1. 将信号量计数器的值加 1
    2. 若加后计数 ≤ 0:表示等待队列中有阻塞进程,唤醒其中一个进程,使其重新尝试申请资源;
    3. 若加后计数 > 0:释放成功,无阻塞进程需唤醒。
  1. 信号量与 IPC 的关联(System V 信号量的核心优势)

信号量本身不传递数据,但其属于 System V IPC 机制,核心价值在于:

  • 支持 "跨进程可见":多个无亲缘关系的进程,可通过相同的 key 值获取同一个信号量(解决了 "多个进程如何看到同一个同步标识" 的问题);
  • 同步互斥属于 IPC 范畴:进程间的同步与互斥是跨进程协作的核心需求,因此信号量是 System V IPC 的重要组成部分(与共享内存、消息队列并列)。

5.8 IPC总结

共享内存、消息队列、信号量,key区分唯一:OS中,共享内存、消息队列、信号量,被当作了同一种资源!!即system V IPC

5.9 内核时如何组织管理IPC资源的

相关推荐
Mr_WangAndy1 小时前
C++23新特性_#warning 预处理指令
c++·c++23·c++40周年·c++23新特性·warning预处理命令
s***P9821 小时前
Spring Boot实时推送技术详解:三个经典案例
spring boot·后端·状态模式
嵌入式郑工1 小时前
UBUNTU开发环境下的一些实用的工具
linux·运维·ubuntu
用户69371750013841 小时前
24.Kotlin 继承:调用超类实现 (super)
android·后端·kotlin
洛克大航海1 小时前
Ubuntu 安装 Docker
linux·docker·ubuntu24.04
java干货1 小时前
优雅停机!Spring Boot 应用如何使用 Hook 线程完成“身后事”?
java·spring boot·后端
鹿里噜哩1 小时前
Spring Authorization Server 打造认证中心(三)自定义登录页
后端·架构
ULTRA??1 小时前
C++拷贝构造函数的发生时机,深拷贝实现
开发语言·c++