深入解析SystemV共享内存和信号量机制

🔥keyipatience:个人主页

🎬作者简介:C/C++后端开发学习者

🌟专栏传送门:《c++》《linux》

⭐️patience is key in life

System背景:

System V 是一套经典的 Unix IPC 标准,除了共享内存,还包含消息队列、信号量。Linux 内核专门实现了对应的模块来支持这些机制,它们的接口设计和使用方式有很强的相似性。

System V 共享内存的原理示意图

1.工作流程:内核开辟物理内存->进程建立虚拟地址映射->进程直接读写共享内存

2.共享内存需要内核级的 "管理结构" 操作系统中可能同时存在多个共享内存,不同进程组使用不同的共享内存通信。为了区分、管理这些共享内存,内核必须为每一块共享内存创建一个内核结构体对象 (比如 struct shmid_kernel

共享内存的生命周期:(后面把完整的代码写了,再仔细说

shmget 接口

复制代码
#include <sys/ipc.h>
#include <sys/shm.h>

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

参数:

参数 含义 说明
key 共享内存的键值 用来唯一标识一块共享内存,不同进程通过相同的 key 就能找到同一块共享内存 (用户态传入内核的,才能让多个进程提前约定同一个标识。)
size 共享内存大小 单位是字节,内核实际会按页大小向上对齐(比如 4KB)
shmflg 标志位 控制创建 / 打开行为 + 权限位,比如 IPC_CREATIPC_EXCL

返回值

  • 成功:返回一个非负整数 ,即共享内存标识符 shmid(后续 shmat/shmdt/shmctl 都要用它)
  • 失败:返回 -1,并设置 errno

核心标志位详解

1. IPC_CREAT

  • 作用:创建共享内存
  • 逻辑:如果 key 对应的共享内存不存在 ,就创建它;如果已经存在 ,就直接打开并返回它的 shmid

2. IPC_CREAT | IPC_EXCL

  • 作用:强制创建全新的共享内存

注意:IPC_EXCL 不能单独使用,必须和 IPC_CREAT 一起用,否则无意义

权限位

shmflg 还需要带上权限位

  • 强制创建全新共享内存:shmget(IPC_CREAT | IPC_EXCL | 0666)
  • 打开已存在的共享内存:shmget(IPC_CREAT)

ftok 函数

把一个已存在的文件路径 和一个项目 ID,转换成一个唯一的 key_t 类型的 key

复制代码
#include <sys/types.h>
#include <sys/ipc.h>

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

参数:

  • pathname:必须是一个存在的文件路径 (比如当前目录下的一个普通文件),内核会根据文件的 inode 号来计算 key
  • proj_id:项目 ID,低 8 位有效(通常填一个非 0 的整数,比如 0x66),用来区分同一个文件生成的不同 key
  • 原理:ftok 会把文件的 inode 号和 proj_id 拼接起来,生成一个唯一的 key。只要文件路径和 proj_id 不变,生成的 key 就不变。

shmctl

控制接口,用来管理共享内存的

复制代码
#include <sys/ipc.h>
#include <sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数 含义
shmid 共享内存标识符,是 shmget 的返回值
cmd 要执行的控制命令(核心参数) IPC_RMID:标记共享内存为删除 IPC_STAT:获取共享内存状态 IPC_STAT:获取共享内存状态
buf 指向 struct shmid_ds 结构体的指针,用于获取 / 设置共享内存属性(现在不管写成nullptr即可)

返回值

  • 成功:返回 0(部分命令会返回非负值,如 IPC_INFO
  • 失败:返回 -1,并设置 errno

shmat

共享内存的挂载接口,作用是把内核里的共享内存映射到进程的虚拟地址空间,

复制代码
#include <sys/types.h>
#include <sys/shm.h>

void *shmat(int shmid, const void *shmaddr, int shmflg);
参数 含义
shmid shmget 返回的共享内存标识符
shmaddr 指定映射到进程的哪个虚拟地址,一般填 NULL,让内核自动选择合适的地址
shmflg 挂载标志,控制映射行为(现在填0即可)

返回值

  • 成功:返回共享内存映射到进程虚拟地址空间的起始地址void* 类型)
  • 失败:返回 (void*)-1,并设置 errno。if (p == (void *)-1) { perror("shmat 失败");

查看 System V 共享内存段(ipcs)

ipcs -m

关键字段:

  • shmid:共享内存段 ID
  • owner:所有者
  • bytes:大小
  • nattch:挂载(attach)的进程数

ipcrm 删除共享内存

ipcrm -m 123456(shmid)

shmdt

解除当前进程虚拟地址 和 共享内存物理页 的映射关系

复制代码
#include <sys/types.h>
#include <sys/shm.h>

int shmdt(const void *shmaddr);

shmaddr为shmat 返回的共享内存映射起始地址。

返回值

  • 成功:返回 0
  • 失败:返回 -1,并设置 errno

进程先调用 shmdt再shmctl

shmctl IPC_RMID:是给内核共享内存打删除标记,等所有进程都先shmdt 解绑后,内核才彻底释放内存。

演示代码:

Shm.hpp:

复制代码
#pragma once
#include <iostream>
#include <cstdio>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include<sys/stat.h>

using namespace std;
const int projid = 0x66;
const int gmode = 0666;
const string pathname = ".";


#define gdefault -1
#define gsize 4096
#define CREATER "creater"
#define USER "user"
#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)

class Shm
{
private:
    void Get()
    {
        CreateHelp(IPC_CREAT);
    }
    void Create()
    {
        CreateHelp(IPC_CREAT | IPC_EXCL | gmode);
    }
    void CreateHelp(int flg)
    {
        umask(0);
        // 1.先fork()确定key
        _key = ftok(pathname.c_str(), projid);
        if (_key < 0)
        {
            ERR_EXIT("ftok\n");
        }
        printf("key:0x%x\n", _key);
        // 2.创建共享内存,创建的一定是一个全新的共享内存
        _shmid = shmget(_key, _size, flg);
        if (_shmid < 0)
        {
            ERR_EXIT("shmget\n");
        }
        printf("shmid:%d\n", _shmid);
    }
    void Attach() // 建立物理和虚拟映射
    {
        _start_men = shmat(_shmid, nullptr, 0);
        if (_start_men == (void *)-1)
        {
            ERR_EXIT("shmat\n");
        }
        printf("attach success\n");
    }
    
    void Detach()
    {
        int n = shmdt(_start_men);
        if (n == 0)
        {
            printf("detach success\n");
        }
    }
    void Destroy()
    {
        Detach();
        if (_usertype == CREATER)
        {

            if (_shmid == gdefault)
            {
                return;
            }
            int n = shmctl(_shmid, IPC_RMID, nullptr);
            if (n ==0)
            {
                printf("shmctl delete shm: %d success\n", _shmid);
            }
            else
            {
                ERR_EXIT("shmctl\n");
            }
        }
    }

public:
    Shm(const string &pathname, const int projid, const string &usertype) : _shmid(gdefault), _size(gsize), _start_men(nullptr), _usertype(usertype)
    {
        if (_usertype == CREATER)
            Create();
        else if (_usertype == USER)
            Get();
        else
        {

        }
        Attach();
    }
    void *VirtualAddr()
    {
        printf("VirtualAddr:%p\n", _start_men);
        return _start_men;
    }
    int Size()
    {
        return _size;
    }

    
    ~Shm()
    {
        //server和client都会调析构即Destroy但是只有server才会shmctl
        Destroy();
    }

private:
    int _flg;
    int _size;
    int _shmid;
    void *_start_men;
    string _usertype;
    key_t _key;
};

client.hpp

复制代码
#include "Shm.hpp"
int main()
{
    Shm shm(pathname, projid, USER);
    char *mem = (char *)shm.VirtualAddr();
    // 可以直接把这段空间当做malloc的空间一样直接使用
    int index = 0;
    // 初始置0:正在写,因为创建新的共享内存会默认把整块内存全部清为0
    mem[0] = 0;

    // 从mem+1开始存数据,跳过flag
    for (char c = 'A'; c <= 'E'; c++, index++)
    {
        sleep(1);
        mem[index + 1] = c;
    }

    // 全部写完,标记置1
    mem[0] = 1;
   // sleep(10);
    return 0;
}

server.hpp

复制代码
#include"Shm.hpp"
int main()
{
    Shm shm(pathname,projid,CREATER);
    char *mem=(char*)shm.VirtualAddr();
   
    while(mem[0]!=1)
    {
        sleep(1);
        printf("%s\n",mem+1);
    }
  
      //跳出循环 → main正常return → 局部shm对象析构执行shmdt清理
    printf("客户端写入完成,服务端准备退出\n");

    
    return 0;
}

运行时要先启动server(服务端)把共享内存创建出来,再运行client

若现在server端先退出(要return 0或者exit 终止执行了析构函数后的退出),client端后退出

会看到什么呢?

解释:

  1. 初始状态 :Server 和 Client 都调用 shmat 挂载了共享内存 右边 ipcs -m 显示:

    plaintext

    复制代码
    key 0x660135b2, shmid 22, nattch 2

    nattch=2 表示当前有 2 个进程挂载了这块共享内存。

  2. Server 调用 shmctl(IPC_RMID) 此时右边的状态变成:

    plaintext

    复制代码
    key 0x00000000, shmid 9, status dest
    • key 变成 0x00000000:内核标记这块共享内存为「待删除」,新进程无法再通过 shmget 打开它。
    • status 显示 dest:表示已被标记删除。
    • nattch 仍然是 1:说明 Client 进程还在挂载使用,所以物理内存和内核对象还没被释放。
  3. Client 退出 / 调用 shmdt nattch 会变为 0,此时内核才会真正释放这块共享内存,ipcs -m 中再也看不到 shmid=9 的条目。


关键结论

  • 调用 IPC_RMID 只是 "标记删除",不是立即删除
  • 共享内存会一直存在,直到所有挂载它的进程都解除挂载(shmdt 或进程退出),内核才会真正释放它。
  • 标记删除后,新进程无法再通过 shmget 打开,但已挂载的进程可以继续正常读写,直到自己解除挂载。

共享内存的优缺点

优点

  • 映射完成后,一方写入,另一方立刻能看到修改;
  • 无系统调用开销,进程间通信速度第一。

最大缺点

  1. 无内置同步机制内核不提供读写互斥、读写完成通知。会出现并发问题:
  • client 写一半,server 就读取残缺数据;
  • 多进程同时修改同一块内存,数据错乱(数据不一致)。
  1. 无自带数据保护没有锁、等待、唤醒逻辑,单纯一块裸内存,必须程序员手动实现同步。

共享内存的大小

举个例子我现在要申请4097

用户逻辑 size(shmget 第二个参数)

你代码里真正要用的缓冲区长度,。代码读写不能超过这个 size,越界访问会触发段错误

(就只给4097)。

内核物理段大小(shmid_ds.shm_segsz)

内核实际分配的、页对齐后的总字节数,ipcs -mbytes列就是这个值,永远是 4096 整数倍。

(会给4096*2)

即内核申请了4096*2但用户只能用4097。

管理共享内存的内核结构

可以用shmctl+IPC_STAT和创建的结构体来访问

复制代码
struct shmid_ds buf;//自己定义的
// 获取shmid对应共享内存内核信息
shmctl(shmid, IPC_STAT, &buf);
printf("段大小:%ld\n", buf.shm_segsz);
printf("权限:%ho\n", buf.shm_perm.mode);
printf("当前挂载进程数:%ld\n", buf.shm_nattch);
printf("创建进程PID:%d\n", buf.shm_cpid);

System信号量

1.信号量是什么

信号量本质是资源计数器,用于记录临界资源剩余可用数量。

2. 电影院类比

放映厅 = 整体临界资源;座位 = 资源细分单元;买票操作 = 信号量 P 申请操作;退票 = 信号量 V 释放操作。

  • 访问资源前必须 "买票(P 操作,sem--)",本质是资源预订机制
  • 无剩余座位(sem ≤ 0)时,进程阻塞挂起等待;
  • 看完了释放座位(V 操作,sem++)后,唤醒等待的进程
  • 操作系统内核保证 P/V 是原子操作,不会被中断

3.信号量解决共享内存并发问题

把一个放映厅当作一个共享内存

  1. 资源分区隔离将共享内存划分为独立区块(每一个座位就是一个子资源),每个进程只占用专属区块,互不读写对方区域,天然规避冲突。
  2. 信号量管控访问量用信号量限制同时进入临界区的进程总数,保证:
  • 不会多个进程抢占同一内存位置;
  • 不会涌入超出资源承载上限的进程。

4.二元信号量和多元信号量

二元信号量(互斥锁)

  • 定义:取值仅为01的信号量,初值固定设为 1。
  • 作用:实现互斥访问,同一时刻只允许一个进程进入临界区,等价于互斥锁 mutex。
  • 适用场景:资源必须整体独占使用

多元(计数)信号量

  • 定义:初值大于 1,对应多份独立可拆分资源(如电影院多个座位、共享内存多区块)。
  • 适用场景:资源可拆分、多个进程能同时占用不同子资源,互不冲突。

信号量和通信之间关系

1.要想访问信号量,多进程必须先共享同一个信号量

普通局部变量、全局变量仅属于单个进程,其他进程无法访问;System V 信号量专门解决该问题:创建内核层面持久化的信号量集,所有进程通过同一个 key 获取、访问同一份信号量,实现跨进程共享计数。

2.信号量属于 IPC 通信

传统认知里 IPC 是 "传递数据",但广义 IPC 定义:进程间能互相通知、同步、互斥,都属于进程通信

  • 信号量不传输业务数据;
  • 通过 P/V 操作改变共享计数器,完成进程间通知、同步、互斥,因此归类为 System V IPC 的一种。

System V 信号量全套系统调用

前置头文件

复制代码
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

semget ------ 创建 / 获取信号量集

复制代码
int semget(key_t key, int nsems, int semflg);

参数解读

  1. key:ftok 生成的 IPC 键;
  2. nsems信号量个数
  3. semflg:创建标记
    • IPC_CREAT:不存在则新建,存在则直接获取;
    • IPC_CREAT | IPC_EXCL:不存在才创建,已存在则报错,用于保证新建。

返回值

成功返回semid(信号量集标识符,后续所有操作的句柄);失败返回 - 1。

eg:

复制代码
// 创建1个信号量,权限666,不存在则新建
int semid = semget(key, 1, IPC_CREAT | 0666);

查看指令ipcs -s

semop ------ 执行原子 P/V 操作(核心)

复制代码
int semop(int semid, struct sembuf *sops, size_t nsops);

参数解读

  1. semid:semget 返回的信号量集 ID;
  2. sops:操作结构体数组指针,一次可批量操作多个信号量;
  3. nsops:本次要执行几条操作(数组长度)。

结构体 struct sembuf

复制代码
struct sembuf {
    unsigned short sem_num;  // 要操作集合里第几个信号量(下标从0开始)
    short sem_op;            // 操作值:-1=P申请,+1=V释放
    short sem_flg;           // 操作标记,常用0阻塞等待
};

eg:

复制代码
struct sembuf p_op = {0, -1, 0};
semop(semid, &p_op, 1);
//sem_num=0:操作集合第 0 个信号量;sem_op=-1=P;0 代表阻塞等待

semctl ------ 信号量集控制(初始化 / 删除 / 查询)

复制代码
int semctl(int semid, int semnum, int cmd, ...);

参数解读

  • semid:信号量集 ID;
  • semnum:集合中目标信号量下标;
  • cmd:控制指令

常用cmd

SETVAL:初始化信号量初值

设置单个信号量的初始资源计数,需要配套共用体union semun传参

复制代码
union semun {
    int val;               // SETVAL时填入信号量初始值
    struct semid_ds *buf;  // IPC_STAT/IPC_SET用
    unsigned short *array; // GETALL/SETALL批量读写
    struct seminfo *__buf; // Linux专属信息查询
};

信号量创建后默认初值为 0,必须用 SETVAL 手动初始化,否则无法正常 P/V。

eg:

复制代码
union semun {
    int val;
};
union semun su;
su.val = 1; // 二元信号量初值1,计数信号量填资源总数
// 给集合第0个信号量赋值
semctl(semid, 0, SETVAL, su);
IPC_RMID:标记待删除

先标记为待删除,直到所有持有 semid 的进程全部终止,内核才会真正释放信号量内存、彻底删除。

eg:

复制代码
// 销毁整个信号量集合
semctl(semid, 0, IPC_RMID, NULL);