一、IPC基础与内存模型:进程地址空间隔离、共享内存原理、内核态与用户态数据拷贝、mmap与shmget系统调用对比

各位同学,咱们今天聊IPC(进程间通信)的内存模型。说实话,我见过太多内存泄漏的案例,根源都在于对底层内存模型理解不够透彻。你想想看,两个进程之间要传数据,它们各自有各自的地址空间,怎么搞?

1.1 进程地址空间隔离——为什么不能直接访问对方内存?

每个进程都有自己的虚拟地址空间。在Linux上,32位系统是4GB,64位系统就更大了。这个空间是独立的,进程A的地址0x1234和进程B的地址0x1234,物理上完全是两码事。

我刚开始做嵌入式开发时,有个同事试图直接通过指针访问另一个进程的数据。结果呢?段错误,进程直接崩了。这就是地址空间隔离在保护你——谁都不能乱闯别人的地盘。

核心要点:进程地址空间隔离是操作系统安全的基础。一个进程不能直接读写另一个进程的内存,必须通过内核提供的IPC机制。

那怎么通信呢?说白了,就两条路:

  • 让内核当中间人——比如管道、消息队列、Socket,数据先拷贝到内核,再拷贝到目标进程
  • 共享一块物理内存——两个进程都映射到同一块物理页,这就是共享内存

1.2 共享内存原理——最快的IPC方式

共享内存为什么快?因为它避免了数据拷贝。两个进程直接读写同一块物理内存,不需要经过内核中转。

我记得有一次优化一个视频处理管道,原来用Socket传数据,延迟总是降不下来。换成共享内存后,吞吐量直接翻了5倍。嗯,这里要注意:共享内存虽然快,但同步问题你得自己解决。

个人经验:共享内存适合大数据量、高频次的通信场景。但如果你只是传几个字节的控制消息,用共享内存反而有点杀鸡用牛刀了。

共享内存的工作流程大致是这样的:

  1. 一个进程创建共享内存段(shmget或mmap)
  2. 其他进程附加(attach)到同一段内存
  3. 所有进程都可以直接读写
  4. 用完要分离(detach)并可能删除(shmctl IPC_RMID)

1.3 内核态与用户态数据拷贝——性能瓶颈在哪里?

你想想看,每次系统调用都要从用户态切换到内核态。这个切换本身就有开销,大概几十到几百纳秒。但更大的开销是数据拷贝。

以管道为例:

  • 进程A调用write(),数据从用户态缓冲区拷贝到内核缓冲区
  • 进程B调用read(),数据从内核缓冲区拷贝到用户态缓冲区

两次拷贝!如果数据量大,这个开销就很可观了。

避坑指南:我曾经在一个项目中,用管道传10MB的图片数据,结果发现CPU占用率飙升到80%。一分析,全是拷贝开销。后来改成共享内存,CPU直接降到15%。

共享内存为什么快?因为它只有一次映射开销,后续读写完全不需要系统调用。数据就在那里,两个进程直接访问。

1.4 mmap与shmget系统调用对比——选哪个?

这两个都是创建共享内存的方式,但各有侧重。我给大家整理了一个对比表:

特性 mmap shmget(System V)
创建方式 映射文件或匿名映射 直接创建共享内存段
持久性 依赖文件(文件映射)或进程生命周期(匿名) 内核持久,直到显式删除
访问控制 文件权限 权限+键值(key_t)
大小限制 受文件系统限制 受内核参数shmmax限制
同步机制 通常配合futex或自旋锁 可配合信号量(semget)
使用场景 文件映射、大文件处理、进程间大数据共享 经典IPC、需要持久化共享内存

我个人习惯:如果是临时共享数据,用mmap匿名映射更方便。如果需要在进程退出后数据还在,或者需要更精细的权限控制,我会选shmget。

来看个mmap的简单示例:

#include <sys/mman.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main() {
    // 创建匿名映射,大小4KB
    void *shared = mmap(NULL, 4096, 
                        PROT_READ | PROT_WRITE,
                        MAP_SHARED | MAP_ANONYMOUS,
                        -1, 0);
    
    if (shared == MAP_FAILED) {
        perror("mmap failed");
        return 1;
    }
    
    // 写入数据
    strcpy((char*)shared, "Hello from process A");
    
    // fork子进程
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程可以直接读取
        printf("Child reads: %s\n", (char*)shared);
    } else {
        // 父进程等待
        wait(NULL);
    }
    
    munmap(shared, 4096);
    return 0;
}

再看shmget的用法:

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

int main() {
    key_t key = ftok("/tmp", 'A');
    int shmid = shmget(key, 4096, 
                       IPC_CREAT | 0666);
    
    if (shmid == -1) {
        perror("shmget failed");
        return 1;
    }
    
    // 附加到共享内存
    void *shared = shmat(shmid, NULL, 0);
    if (shared == (void*)-1) {
        perror("shmat failed");
        return 1;
    }
    
    strcpy((char*)shared, "Hello via shmget");
    
    // 分离
    shmdt(shared);
    
    // 删除共享内存段
    shmctl(shmid, IPC_RMID, NULL);
    
    return 0;
}

关键区别:mmap的MAP_ANONYMOUS不需要文件,但只能用于有亲缘关系的进程(比如fork出来的子进程)。shmget通过key_t可以让任意进程找到同一块内存,更灵活。

1.5 内存泄漏的隐患——从创建到销毁

嗯,这里要重点说一下。共享内存用完后不释放,就是典型的内存泄漏。而且这种泄漏很隐蔽——进程退出后,共享内存段可能还在内核里。

我曾经接手过一个服务,跑了半年后系统报"无法分配共享内存"。一查,发现shmget创建了上千个共享内存段,都是之前进程异常退出时没清理的。每个段虽然不大,但架不住多啊。

所以,我建议:

  • shmget创建的段,一定要在适当时候调用shmctl IPC_RMID
  • mmap映射的,记得munmap
  • 进程异常退出时,要有清理机制(比如注册atexit处理函数)
  • 定期用ipcs -m检查系统中残留的共享内存段

小技巧:我习惯在代码里加一个宏,每次创建共享内存时记录到日志。出问题时,直接查日志就知道哪个模块没释放。

好了,这一章的内容就到这里。下一章我们会深入共享内存的同步问题——两个进程同时读写同一块内存,怎么保证数据一致性?到时候我会分享一个我踩过的坑,保证让你印象深刻。