第4章:共享内存(Shared Memory)——进程间最快的“快递员”

聊到进程间通信,我个人最偏爱的方式就是共享内存。为什么?说白了,它够快。你想想看,管道要拷贝数据,消息队列也要拷贝,但共享内存呢?两个进程直接读写同一块物理内存,零拷贝!

我在项目中遇到过一个大数据的场景:一个进程负责采集传感器数据,另一个负责实时分析。数据量动不动就几百兆。用管道?慢得让人抓狂。换成共享内存后,性能直接起飞。嗯,今天我们就来好好聊聊这个“快枪手”。

4.1 共享内存的原理与优势

共享内存的原理其实很简单。内核在物理内存中划出一块区域,然后让两个或多个进程的虚拟地址空间都映射到这块区域。这样一来,进程A往这块内存写数据,进程B立马就能看到。

它的核心优势就三个字:快、快、快。

  • 零拷贝:数据不需要在内核和用户空间之间来回搬运。这是它比管道、消息队列快一个数量级的根本原因。
  • 大数据友好:你传1KB和传1GB,开销几乎一样。因为只是修改了页表映射,没有数据拷贝动作。
  • 双向通信:只要双方约定好读写规则,共享内存天然就是全双工的。

核心要点:共享内存是IPC中吞吐量最高的方式,没有之一。但它的代价是——你需要自己处理同步问题。

为什么会这样?因为共享内存本身不提供任何锁机制。两个进程同时写一块区域,数据就乱套了。所以,共享内存通常要搭配信号量或互斥锁一起使用。这个我们后面会讲到。

4.2 System V 共享内存 API 详解

Linux下有两套共享内存API:POSIX的和System V的。我个人习惯用System V的,虽然接口老一点,但稳定可靠,我在生产环境用了很多年没出过问题。

核心API就四个函数:

函数 作用 头文件
shmget() 创建或获取共享内存段 sys/shm.h
shmat() 将共享内存附加到进程地址空间 sys/shm.h
shmdt() 分离共享内存 sys/shm.h
shmctl() 控制共享内存(删除、查询状态等) sys/shm.h

4.2.1 shmget() — 创建或获取

这个函数是第一步。它的原型是:

int shmget(key_t key, size_t size, int shmflg);
  • key:相当于共享内存的“门牌号”。两个进程用同一个key就能找到同一块内存。通常用ftok()生成。
  • size:共享内存的大小,单位字节。建议按页对齐(4KB的倍数)。
  • shmflg:权限标志。比如IPC_CREAT | 0666表示如果不存在就创建,权限为读写。

返回值是共享内存的标识符(shmid),后面三个函数都要用到它。

小技巧:我习惯用IPC_CREAT | IPC_EXCL来确保只有第一个进程负责创建,后面的进程只获取。这样可以避免重复初始化的问题。

4.2.2 shmat() — 附加到进程

创建好共享内存后,进程需要把它“挂载”到自己的虚拟地址空间里。这就是shmat()干的事:

void *shmat(int shmid, const void *shmaddr, int shmflg);
  • shmid:shmget返回的标识符。
  • shmaddr:建议传NULL,让内核自己选地址。指定地址容易出问题,我不推荐。
  • shmflg:一般传0。如果传SHM_RDONLY,就只能读不能写。

返回值是映射后的起始地址。之后你就可以像操作普通指针一样读写这块内存了。

4.2.3 shmdt() — 分离

用完了记得分离:

int shmdt(const void *shmaddr);

注意,分离只是解除映射,不会删除共享内存。共享内存会一直存在,直到你显式删除或系统重启。

4.2.4 shmctl() — 控制与删除

这个函数功能很强大。最常用的操作是删除:

shmctl(shmid, IPC_RMID, NULL);

删除后,所有进程都无法再attach。但已经attach的进程还能继续用,直到它们自己detach。

我曾经踩过的坑:忘记调用shmctl()删除共享内存,导致系统共享内存资源耗尽。用ipcs -m一看,一堆废弃的共享内存段。后来我写了个监控脚本,定期清理孤儿共享内存。

4.3 实战:两个进程通过共享内存交换大数据

光说不练假把式。我们写一个完整的例子:进程A写入1MB数据,进程B读取并校验。

4.3.1 公共头文件

// shm_common.h
#ifndef SHM_COMMON_H
#define SHM_COMMON_H

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

#define SHM_KEY 0x1234
#define SHM_SIZE (1024 * 1024)  // 1MB

typedef struct {
    int ready;          // 数据是否就绪
    int data_len;       // 实际数据长度
    char data[SHM_SIZE]; // 数据区
} shared_data;

#endif

这里我定义了一个结构体,包含一个ready标志位。为什么?因为共享内存没有同步机制,我们需要一个简单的“生产者-消费者”协议。进程A写完数据后把ready设为1,进程B看到1就开始读。

4.3.2 写入进程(writer.c)

// writer.c
#include "shm_common.h"

int main() {
    // 创建共享内存
    int shmid = shmget(SHM_KEY, sizeof(shared_data), IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget");
        exit(1);
    }

    // 附加到进程空间
    shared_data *shm = (shared_data *)shmat(shmid, NULL, 0);
    if (shm == (void *)-1) {
        perror("shmat");
        exit(1);
    }

    // 准备数据
    memset(shm->data, 'A', SHM_SIZE);
    shm->data_len = SHM_SIZE;

    // 内存屏障,确保数据写完后再设置标志
    __sync_synchronize();
    shm->ready = 1;

    printf("Writer: 数据已写入,共 %d 字节\n", shm->data_len);

    // 等待读者读完
    sleep(2);

    // 分离并删除
    shmdt(shm);
    shmctl(shmid, IPC_RMID, NULL);

    return 0;
}

注意我用了__sync_synchronize()这个内存屏障。为什么?因为编译器可能会重排指令,把ready=1提前到数据写入之前。那样读者就会读到不完整的数据。嗯,这个细节很容易被忽略。

4.3.3 读取进程(reader.c)

// reader.c
#include "shm_common.h"

int main() {
    // 获取共享内存(不创建)
    int shmid = shmget(SHM_KEY, sizeof(shared_data), 0666);
    if (shmid == -1) {
        perror("shmget");
        exit(1);
    }

    // 附加
    shared_data *shm = (shared_data *)shmat(shmid, NULL, 0);
    if (shm == (void *)-1) {
        perror("shmat");
        exit(1);
    }

    // 等待数据就绪
    while (!shm->ready) {
        usleep(100);  // 忙等待,实际项目建议用信号量
    }

    // 内存屏障,确保读到最新的数据
    __sync_synchronize();

    // 校验数据
    int valid = 1;
    for (int i = 0; i < shm->data_len; i++) {
        if (shm->data[i] != 'A') {
            valid = 0;
            break;
        }
    }

    if (valid) {
        printf("Reader: 数据校验成功!共 %d 字节\n", shm->data_len);
    } else {
        printf("Reader: 数据校验失败!\n");
    }

    // 分离
    shmdt(shm);

    return 0;
}

4.3.4 编译与运行

gcc -o writer writer.c
gcc -o reader reader.c

# 先运行writer,再运行reader(另一个终端)
./writer
./reader

运行结果应该是:

Writer: 数据已写入,共 1048576 字节
Reader: 数据校验成功!共 1048576 字节

关键点总结:

  • 共享内存本身不提供同步,需要自己实现协议(如这里的ready标志)。
  • 内存屏障是必须的,否则编译器和CPU的重排会坑你。
  • 生产环境中,建议用信号量或互斥锁替代忙等待。

4.4 避坑指南

最后,分享几个我实战中遇到的坑:

  • 共享内存泄漏:进程崩溃退出时,如果没调用shmctl(IPC_RMID),共享内存会变成孤儿。用ipcs -m查看,ipcrm -m shmid手动删除。
  • 大小限制:Linux默认的共享内存最大是32MB。如果需要更大,用sysctl -w kernel.shmmax=1073741824调整。
  • 权限问题:两个进程必须用相同的key,并且有足够的权限。我遇到过用root创建,普通用户无法attach的情况。
  • 指针陷阱:共享内存里不能存指针!因为不同进程的虚拟地址空间不同,指针指向的地址在另一个进程里是无效的。用偏移量代替。

好了,共享内存的内容就到这里。下一章我们聊聊信号量——那个专门用来给共享内存“上锁”的好搭档。