
共享内存一、什么是共享内存共享内存是操作系统提供的一种进程间通信IPC机制允许两个或多个不相关的进程访问同一块物理内存。进程可以像操作自己的私有内存一样直接读写这块区域无需进行数据拷贝。本质多个进程的虚拟地址空间中的不同地址映射到同一块物理内存页面。特点优点无内核拷贝数据直接在内核空间操作效率极高。缺点无同步机制必须配合信号量或互斥锁使用防止数据竞争。ps对于“无同步机制”的理解共享内存不会自动阻止多进程并发读写带来的冲突冲突要程序员自己借助信号量或互斥锁来处理二、物理内存与进程逻辑地址的关系核心难点在操作系统中逻辑地址由程序生成是虚拟地址空间的一部分而物理地址则是内存中实际位置的标识。 两者通过内存管理单元MMU进行转换。逻辑地址与物理地址的区别在于逻辑地址独立于物理内存布局便于实现内存保护和碎片整理而物理地址直接指向硬件内存单元。 联系方面借助页表等机制逻辑地址可映射到对应的物理地址使程序无需关心内存具体分布。图解关系详细原理分4步理解第1步正常情况无共享内存text进程A: 虚拟地址 0x4000 → MMU → 物理地址 0x8000 进程B: 虚拟地址 0x5000 → MMU → 物理地址 0x9000不同进程的虚拟地址通过页表映射到不同物理页一个进程修改自己的内存另一个进程完全看不到第2步创建共享内存内核操作text内核分配一块物理内存例如物理页框号 0x1000 这块内存独立于任何进程第3步进程A附加共享内存text进程A调用 shmat() - 在进程A的页表中添加条目 - 虚拟地址例如0x4000→ 物理地址0x1000 - 此时进程A访问0x4000就是访问共享物理页第4步进程B附加同一共享内存text进程B调用 shmat() - 在进程B的页表中添加条目 - 虚拟地址例如0x5000→ 物理地址0x1000[同一个物理页] - 此时进程B访问0x5000也访问同一块物理页关键理解点概念说明物理内存真实存在的RAM芯片地址是全局唯一的虚拟地址每个进程独立的地址空间从0到最大值页表CPU的MMU使用的映射表虚拟地址→物理地址共享的关键不同进程的页表中不同虚拟地址映射到相同物理地址内存结构图更直观text【物理内存真实分布】 ┌──────────────┐ 0x0000 │ 内核空间 │ ├──────────────┤ 0x1000 ←─┐ │ 共享内存页 │ │ 同一块物理页 ├──────────────┤ 0x2000 ←─┘ │ 进程A私有页 │ ├──────────────┤ 0x3000 │ 进程B私有页 │ └──────────────┘ 【进程A视角的虚拟地址空间】 【进程B视角的虚拟地址空间】 0x0000 ┌────────┐ 0x0000 ┌────────┐ │ 代码段 │ │ 代码段 │ 0x4000 ├────────┤ ←─┐ 0x5000 ├────────┤ ←─┐ │ 共享区 │ │ │ 共享区 │ │ └────────┘ │ └────────┘ │ │ │ 物理0x1000◄─┘ 物理0x1000◄─┘三、共享内存相关函数shmget — 创建或获取int shmget(key_t key, size_t size, int shmflg);要点说明作用向内核申请一块共享内存或获取已存在的key全局唯一标识不同进程用同一个key找到同一块内存常用ftok()生成size内存大小字节建议按页4KB对齐shmflgIPC_CREAT没有就创建|IPC_EXCL存在则报错| 权限位如0666返回值shmid进程内句柄失败返回 -1shmat — 挂载到进程void *shmat(int shmid, const void *shmaddr, int shmflg);要点说明作用把共享内存段挂到当前进程的虚拟地址空间shmidshmget返回的IDshmaddr建议地址固定填NULL让系统自动分配shmflg常用0可读可写或SHM_RDONLY只读返回值挂载后的内存地址指针失败返回(void*) -1 拿到返回的指针后就可以直接用memcpy、strcpy等读写数据。shmdt — 脱离连接int shmdt(const void *shmaddr);要点说明作用将共享内存从当前进程的地址空间中分离shmaddr就是shmat返回的那个指针返回值成功 0失败 -1⚠️注意只是“断开连接”并不删除共享内存其他进程照常使用。shmctl — 控制主要是删除int shmctl(int shmid, int cmd, struct shmid_ds *buf);要点说明作用对共享内存执行控制操作shmidshmget返回的IDcmd最常用IPC_RMID标记删除buf属性结构体指针删除时直接填NULL返回值成功 0失败 -1IPC_RMID是延迟删除先标记等所有进程都shmdt后才真正销毁内核对象口诀shmget拿IDshmat挂地址shmdt断连接shmctl删内存。速度最快同步靠锁。完整示例代码c #includestdio.h #includestdlib.h #includeunistd.h #includesys/shm.h #includestring.h #includesem.c int main() { int shmidshmget((key_t)1234,128,IPC_CREAT|0600); //shmid接收操作系统分配的内存共享块的编号 if(shmid-1) { exit(1); } char *s(char )shmat(shmid,NULL,0);//映射到当前进程中可以通过s访问 if(s(char)-1) { exit(1); } sem_init();//调用的是#includesem.c文件中的函数函数实现详见上一章“进程间通信信号量” while(1) { char buff[128]{0}; printf(input\n); fgets(buff,128,stdin); sem_p(SEM1); strcpy(s,buff);//共享内存写入数据 sem_v(SEM2); if(strncmp(buff,end,3)0) { break; } } shmdt(s);//断开映射自己不用了别人可以用 }查看共享内存的命令ipcs -m # 查看所有共享内存 ipcrm -m shmid # 手动删除共享内存 cat /proc/sys/kernel/shmmax # 查看最大共享内存大小四、面试常考问题含答案要点Q1共享内存为什么是最快的IPCInter-Process Communication进程间通信答案要点其他IPC管道、消息队列、Socket需要两次数据拷贝用户→内核→用户共享内存0次拷贝进程直接访问物理内存但需配合同步机制如信号量防止竞态条件Q2共享内存如何解决同步问题答案要点本身不提供同步需配合其他IPC常用方案信号量推荐专门用于同步互斥锁条件变量需放在共享内存中文件锁fcntl自旋锁多核CPU优化Q3shmget中的key参数怎么理解答案要点key是共享内存或消息队列、信号量在内核中的全局唯一标识符类似文件路径不同进程使用相同的key才能获取同一共享内存生成方式ftok()根据文件路径和项目ID生成推荐IPC_PRIVATE创建私有共享内存key0直接指定数字不推荐可能冲突Q4shmat返回值是什么如果地址冲突怎么办答案要点返回附加后的虚拟地址用户空间指针失败返回(void *)-1地址选择策略shmaddr NULL内核自动选择空闲地址推荐指定地址SHM_RND自动向下舍入到页边界指定地址SHM_RDONLY只读附加Q5ftok() 的生成原理面试拓展key_t ftok(const char *pathname, int proj_id);输入说明pathname必须是一个存在的文件proj_id只有低8位有效0-255通常传一个字符计算方式说明key (文件 inode 号) (设备号) (proj_id)三者组合成一个几乎唯一的整数关键只要pathname和proj_id相同ftok()返回的key就一定相同所以不同进程只要传同样的参数就能拿到同一个 key。五、总结记忆口诀共享内存原理物理一块页虚址各自建页表映射巧数据零拷贝面试必考点最快原因 → 零拷贝最大缺陷 → 需同步核心函数 → get/at/dt/ctl物理关系 → 多虚一实限制调整 → sysctl配置进程a,b在逻辑上使用不同或相同的虚拟地址但这些虚拟地址通过页表映射到同一块物理内存精确描述三种情况情况1虚拟地址相同常见但非必须c// 进程Ashmat返回 0x4000 // 进程Bshmat返回 0x4000相同虚拟地址 // 结论不同的进程空间相同的虚拟地址映射到同一物理页情况2虚拟地址不同也很常见c// 进程Ashmat返回 0x4000 // 进程Bshmat返回 0x5000不同虚拟地址 // 结论不同的虚拟地址映射到同一物理页情况3同进程多次附加c// 同一进程可以多次附加同一共享内存 addr1 shmat(shmid, NULL, 0); // 返回 0x4000 addr2 shmat(shmid, NULL, 0); // 返回 0x6000不同虚拟地址 // 两个虚拟地址指向同一物理内存维度正确理解逻辑地址空间每个进程独立拥有互不干扰虚拟地址值逻辑地址 虚拟地址可能相同也可能不同由内核决定物理内存共享同一块这才是关键访问方式进程通过各自的虚拟地址访问同一块物理数据更准确的表述进程A和进程B在各自独立的逻辑地址空间中通过不同的虚拟地址或相同虚拟地址访问同一块物理内存页面。这块物理页面对两个进程而言是共享的但逻辑地址空间始终是独立的。消息队列一、进程间通信IPC- 消息队列定义消息队列是存放消息的链表内核维护。进程通过发送和接收消息块进行通信。特点面向数据块有边界不像管道是无边界字节流异步通信发送方无需等待接收方就绪。生命周期随内核持续即使进程退出队列依然存在需手动删除。vs 共享内存共享内存速度快但无同步消息队列自带同步队列空则读阻塞满则写阻塞但速度较慢涉及内核拷贝。二、System V 消息队列核心函数msgget— 创建或获取消息队列#include sys/msg.h int msgget(key_t key, int msgflg);参数类型说明keykey_t消息队列的全局唯一标识符类似共享内存的 keymsgflgint权限标志 创建选项msgflg常用组合标志含义IPC_CREAT如果队列不存在则创建存在则直接获取IPC_EXCL与IPC_CREAT配合使用若队列已存在则报错0600权限位八进制表示所有者可读可写0666权限位表示所有人可读可写返回值返回值含义 0成功返回消息队列 IDmsqid-1失败errno记录错误原因msgsnd— 发送消息int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);参数类型说明msqidintmsgget返回的消息队列 IDmsgpconst void*指向消息结构体的指针msgszsize_t消息数据部分的字节数不含mtypemsgflgint发送选项通常传 0消息结构体定义struct msgbuf { long mtype; // 消息类型必须 0,否则发送失败 char mtext[100]; // 消息数据(可以是任意类型) };msgrcv— 接收消息ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);参数类型说明msqidint消息队列 IDmsgpvoid*指向消息结构体的指针用于存放接收的数据msgszsize_t消息数据部分的最大字节数msgtyplong消息类型决定接收哪条消息msgflgint接收选项msgtyp接收规则重要msgtyp值接收规则 0接收队列中第一条消息FIFO不区分类型 0接收第一个mtype msgtyp的消息精确匹配 0接收类型值 ≤ |msgtyp|的最小类型的消息优先级接收msgctl— 控制消息队列int msgctl(int msqid, int cmd, struct msqid_ds *buf);参数类型说明msqidint消息队列 IDcmdint控制命令bufstruct msqid_ds*队列属性结构体指针用于查询/设置调试命令命令用途ipcs -q查看系统中所有消息队列状态key, msqid, 消息数, 总字节数ipcrm -q [msqid]手动删除指定的消息队列创建线程一、pthread_create函数int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);参数含义pthread_t *thread输出参数返回线程IDconst pthread_attr_t *attr线程属性NULL默认void *(*start_routine)(void*)线程入口函数void *arg传递给入口函数的参数编译命令gcc -pthread program.c -o program二、多线程经典Bug - 传递栈变量地址问题代码void* fun(void* arg) { int val *(int*)arg; // 从地址里取值 printf(线程 %d\n, val); return NULL; } int main() { pthread_t ids[5]; for (int i 0; i 5; i) { pthread_create(ids[i], NULL, fun, i); // ❌ 传的是 i 的地址 } for (int i 0; i 5; i) { pthread_join(ids[i], NULL); } }预期输出0, 1, 2, 3, 4顺序不定实际输出可能是2, 3, 4, 4, 4 或 1, 2, 3, 3, 4 或直接崩溃为什么会错分四层理解第 1 层i只有一个地址cfor (int i 0; i 5; i) { // 每次循环i 的地址不变都是同一个栈地址如 0x7ffc1234 // 变的只是这个地址里存的值0→1→2→3→4 pthread_create(..., i); // 5个线程收到的地址都一样 }关键i是地址不是值。5 次循环传的是同一个地址。第 2 层主线程和子线程同时跑text时间线 ───────────────────────────────────────────────────────── 主线程: i0 创建线程0 i1 创建线程1 i2 创建线程2 i3 创建线程3 i4 创建线程4 ↘ ↘ ↘ ↘ ↘ 子线程0: ──────────── 读 *arg ────────────────→ 读到可能已经是 4 子线程1: ──────────── 读 *arg ──→ 读到可能已经是 4 子线程2: ── 读 *arg → 读到可能是 3 或 4关键子线程不一定立即执行。等它执行时主线程的i可能已经变成其他值了。第 3 层所有线程读同一块内存text内存地址 0x7ffc1234 (主线程 i 的位置) │ ├── 线程0 ──┐ ├── 线程1 ──┤ 都指向同一个地址 ├── 线程2 ──┤ ├── 线程3 ──┤ └── 线程4 ──┘ 所有线程执行 *(int*)arg 时读的都是 0x7ffc1234 这个地址里的值 → 这个值被主线程不断修改 → 子线程读到什么全靠运气竞态条件第 4 层主线程退出后栈被回收最严重如果主线程在子线程结束前就退出了text┌─────────────────┐ │ main 的栈帧 │ ← 包含局部变量 i ├─────────────────┤ │ ... │ └─────────────────┘ ↓ main 返回后栈帧被回收 ┌─────────────────┐ │ 其他数据覆盖 │ ← i 所在的内存可能被其他数据覆盖 ├─────────────────┤ │ ... │ └─────────────────┘ ↓ 子线程此时执行 *(int*)arg ↓ 访问的是一块已被回收的内存 → 野指针 → 段错误Segmentation Faultps:用生活例子理解场景对应代码你有一块小白板栈变量i的地址i你在白板上写数字0、1、2、3、4for循环修改i你叫了 5 个朋友来看白板创建 5 个线程pthread_create(..., i)朋友不是马上来看而是过一会儿才来线程调度延迟朋友来看的时候白板上的数字已经被你改掉了线程读到的是i的当前值你走了白板被收走了朋友还来看主线程退出栈被回收野指针三、正确修复方案对比方案 1传值✅ 推荐最简单pthread_create(ids[i], NULL, fun, (void*)(long)i); // ↑ // 把 i 的值转成指针传过去原理说明传的是i的值不是地址每个线程收到的是数字副本如 0、1、2、3、4线程内部用(int)(long)arg取值每个线程独立持有自己的数字互不影响void* fun(void* arg) { int val (int)(long)arg; // 直接从 arg 取值不用解引用 printf(%d\n, val); }方案 2动态分配堆内存cint* p malloc(sizeof(int)); *p i; pthread_create(ids[i], NULL, fun, p); // 传堆地址原理说明在堆上分配内存堆内存不会随函数返回而销毁每个线程有自己的p每个线程指向不同的堆地址互不干扰void* fun(void* arg) { int* p (int*)arg; int val *p; free(p); // 用完后释放 printf(%d\n, val); }方案 3数组独立存储int vals[5]; for (int i 0; i 5; i) { vals[i] i; pthread_create(ids[i], NULL, fun, vals[i]); }原理说明数组在栈上分配但vals数组的生命周期覆盖整个for循环每个元素地址不同vals[0]、vals[1]... 各不相同四、传递地址 vs 传递值的核心结论传递方式代码示例线程间是否共享风险传地址pthread_create(..., i)✅共享同一块内存⚠️ 竞态条件、野指针传值pthread_create(..., (void*)(long)i)❌每个线程独立副本✅ 安全核心原则传地址→ 多个线程操作同一块内存 → 需要同步加锁传值→ 每个线程拥有自己的数据副本 → 不需要同步五、图解对比❌ 错误方式传地址text主线程栈: ┌─────────────┐ │ i 当前值 │ ← 地址 0x7ffc1234 └─────────────┘ ↑ ├─────── 线程0 的 arg ──────┐ ├─────── 线程1 的 arg ──────┤ 都指向同一个地址 ├─────── 线程2 的 arg ──────┤ ├─────── 线程3 的 arg ──────┤ └─────── 线程4 的 arg ──────┘✅ 正确方式传值text主线程栈: ┌─────────────┐ │ i 4 │ ← 主线程自己用 └─────────────┘ 每个线程的 arg 独立: 线程0: arg 0x0 (数字0) 线程1: arg 0x1 (数字1) 线程2: arg 0x2 (数字2) 线程3: arg 0x3 (数字3) 线程4: arg 0x4 (数字4)总结循环变量i的地址是唯一的所有线程都指向同一个地方。主线程不断改这个地址里的值子线程执行时间不确定读到什么全看运气。正确做法是传值把数字本身传过去而不是传地址。竞态条件Race Condition-val非原子性竞态条件 多个执行单元 × 共享资源 × 无序访问竞态条件产生的三个条件缺一不可多个执行单元— 存在两个或以上的并发执行体如线程、进程、中断处理程序、信号处理器等。访问同一个共享资源— 这些执行单元会操作同一份数据如全局变量、共享内存、文件、数据库记录等。至少一个是写操作且访问顺序不受控制— 其中至少有一个执行单元会修改该资源并且它们对该资源的访问顺序无法预测即调度器或外部事件决定谁先谁后。一、先看清本质val不是一步完成的c val; // 实际拆分为3步LOAD → ADD → STORE指令操作类比第1步LOAD从内存读取val的值到 CPU 寄存器从黑板抄数字到草稿纸第2步ADD寄存器里的值 1在草稿纸上加1第3步STORE把寄存器里的值写回内存把草稿纸上的数字抄回黑板关键这三步是分开执行的不是一瞬间完成的这就给了其他线程插队的机会。并发执行示意图text线程A: LOAD val100 线程B: LOAD val100 (同时读到旧值) 线程A: ADD 100→101, STORE val101 线程B: ADD 100→101, STORE val101 (覆盖) 结果: val只增加了1丢失一次更新二、竞态条件的三个典型现象现象 1输出重复text线程打印: 1, 2, 3, 3, 4, 5, 5, 6, 7, 8, 8, 9 ... ↑ ↑ 重复了3 重复了8现象 2输出跳数text线程打印: 1, 2, 4, 6, 7, 10, 11 ... ↑ ↑ ↑ 跳过了3 跳过了5 跳过了8,9现象 3最终值远小于预期text理论值: 5000 实际值: 2673 ← 少了将近一半三、非原子性到底是什么意思概念解释原子操作要么全部执行完要么完全不执行中间不能被中断非原子操作可以被拆分成多个步骤中间可以被其他线程插进来val是非原子的因为它的三步LOAD→ADD→STORE之间可能被其他线程打断。text原子操作 一颗手雷拉了环就瞬间爆炸没有中间状态 非原子操作 煎鸡蛋要经历打蛋→煎→翻面多个步骤每一步都可能被干扰四、竞态条件 vs 数据竞争面试进阶很多人把这两个混为一谈其实有区别术语定义例子竞态条件程序执行结果依赖于事件发生的顺序或时机两个线程谁先执行val会影响最终结果数据竞争两个线程同时访问同一内存且至少一个是写操作两个线程同时执行val都是写竞态条件包含数据竞争。数据竞争是竞态条件最常见的一种形式。五、如何修复三种方法方法 1互斥锁—— 最常用pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; void* thread_func(void* arg) { for (int i 0; i 1000; i) { pthread_mutex_lock(lock); // 加锁 val; // 临界区 pthread_mutex_unlock(lock); // 解锁 } }原理说明每次只有一个线程能进入临界区其他线程在lock处阻塞等待val的三步变成了不可分割的整体不会再被其他线程插进来方法 2原子操作C11c #include stdatomic.h atomic_int val 0; void* thread_func(void* arg) { for (int i 0; i 1000; i) { atomic_fetch_add(val, 1); // 原子化的 val } }原理说明CPU 提供特殊的原子指令如 x86 的LOCK XADD硬件层面保证 LOAD→ADD→STORE 不可分割方法 3信号量信号量通过原子化的P/V操作保证临界区同一时刻只有一个执行单元进入从而解决竞态问题c sem_t sem; sem_init(sem, 0, 1); // 初始值1二元信号量 void* thread_func(void* arg) { for (int i 0; i 1000; i) { sem_wait(sem); // P操作减1值为0则阻塞 val; sem_post(sem); // V操作加1 } }Shell重定向 -./b fife.txt一、三个默认通道通道文件描述符默认去向标准输入stdin0键盘标准输出stdout1屏幕标准错误stderr2屏幕二、常用重定向命令命令效果./b filestdout → file覆盖./b filestdout → file追加./b 2 errstderr → err./b filestdout 和 stderr → fileShell在程序启动前将文件描述符指向从“屏幕”改为“文件”程序本身无感知。多线程编程中要注意数据的共享方式传值vs传地址和操作的原子性否则会产生竞态条件信号量等同步机制可以保证临界区的互斥访问而消息队列则是进程间通信的一种方式shell重定向则用于控制程序的输出流向。生产者消费者,读者写者比较