进程、线程与 IPC 并发实战:不加锁的共享内存为何丢数据(srvB)
本篇基于 srvB(phase3)真实实操。覆盖
fork与僵尸进程、无名管道、命名管道 FIFO、System V 消息队列、共享内存+信号量、线程互斥锁与条件变量、阻塞/非阻塞 IO,以及select/epoll多路复用。最精彩的是竞态对比实验:同一段并发自增,无锁时结果从 200000 变成 137328。
1. 环境
Linux ecs-bee4-c6af-0002 6.8.0-106-generic x86_64 gcc 13.3.0 (pthread 用 -pthread 链接)全部 demo 一次编译通过:
$ gcc-Wall-ofork_demo fork_demo.c&&...&&gcc-Wall-oepoll_echo epoll_echo.c&&echo编译全部通过 编译全部通过2. fork 与僵尸进程
fork_demo演示了三件事:基本fork、stdout 缓冲区在子进程里被复制、以及僵尸进程的产生与回收。
我是子进程: pid=9888, 我的父进程 ppid=9887 ----- [3] 僵尸进程演示 ----- 子进程 9890 即将退出, 父进程暂时不回收 此时用 ps 查看子进程状态应为 Z (zombie) 父进程现在回收子进程, 僵尸消失僵尸进程的本质:子进程退出后,内核保留其 PCB 让父进程wait回收;父进程不回收,子进程就一直以Z状态存在,占用 PID。这是面试高频题,这里直接用ps看到了Z。
3. 进程间通信三件套
无名管道(父子进程):
子进程(pid=9958)收到: hello pipe ipc from embedded linux lab 父进程(pid=9957)发送: hello pipe ipc from embedded linux lab 父进程收到子进程处理结果: HELLO PIPE IPC FROM EMBEDDED LINUX LAB命名管道 FIFO(无亲缘关系进程):写端open会阻塞到读端打开,这是 FIFO 的典型语义。
写端发送: 消息1: FIFO 可以跨进程跨终端通信 写端发送: 消息2: 写端 open 会阻塞直到读端打开 读端收到: 消息1: FIFO 可以跨进程跨终端通信 读端收到: 消息2: 写端 open 会阻塞直到读端打开System V 消息队列:支持按类型接收,发 2 条收 2 条后删除。
消息队列已创建:key=0x51016002msqid=0父进程收到(type=1): 来自子进程(pid=9968)的问候 父进程收到(type=1): 子进程第二条: 消息队列支持按类型接收 消息队列已删除4. 重头戏:共享内存 + 信号量,竞态对比
父子进程各对共享内存计数器自增 100000 次,期望 200000。
不加任何锁:
[对比1] 不加锁直接写共享内存: 无锁竞态 : 期望 200000, 实际 137328 (发生数据丢失!)加 System V 信号量互斥:
[对比2] 使用 System V 信号量互斥: 信号量保护: 期望 200000, 实际 200000 (正确)为什么差 6 万多?因为counter++在汇编层是"读-改-写"三步,两个进程可能同时读到旧值、各自加一写回,一次自增就丢了。信号量把这段临界区变成原子操作,结果立即正确。这是并发编程最该刻进肌肉记忆的一课。
5. 线程:互斥锁与条件变量
线程共享地址空间,竞态更直观。两个线程各自增 1000000 次:
-----[1]互斥锁实验(2线程各自增1000000次)----- 不加锁: 期望2000000, 实际1055065(丢失更新!)加互斥锁: 期望2000000, 实际2000000(正确)条件变量实现生产者-消费者(缓冲区容量 8,各生产/消费 15 件),可以看到缓冲区满时生产者等待、空时消费者等待:
[生产者] 放入产品 13 (当前库存 8) [生产者] 缓冲区满, 等待... [消费者] 取出产品 7 (当前库存 7) [生产者] 放入产品 15 (当前库存 8) ... 全部完成, 最终库存 0条件变量 + 互斥锁,正是 bounded-buffer 问题的标准解法。
6. 阻塞 IO vs 非阻塞 IO
写端 3 秒后才写,读端表现截然不同:
-----[1]阻塞 IO -----[read 开始等待]16:31:00[read 返回]16:31:03 读到18字节: 数据终于来了 —— 中间约3秒进程在睡眠, CPU 可被调度给别的任务 -----[2]非阻塞 IO ----- 第1次 read: 返回 -1,errno=Resource temporarily unavailable 立即返回, 进程没有被阻塞 第2次 read: 返回 -1,errno=Resource temporarily unavailable 立即返回 结论: 阻塞IO逻辑简单但会挂起进程;非阻塞IO需要配合轮询或IO多路复用, 才能在单进程/单线程里同时服务多个IO来源7. IO 多路复用:select / epoll
非阻塞 IO 必须配多路复用才能"一边等一边干活"。本实验实现了select聊天广播服务器和epoll回显服务器——单进程同时看护多个客户端连接,这正是 Nginx、Redis 等高并发服务的基础模型。
实操记录中
select_server/epoll_echo均编译运行通过;epoll在大量连接下比select的 O(n) 轮询更高效(内核回调机制,O(1) 就绪通知),是生产环境的首选。
8. 小结
| 机制 | 适用场景 | 本实验关键结论 |
|---|---|---|
| 管道/FIFO/消息队列 | 进程间数据传递 | FIFO 的open阻塞语义、消息队列按类型收 |
| 共享内存 | 进程间大数据共享 | 必须配信号量,否则竞态丢数据 |
| 线程+互斥锁/条件变量 | 线程间协作 | 不加锁自增从 2000000 掉到 1055065 |
| 阻塞/非阻塞 + 多路复用 | 高并发 IO | 非阻塞要配select/epoll才实用 |
下一篇跳到 srvC:交叉编译、BusyBox 根文件系统与 QEMU 启动——以及一个折磨人的"32 位内核跑 64 位程序"启动陷阱。