C高级:关于进程通信之共享内存的学习记录

共享内存是 Linux System V IPC 机制中速度最快的进程间通信方式。它的核心思想很简单:让两个或多个进程映射同一块物理内存到各自的虚拟地址空间,一个进程写入的数据,另一个进程立刻就能看到——不需要经过内核的拷贝,也不需要数据的序列化与反序列化。本文将从概念原理到实际代码,系统讲解共享内存的使用方式以及 shmget()shmat()shmdt()shmctl() 四个核心函数。以下是我在职坐标的学习记录:

一、共享内存是什么

1.1 基本概念

共享内存(Shared Memory)是操作系统提供的一种 IPC 机制,它允许两个或多个无关进程将同一块物理内存区域映射到各自的虚拟地址空间中。一旦映射建立,进程就可以像操作普通内存一样对这块区域进行读写,其他映射了同一区域的进程也能立即看到这些变化。

打个比方:普通的管道通信就像用快递传东西——你把数据打包、交给内核(快递员),内核再转交给对方,中间有一次拷贝。而共享内存就像两个人共用同一张桌子——你往桌上放的东西,对方伸手就能拿到,没有中间环节。

1.2 为什么共享内存最快

在所有 IPC 方式(管道、消息队列、信号量、Socket、共享内存)中,共享内存的性能最高,原因在于:

  • 零拷贝:数据直接在用户空间的内存中读写,不经过内核缓冲区的复制。管道和消息队列都需要至少两次数据拷贝(用户空间 → 内核空间 → 用户空间),而共享内存一次都不需要
  • 无系统调用开销:映射建立后,后续的读写操作就是普通的内存访问,不涉及系统调用。
  • 低延迟:写入方修改内存后,读取方可以立刻感知到(当然需要配合同步机制)。

1.3 共享内存的核心特点

  • 速度最快:如上所述,零拷贝带来的性能优势。
  • 适用于大数据量:特别适合需要在进程间传递大量数据的场景,比如图像处理、音视频缓冲、数据库共享缓冲池等。
  • 不保证同步:共享内存本身不提供任何同步机制。如果两个进程同时读写同一块区域,就会出现数据竞争。因此共享内存通常需要配合信号量(Semaphore)互斥锁来实现同步。
  • 随内核持续:共享内存段在创建后会一直存在于内核中,直到被显式删除(通过 shmctl()IPC_RMID)或系统重启。即使所有使用它的进程都已退出,它仍然存在。
  • 不要求亲缘关系:任何进程只要知道共享内存的 key 或 ID,就可以连接并使用它,不限于父子进程。

1.4 共享内存的使用流程

使用共享内存进行进程间通信通常遵循以下四个步骤:

创建/获取共享内存 → 映射到进程地址空间 → 读写数据 → 解除映射 & 删除
     shmget()          shmat()           普通内存操作     shmdt() / shmctl()

下面逐一讲解这四个函数。

二、shmget() —— 创建或获取共享内存段

2.1 函数原型

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

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

2.2 函数作用

shmget() 用于创建一个新的共享内存段,或者获取一个已经存在的共享内存段的标识符(shmid)。它是使用共享内存的第一步。

每个共享内存段在内核中通过一个唯一的整数 ID(shmid)来标识,类似于文件描述符的角色。同时,创建时可以用一个 key 值作为"名字",方便不同进程通过相同的 key 找到同一块共享内存。

2.3 参数详解

参数 类型 说明
key key_t 共享内存的键值,用于标识一块特定的共享内存。不同进程通过相同的 key 来访问同一块共享内存。常用的赋值方式有两种:① 使用 ftok() 函数根据文件路径和项目 ID 生成一个 key(如 ftok("/tmp/myfile", 66));② 使用特殊值 IPC_PRIVATE(值为 0),表示创建一个私有的共享内存,只能通过 shmid 在父子进程间共享。
size size_t 要创建的共享内存段的大小(单位:字节)。如果 key 对应的共享内存已经存在,则 size 可以为 0(表示不关心大小,直接获取已有的)。如果是新建,则必须大于 0,内核会将大小向上取整到页大小的整数倍(通常 4KB 对齐)。
shmflg int 控制标志位,用于指定创建行为和权限。常用的标志如下。

shmflg 常用标志说明:

标志 说明
IPC_CREAT 如果 key 对应的共享内存不存在,则创建它。如果已存在,则直接返回已有的 shmid。
IPC_EXCL 必须与 IPC_CREAT 一起使用。如果 key 对应的共享内存已存在,则返回错误(errno = EEXIST)。用于确保创建的是全新的共享内存段,避免意外连接到别人已创建的。
0666 权限位,类似文件权限。0666 表示所有用户可读写,0600 表示仅所有者可读写。最终权限受 umask 影响。

常见组合:

// 创建或获取(已存在就复用),权限 0666
shmget(key, 1024, IPC_CREAT | 0666);

// 确保创建全新的,已存在则报错
shmget(key, 1024, IPC_CREAT | IPC_EXCL | 0666);

// 只获取已存在的,不存在则报错
shmget(key, 0, 0666);

2.4 返回值

  • 成功:返回共享内存段的标识符 shmid(一个非负整数)。
  • 失败:返回 -1,并设置 errno。常见错误包括 EEXISTIPC_CREAT | IPC_EXCL 下 key 已存在)、ENOENT(key 不存在且未指定 IPC_CREAT)、ENOMEM(内存不足)。

2.5 key 的生成方式

#include <sys/ipc.h>

key_t key = ftok("/tmp/myfile", 66);
// 第一个参数:一个已存在的文件路径
// 第二个参数:一个项目 ID(通常取 0~255 的整数)
// ftok 根据文件的 inode 和项目 ID 生成一个唯一的 key

2.6 示例代码

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

int main() {
    key_t key = ftok("/tmp", 66);
    if (key == -1) {
        perror("ftok failed");
        return 1;
    }

    // 创建 4KB 的共享内存段
    int shmid = shmget(key, 4096, IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget failed");
        return 1;
    }

    printf("共享内存创建成功,shmid = %d\n", shmid);
    return 0;
}

三、shmat() —— 将共享内存映射到进程地址空间

3.1 函数原型

#include <sys/shm.h>

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

3.2 函数作用

shmat()(shared memory attach)将 shmid 标识的共享内存段映射到调用进程的虚拟地址空间。映射成功后,进程就可以通过返回的指针直接读写这块共享内存,就像操作自己 malloc 出来的内存一样。

这是使用共享内存的第二步:光创建(shmget)还不够,必须"连接"(attach)到自己的地址空间才能使用。

3.3 参数详解

参数 类型 说明
shmid int 共享内存段的标识符,由 shmget() 返回。指定要映射的是哪一块共享内存。
shmaddr const void * 指定共享内存映射到进程地址空间的起始地址。通常传 NULL(即 0),让内核自动选择一个合适的地址。如果指定了非 NULL 地址,内核会尝试映射到该地址(需要对齐到页边界),但这会降低可移植性,一般不推荐。
shmflg int 映射的标志位,控制访问方式和映射行为。

shmflg 常用标志说明:

标志 说明
0 默认方式,映射为可读写(前提是共享内存权限允许)。这是最常用的值。
SHM_RDONLY 只读方式映射。进程只能读不能写,写入会触发 SIGSEGV 信号。适用于只需要接收数据的一方。
SHM_RND shmaddr 配合使用。当 shmaddr 不为 NULL 时,内核会将 shmaddr 向下取整到 SHMLBA(Shared Memory Low Boundary Address,通常为页大小)的整数倍。
SHM_REMAP Linux 特有。如果 shmaddr 处已有映射,则替换它(类似于 mmapMAP_FIXED)。

3.4 返回值

  • 成功:返回共享内存映射后的起始地址void * 类型),可以强制转换为需要的指针类型(如 char *int *、自定义结构体指针等)。
  • 失败:返回 (void *)-1,并设置 errno。注意返回值不是 NULL,而是 -1 的指针形式,因此判断时需要这样写:
void *ptr = shmat(shmid, NULL, 0);
if (ptr == (void *)-1) {
    perror("shmat failed");
}

3.5 示例代码

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

int main() {
    key_t key = ftok("/tmp", 66);
    int shmid = shmget(key, 4096, IPC_CREAT | 0666);

    // 映射共享内存,内核自动选择地址,可读写
    char *shm_ptr = (char *)shmat(shmid, NULL, 0);
    if (shm_ptr == (void *)-1) {
        perror("shmat failed");
        return 1;
    }

    // 向共享内存写入数据
    strcpy(shm_ptr, "Hello, shared memory!");
    printf("写入共享内存: %s\n", shm_ptr);

    // 注意:用完之后需要调用 shmdt() 解除映射(见下一节)
    return 0;
}

四、shmdt() —— 解除共享内存映射

4.1 函数原型

#include <sys/shm.h>

int shmdt(const void *shmaddr);

4.2 函数作用

shmdt()(shared memory detach)将之前通过 shmat() 映射的共享内存从当前进程的地址空间中解除映射。解除后,进程不再能访问这块内存,但共享内存段本身并不会被销毁——它仍然存在于内核中,其他进程仍然可以连接和使用。

这一点和 close() 关闭文件描述符类似:关闭只是断开当前进程与文件的关联,文件本身还在。要真正销毁共享内存段,需要使用 shmctl()IPC_RMID 命令(见下一节)。

4.3 参数详解

参数 类型 说明
shmaddr const void * 之前 shmat() 返回的映射地址。必须是 shmat() 的返回值,不能是映射范围内的其他地址。

4.4 返回值

  • 成功:返回 0
  • 失败:返回 -1,并设置 errno。常见原因包括传入的地址不是有效的共享内存映射地址(EINVAL)。

4.5 使用注意

  • 每个进程在使用完共享内存后,都应该调用 shmdt() 来解除映射。
  • 进程退出时,内核会自动解除该进程所有的共享内存映射,但显式调用 shmdt() 是良好的编程习惯,可以避免遗漏。
  • shmdt() 不会减少共享内存段的 shm_nattch(当前连接数)计数器——当然,解除映射后计数器会减 1,只有当计数减到 0 且设置了 IPC_RMID 标志时,共享内存段才会被真正销毁。

4.6 示例代码

// 假设 shm_ptr 是之前 shmat() 的返回值
if (shmdt(shm_ptr) == -1) {
    perror("shmdt failed");
} else {
    printf("已解除共享内存映射\n");
}

五、shmctl() —— 共享内存控制

5.1 函数原型

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

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

5.2 函数作用

shmctl() 是共享内存的控制命令接口,可以对共享内存段执行各种管理操作,包括:查询状态信息、修改权限、标记删除等。它是共享内存生命周期管理的核心函数。

5.3 参数详解

参数 类型 说明
shmid int 共享内存段的标识符,由 shmget() 返回。指定要对哪块共享内存执行操作。
cmd int 要执行的控制命令。常用命令见下表。
buf struct shmid_ds * 指向 shmid_ds 结构体的指针,用于接收查询结果或传入要设置的属性。不同的 cmd 命令对 buf 的使用方式不同:有些需要 buf 来接收数据,有些需要 buf 来提供数据,有些则根本不需要 buf(传 NULL 即可)。

cmd 常用命令说明:

命令 说明 buf 用法
IPC_STAT 查询共享内存段的当前状态信息,包括权限、大小、创建者、当前连接数等,结果存入 buf 指向的 shmid_ds 结构体。 输出参数,内核将信息写入 buf
IPC_SET 修改共享内存段的权限属性(uidgidmode)。从 buf 中读取要设置的值。 输入参数,内核从 buf 读取新权限
IPC_RMID 标记删除共享内存段。标记后,不会再有新的进程能连接它;已经连接的进程仍然可以继续使用,直到它们调用 shmdt() 解除映射。当所有连接都断开后,内核会真正释放这块内存。 NULL 即可

5.4 返回值

  • 成功:返回 0(对于 IPC_STATIPC_SETIPC_RMID);对于 IPC_INFOSHM_INFO,返回一个非负索引值。
  • 失败:返回 -1,并设置 errno

5.5 示例代码

查询共享内存信息:

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

void print_shm_info(int shmid) {
    struct shmid_ds buf;

    if (shmctl(shmid, IPC_STAT, &buf) == -1) {
        perror("shmctl IPC_STAT failed");
        return;
    }

    printf("共享内存信息:\n");
    printf("  大小: %zu 字节\n", buf.shm_segsz);
    printf("  当前连接数: %lu\n", (unsigned long)buf.shm_nattch);
    printf("  创建者 PID: %d\n", buf.shm_cpid);
    printf("  最后 attach 时间: %ld\n", buf.shm_atime);
    printf("  最后 detach 时间: %ld\n", buf.shm_dtime);
}

删除共享内存段:

// 标记删除,当所有进程都 shmdt 后,内存真正释放
if (shmctl(shmid, IPC_RMID, NULL) == -1) {
    perror("shmctl IPC_RMID failed");
} else {
    printf("共享内存已标记删除\n");
}

六、完整示例:两个进程通过共享内存通信

以下是一个完整的读写端示例,写端向共享内存写入一条消息,读端从共享内存读取该消息。

写端(shm_writer.c):

#include <stdio.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define SHM_SIZE 1024

int main() {
    key_t key = ftok("/tmp", 88);
    if (key == -1) {
        perror("ftok");
        return 1;
    }

    // 1. 创建共享内存
    int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }
    printf("[写端] 共享内存已创建,shmid = %d\n", shmid);

    // 2. 映射到本进程地址空间
    char *shm_ptr = (char *)shmat(shmid, NULL, 0);
    if (shm_ptr == (void *)-1) {
        perror("shmat");
        return 1;
    }

    // 3. 写入数据
    const char *msg = "Hello from shm_writer!";
    strcpy(shm_ptr, msg);
    printf("[写端] 已写入: %s\n", msg);

    // 等待读端读取(简单等待 5 秒)
    printf("[写端] 等待读端读取...\n");
    sleep(5);

    // 4. 解除映射
    shmdt(shm_ptr);

    // 5. 删除共享内存段
    shmctl(shmid, IPC_RMID, NULL);
    printf("[写端] 共享内存已标记删除\n");

    return 0;
}

读端(shm_reader.c):

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

#define SHM_SIZE 1024

int main() {
    key_t key = ftok("/tmp", 88);
    if (key == -1) {
        perror("ftok");
        return 1;
    }

    // 1. 获取已存在的共享内存(注意:不传 IPC_CREAT,如果不存在会报错)
    int shmid = shmget(key, SHM_SIZE, 0666);
    if (shmid == -1) {
        perror("shmget(请确保写端已先运行)");
        return 1;
    }
    printf("[读端] 已获取共享内存,shmid = %d\n", shmid);

    // 2. 映射到本进程地址空间
    char *shm_ptr = (char *)shmat(shmid, NULL, 0);
    if (shm_ptr == (void *)-1) {
        perror("shmat");
        return 1;
    }

    // 3. 读取数据
    printf("[读端] 读取到: %s\n", shm_ptr);

    // 4. 解除映射
    shmdt(shm_ptr);

    return 0;
}

编译运行:

gcc shm_writer.c -o shm_writer
gcc shm_reader.c -o shm_reader

# 终端 1:运行写端
./shm_writer &

# 终端 2:运行读端(在 5 秒内运行)
./shm_reader

七、共享内存与管道/消息队列的对比

对比维度 管道(Pipe) 共享内存(Shared Memory)
数据拷贝 2 次(写→内核→读) 0 次(直接内存访问)
速度 较慢 最快
同步机制 自带阻塞(缓冲区满/空时) 不提供,需自行实现
数据格式 字节流 任意(直接操作内存)
亲缘关系要求 匿名管道要求有亲缘 不要求
生命周期 随进程 随内核
适用场景 简单数据传递 大数据量、高性能场景

八、使用共享内存的注意事项

  1. 同步问题:共享内存不提供任何同步机制。多个进程同时读写时,必须配合信号量(semget/semop)或 POSIX 互斥锁来保证数据一致性,否则会出现数据竞争。
  2. 生命周期管理:共享内存段随内核持续存在,进程退出后不会自动销毁。如果忘记调用 shmctl(shmid, IPC_RMID, NULL),这块内存会一直占用系统资源。可以用 ipcs -m 命令查看系统中所有的共享内存段,用 ipcrm -m <shmid> 手动清除。
  3. 大小限制:共享内存的大小受系统参数限制,可以通过 /proc/sys/kernel/shmmax(单个段最大字节数)和 /proc/sys/kernel/shmall(系统总共享内存页数)查看和调整。
  4. 安全性:默认权限 0666 意味着系统中任何用户都可以读写这块内存。在生产环境中,建议设置为 0600 并配合特定用户运行。
  5. 映射地址不可移植:同一个 shmid 在不同进程中 shmat() 返回的地址可能不同(因为各进程的虚拟地址空间布局不同),所以不能在共享内存中存储绝对地址指针,只能存储偏移量。

九、总结

函数 作用 一句话记忆
shmget() 创建或获取共享内存段 get a shared memory segment
shmat() 将共享内存映射到进程地址空间 attach(连接)共享内存
shmdt() 解除共享内存映射 dt = detach(断开)共享内存
shmctl() 控制共享内存(查询/修改/删除) ctl = control(控制)共享内存

共享内存是 Linux IPC 中性能最强的通信方式,核心优势是零拷贝。但"快"也意味着"原始"——你必须自己处理同步问题。实际项目中,共享内存 + 信号量的组合是最经典的高性能 IPC 方案。

posted @ 2026-07-25 14:50  Solitude0420  阅读(17)  评论(4)    收藏  举报