3、共享内存的同步与泄漏:共享内存与信号量配合使用、shmdt与shmctl的调用时机、共享内存段残留(ipcs命令查看僵尸段)、ftok冲突导致的内存泄漏

共享内存,说白了就是让两个进程直接读写同一块物理内存。速度确实快,但坑也不少。我这些年排查过的线上问题里,共享内存相关的泄漏占了不小的比例。今天咱们就聊聊这块的几个关键点。

3.1 共享内存与信号量配合使用

共享内存本身没有同步机制。两个进程同时写,数据就乱了。我见过一个项目,两个进程往共享内存里写日志,结果日志内容互相穿插,根本没法看。

解决办法很简单——加信号量。信号量就像一把锁,谁拿到谁才能操作共享内存。

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

// 创建信号量
int sem_id = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666);
// 初始化为1,表示可用
semctl(sem_id, 0, SETVAL, 1);

// 创建共享内存
int shm_id = shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0666);
void *shm_ptr = shmat(shm_id, NULL, 0);

// 写操作前加锁
struct sembuf sb = {0, -1, SEM_UNDO};
semop(sem_id, &sb, 1);

// 写入共享内存
memcpy(shm_ptr, data, size);

// 写完后解锁
sb.sem_op = 1;
semop(sem_id, &sb, 1);

这里有个细节——SEM_UNDO标志。进程意外退出时,信号量会自动归还。不然锁就永远拿不到了。嗯,这个我吃过亏。

我的习惯:信号量初始值设为1,就是互斥锁。如果允许多个读进程,可以设为N。但写操作必须独占。

3.2 shmdt与shmctl的调用时机

这两个函数容易搞混。我刚开始做项目时也分不清。

shmdt是把共享内存从当前进程的地址空间分离。进程退出时会自动调用,但显式调用更安全。

shmctl是真正删除共享内存段。调用shmctl(shm_id, IPC_RMID, NULL)后,所有进程都无法再attach。

调用时机很重要:

  • 用完就分离:不再需要访问共享内存时,立刻调用shmdt。别等到进程退出。
  • 最后一个进程分离后删除:所有进程都调用了shmdt,再调用shmctl删除。
// 正确的做法
void cleanup_shared_memory(int shm_id, void *shm_ptr) {
    // 1. 先分离
    if (shmdt(shm_ptr) == -1) {
        perror("shmdt failed");
        return;
    }
    
    // 2. 再删除
    if (shmctl(shm_id, IPC_RMID, NULL) == -1) {
        perror("shmctl failed");
    }
}
注意:先分离再删除,顺序不能反。如果先删除了共享内存段,再调用shmdt会失败。虽然不影响进程退出,但会留下错误日志。

3.3 共享内存段残留——僵尸段

进程崩溃了,共享内存没来得及删除。这就产生了僵尸段。用ipcs -m命令可以查看:

$ ipcs -m

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status      
0x00000000 123456     root       666        4096       0          dest
0x00000000 789012     root       666        8192       0          dest

看到nattch为0,statusdest的段,就是僵尸段。它们占着系统资源不释放。

我遇到过最严重的一次,生产环境上有几百个僵尸段,每个4KB,加起来占了几MB内存。虽然不多,但系统共享内存上限被耗尽,新进程创建共享内存时直接报错。

清理方法:

# 查看所有共享内存段
ipcs -m

# 删除指定段
ipcrm -m shmid

# 或者批量删除
ipcs -m | grep "dest" | awk '{print $2}' | xargs -I {} ipcrm -m {}
我建议:在程序启动时,先清理同key的僵尸段。用shmget时加IPC_CREAT | IPC_EXCL标志,如果段已存在就报错,然后删除重建。

3.4 ftok冲突导致的内存泄漏

ftok函数用文件路径和项目ID生成一个key。问题在于,不同文件可能生成相同的key。

key_t key1 = ftok("/tmp/file1", 'A');
key_t key2 = ftok("/tmp/file2", 'A');
// 有可能 key1 == key2

为什么会这样?ftok的实现是把文件的inode号和项目ID组合。如果两个文件在同一个文件系统,inode号可能相同(比如硬链接),或者项目ID相同,key就冲突了。

冲突的后果:两个进程以为用的是不同的共享内存,实际上操作的是同一块。数据互相覆盖,逻辑混乱。更糟的是,一个进程删除了共享内存,另一个进程还在用,直接段错误。

我曾经排查过一个诡异的问题:两个服务A和B,各自用共享内存存配置。结果A改了配置,B的配置也变了。查了半天,发现是ftok生成的key冲突了。

解决办法:

  • IPC_PRIVATE不依赖key,系统自动分配。但只能用于有亲缘关系的进程。
  • 用固定key:自己定义key值,比如0x1234。但要确保不冲突。
  • 检查key是否冲突:shmget尝试创建,如果返回EEXIST,说明key已被占用。
key_t key = ftok("/tmp/myapp.conf", 'X');
if (key == -1) {
    perror("ftok failed");
    exit(1);
}

int shm_id = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
if (shm_id == -1 && errno == EEXIST) {
    // key冲突,删除旧的再创建
    shm_id = shmget(key, 4096, 0);
    shmctl(shm_id, IPC_RMID, NULL);
    shm_id = shmget(key, 4096, IPC_CREAT | 0666);
}
总结一下:
  • 共享内存必须配信号量,不然数据会乱
  • shmdt和shmctl的调用顺序不能错
  • 定期用ipcs检查僵尸段,及时清理
  • ftok冲突是个隐蔽的坑,用IPC_PRIVATE或固定key更安全

嗯,这些经验都是真金白银换来的。下次咱们聊聊消息队列的泄漏问题,那个坑也不少。