本文要点
mkfifo出来的文件ls -l第一位是p、size 恒为0:数据只在管道缓冲区里,从不落盘(实测传完数据后仍是size=0 blocks=0)- 没有读者时写端会一直阻塞:实测
timeout 2到点杀进程返回124;改成exec 3<>fifo读写方式打开则立刻返回0 - System V 共享内存是内核里的一块段,不是文件:
ipcs -m看得见,/dev/shm里一个文件都没有 - 200MB 的段被触碰后
free -m的shared列从0跳到200、available掉 200,used反而变小——查共享内存不能只看 used - 段的生命周期与进程无关:两个进程都退出了,
nattch=0的段还留在ipcs -m里,不ipcrm就一直在
Linux 进程间通信:从命名管道 FIFO 到共享内存与信号量
两个进程要交换数据,最顺手的是管道——ps aux | grep nginx 每天都在用。但管道有个硬限制:它只存在于有亲缘关系的进程之间,而且是匿名的,ls 看不到它,别的进程也找不到它。一旦需求变成「A 进程和 B 进程是两个独立的服务,谁也不认识谁」,匿名管道就没法用了。
本文接着往下走一层,把 Linux 上另外三条进程间通信(IPC)的路子跑一遍:命名管道 FIFO(有名字、能跨任意进程)、System V 共享内存(一块内核里的段,零拷贝)、信号量(用来给共享内存上锁)。所有命令都在一台 Ubuntu 22.04 云服务器(KVM,1 核、内存 1.9G 无 swap,内核 5.15.0-30-generic)上真实执行,输出原样贴出。
⚠️ 先划清和 Linux 管道与重定向:从文件描述符到进程替换 的边界:那篇讲的是 shell 层面的匿名管道、文件描述符和进程替换(|、2>&1、>(...)),用完之后管道就随进程结束了;本文讲的是内核提供的 IPC 对象——FIFO 有文件名、SysV 段有 key、它们都有独立于进程的生命周期,也因此都需要你手动清理。
第一步:FIFO —— 有名字的管道
mkfifo 造出来的东西在文件系统里是一个特殊文件,第一列是 p:
cd /tmp && rm -f ipc.fifo && mkfifo ipc.fifo && ls -l ipc.fifo && timeout 2 bash -c 'echo hello > /tmp/ipc.fifo'; echo "无读者写入 exit=$?"
两个信息点:
prw-r--r--的p就是 pipe,权限、属主和普通文件一样受管,所以「谁能往这个 FIFO 里写」是靠文件权限控制的,不需要额外协议;- 那条
echo没有报错,也没有返回——它卡在open()上,这正是timeout在 2 秒后动手、退出码为124的原因(124是timeout自己的「超时了」约定值,不代表写入失败)。
「没有读者就阻塞」是 FIFO 最容易踩的坑:半夜跑个定时脚本往里写日志,如果收集端挂了,脚本会静默挂死在这儿,而不是报错退出。给这类写入套一层 timeout 是最省事的保险。
第二步:FIFO 的数据在哪、怎么绕开阻塞
换个玩法:先起一个读者,再写入,最后看看 FIFO 文件本身变大了没有:
cd /tmp && timeout 2 bash -c 'exec 3<>ipc.fifo && echo "O_RDWR 方式写入不阻塞" >&3' && echo "O_RDWR exit=$?" && rm -f got.txt && (timeout 2 cat ipc.fifo > got.txt &) && sleep 0.3 && echo "hello over fifo" > ipc.fifo && sleep 0.5 && echo "读者收到: $(cat got.txt)" && stat -c 'FIFO 本体 size=%s blocks=%b' ipc.fifo
三行输出对应三件事:
O_RDWR(exec 3<>fifo)不阻塞。只读打开要等写者、只写打开要等读者,而读写方式打开两边都满足,于是立刻成功——这解释了为什么有的脚本用< >打开 FIFO 就「活」了,数据其实是被塞进管道缓冲区后丢掉了。- 数据传过去了:
读者收到: hello over fifo。 - FIFO 文件本身还是空的:
size=0 blocks=0。数据从来不在磁盘上,它只存在于内核的管道缓冲区里,读者取走就没了;blocks=0说明连一个数据块都没分配。
顺带一个和写入安全相关的数字:这台机器上 getconf PIPE_BUF /tmp 是 4096,POSIX 保证不超过 PIPE_BUF 字节的单次写入是原子的——多个进程同时往一个 FIFO 写日志时,只要每行不超过 4KB 就不会互相截断;超了就可能交错。这和匿名管道是同一套缓冲区规则。
第三步:System V 共享内存 —— 一块内核里的段
FIFO 每次读写都要过一次内核拷贝。如果两个进程要反复读同一大块数据,共享内存是更直接的做法:同一块物理内存映射进两个进程的地址空间,谁改了对方立刻看得见,没有拷贝开销。
Linux 上的 System V 共享内存用 key 标识(shmget 拿 key 建段,shmat 把段挂进自己的地址空间)。写端:
import ctypes, os
libc = ctypes.CDLL(None, use_errno=True)
libc.shmget.restype = ctypes.c_int
libc.shmat.restype = ctypes.c_void_p # 不写这行, 64 位指针会被按 int 截断
key = libc.ftok(b"/tmp/ipcdemo", 65)
shmid = libc.shmget(key, 4096, 0o666 | 0o1000) # 0o1000 = IPC_CREAT
buf = libc.shmat(shmid, None, 0)
msg = b"pid=%d wrote this into shm" % os.getpid()
ctypes.memmove(buf, msg, len(msg) + 1)
print(f"key=0x{key:x} shmid={shmid} wrote: {msg.decode()}")读端只有两处不同——shmget 不传 IPC_CREAT(只挂接已存在的段),以及把指针当 C 字符串读回来:
import ctypes
libc = ctypes.CDLL(None, use_errno=True)
libc.shmget.restype = ctypes.c_int
libc.shmat.restype = ctypes.c_void_p
key = libc.ftok(b"/tmp/ipcdemo", 65)
shmid = libc.shmget(key, 4096, 0o666) # 不带 IPC_CREAT: 只挂接已存在的段
buf = libc.shmat(shmid, None, 0)
print("reader saw:", ctypes.string_at(buf).decode())两个独立的 Python 进程、没有任何父子关系,跑一遍看结果:
cd /tmp/ipcdemo && for k in $(ipcs -m | awk '$1 ~ /^0x/ {print $1}'); do ipcrm -M $k; done; python3 shm_write.py && python3 shm_read.py && ipcs -m && ipcs -m -p | tail -3
(命令开头那个 for 循环是先清掉上一次演示残留的段——原因见下一节。)
几个关键读法:
key=0x4101ecf3:这个 key 不是随机的,来自ftok(路径, proj_id)。glibc 的算法是key = (proj_id & 0xFF) << 24 | (设备号 & 0xFF) << 16 | (inode & 0xFFFF)——实测proj_id=65时 key 的最高字节正好是0x41,另一个目录(inode 519453,低 16 位0xED1D)算出来的 key 低 16 位也正好是0xed1d。同一个路径只要 inode 不变,两次独立运行拿到的 key 就完全一样(本文两次运行都是0x4101ecf3);反过来说,文件被删掉重建、inode 换了,key 就跟着换,这正是「重启后连不上原来那段共享内存」的经典原因。nattch=0:两个进程都退出了,挂接数为 0,但段还在。共享内存段的生命周期和进程完全无关,这是它跟 FIFO 最大的区别。ipcs -m -p里的cpid/lpid是「创建者 / 最后一次操作的进程」,这里103488(写)和103489(读)是两个不同的 PID,正好证明数据是跨进程拿到的。
进程退出后段还在,意味着忘记 ipcrm 就是内存泄漏。在一台只有 1.9G 内存、还没有 swap 的机器上,这种泄漏会直接把你送到 OOM。
第四步:共享内存在 free 里长什么样
这是一台内存吃紧的机器,所以专门验证一下:一个 200MB 的 SysV 段被真正触碰之后,内存账本怎么变。先看分配前:
free -m total used free shared buff/cache available
Mem: 1977 171 63 0 1742 1613
Swap: 0 0 0然后 shmget 要一段 200MB 的共享内存,并逐页写一遍:
import ctypes, os
libc = ctypes.CDLL(None, use_errno=True)
libc.shmget.restype = ctypes.c_int
libc.shmat.restype = ctypes.c_void_p
key = libc.ftok(b"/tmp/ipcdemo", 66)
size = 200 * 1024 * 1024
shmid = libc.shmget(key, size, 0o666 | 0o1000)
buf = libc.shmat(shmid, None, 0)
for off in range(0, size, 4096): # 逐页触碰, 逼内核真正分配物理页
ctypes.memset(buf + off, 1, 1)
print(f"shmid={shmid} 已写入并触碰 {size // 1024 // 1024} MB")shmid=4 已写入并触碰 200 MB⚠️ 注意最后那个循环不能省:shmget 只是向内核申请一段地址空间,真正占物理内存要等每一页被写到。只 shmget 不触碰的话,段在 ipcs -m 里照样显示 200MB,但内存其实还没被吃掉(前面 POSIX 那组 df 只涨 4.0K 也是同一个道理)。所以「ipcs -m 显示很大」和「内存真的被占了」是两件事,测内存占用必须逐页触碰。
再看触碰之后:
free -m total used free shared buff/cache available
Mem: 1977 148 77 200 1751 1435
Swap: 0 0 0ipcrm -M 0x4201ecf3 把段删掉之后:
total used free shared buff/cache available
Mem: 1977 185 244 0 1548 1599前后三组数字:
| 时刻 | total | used | free | shared | buff/cache | available |
|---|---|---|---|---|---|---|
| 分配前 | 1977 | 171 | 63 | 0 | 1742 | 1613 |
| 200MB 段已分配并触碰 | 1977 | 148 | 77 | 200 | 1751 | 1435 |
ipcrm 之后 | 1977 | 185 | 244 | 0 | 1548 | 1599 |
注意第二行那对反向的数字:shared 涨了 200,used 反而降了 23。used 是按 total - free - buff/cache 算的,而共享内存不计进 used——所以排查「内存不知去哪了」只看 used 会漏掉共享内存,要看 shared 列和 available 列。这条规律和 Linux 内存排查:从 free 到 /proc/meminfo 与 OOM Killer 定位 里那套思路是接得上的:available 掉下去才是真的吃紧。
同时验证一个反直觉的点:ipcs -m 里躺着 200MB 的段,而 /dev/shm 里一个文件都没有(ls -l /dev/shm/ 是空的)——因为 SysV 共享内存不在文件系统里,它只在 ipcs 和 /proc/sysvipc/shm 里可见。磁盘工具看不见它,内存工具才看得见。
对照一下 POSIX 共享内存(shm_open)就不一样了:Python 的 multiprocessing.shared_memory 建的段就是 /dev/shm 下的一个文件,ls -l 能看到 1048576 字节。有意思的是 df -h /dev/shm 只涨了 4.0K——tmpfs 按实际触碰的页计费,声明了 1MB 但只写了 13 字节,就只占一页。而且这类文件在创建进程退出时会被 Python 的 resource_tracker 回收掉(实测第二个进程再去挂接时文件已经不存在了);SysV 那边没有这个自动回收,只能靠你 ipcrm。
第五步:信号量 —— 给共享内存上锁
共享内存本身不提供任何互斥:两个进程同时往同一个结构里写,数据就烂了。SysV 的配套工具是信号量集合。
ipcmk -S 2 && ipcs -s && ipcrm -S $(ipcs -s | awk '$1 ~ /^0x/ {print $1}') && echo "已删除信号量集" && ipcs -sSemaphore id: 1
------ Semaphore Arrays --------
key semid owner perms nsems
0xaa5f428a 1 root 644 2
已删除信号量集
------ Semaphore Arrays --------
key semid owner perms nsemsipcmk -S 2 里的 2 是信号量个数,它回显的 Semaphore id: 1 是 semid(和 key 不是一回事);ipcs -s 表里的 nsems=2 才对得上。删除时注意大小写:大写 -S/-M 按 key 删,小写 -s/-m 按 id 删,混了会报 invalid argument,这是清理时最常见的卡点。
同样的 ipcmk 也能建共享内存和消息队列:ipcmk -M 4096 建一个 4096 字节的段(实测 ipcs -m 里多出一行 key=0x2f2ef7b2 perms=644),ipcmk -Q 建消息队列。三条命令对应三种 IPC 对象,默认权限都是 644——比我们自己用 shmget 传 0666 建出来的要严,多进程协作时留意这个差别。
顺带把内核的上限看一眼,方便估容量:
ipcs -l------ Shared Memory Limits --------
max number of segments = 4096
max seg size (kbytes) = 18014398509465599
max total shared memory (kbytes) = 18446744073709551612
min seg size (bytes) = 1
------ Semaphore Limits --------
max number of arrays = 32000
max semaphores per array = 32000
max semaphores system wide = 1024000000
max ops per semop call = 500
semaphore max value = 32767
------ Messages Limits --------
max queues system wide = 32000
max size of message (bytes) = 8192
default max size of queue (bytes) = 16384cat /proc/sys/kernel/shmmax /proc/sys/kernel/shmmni /proc/sys/kernel/msgmax18446744073692774399
4096
8192这台机器上 shmmni=4096(最多 4096 个段)、msgmax=8192(单条消息最大 8KB)、msgmnb=16384(单个队列默认 16KB),而 shmmax 是 18446744073692774399(约 16EB,等于没有限制)——也就是说内核不会拦你分配一块过大的共享内存,能拦住你的只有物理内存和 OOM Killer。这也是为什么前面那 200MB 的段必须记得清掉。
第六步:清理姿势与常用排查命令
把这一套 IPC 对象的生命周期和清理方式整理成一张表:
| 对象 | 创建 | 查看 | 删除(按 key / 按 id) | 会自动消失吗 |
|---|---|---|---|---|
| FIFO | mkfifo /tmp/x.fifo | ls -l(第一位 p) | rm | 不会,就是文件系统里的一个文件 |
| SysV 共享内存 | ipcmk -M 4096 / shmget | ipcs -m、/proc/sysvipc/shm | ipcrm -M <key> / ipcrm -m <id> | 不会,进程退出后段仍在 |
| SysV 信号量 | ipcmk -S 2 / semget | ipcs -s | ipcrm -S <key> / ipcrm -s <id> | 不会 |
| SysV 消息队列 | ipcmk -Q / msgget | ipcs -q | ipcrm -Q <key> / ipcrm -q <id> | 不会 |
| POSIX 共享内存 | shm_open(Python 的 shared_memory) | ls /dev/shm、df -h /dev/shm | shm_unlink / rm /dev/shm/xxx | 文件形式,随 tmpfs 与进程行为而定 |
清理时最省事的一行是「把当前所有段和信号量都收掉」(演示前清场、脚本收尾都用它):
for k in $(ipcs -m | awk '$1 ~ /^0x/ {print $1}'); do ipcrm -M $k; done
for s in $(ipcs -s | awk '$1 ~ /^0x/ {print $1}'); do ipcrm -S $s; done⚠️ 但这条命令在多用户机器上要慎用:ipcs 列的是全系统的 IPC 对象,不止你自己的。真要精确清理,用 ipcs -m -p 里的 cpid 认领自己创建的段,或者一开始就用 IPC_EXCL 避免误挂别人的段。
几个日常排查会用到的点:
- 谁在占着这段共享内存:
ipcs -m -p的cpid/lpid,再顺着 PID 看ps -p <pid> -o cmd=; - 某个进程打开了哪些 FIFO:
lsof /tmp/x.fifo,或者ls -l /proc/<pid>/fd | grep p(FIFO 在 fd 里的类型标记也是p),思路和 lsof 进程文件排查:从打开文件到端口占用的完整指南 一致; - 进程卡在管道上不动:先怀疑 FIFO 的阻塞语义,Linux 信号与后台任务:从 kill 终止到 nohup 与 setsid 里那套「找到卡住的进程再决定怎么收拾」的流程正好接得上;
- 容器为什么看不见宿主机的 IPC 对象:因为 IPC namespace 会把 SysV IPC 和 POSIX 消息队列隔离开,和 Linux 网络命名空间:从 ip netns 到 veth 虚拟网卡互联 里的 netns 是同一种隔离思路,只不过隔的是另一类资源。
小结
这三条路各自解决不同的问题,选型时可以按「数据要不要落盘、要不要跨机器、拷贝开销能不能忍」来分:
- FIFO:有名字、靠文件权限管访问、数据不落盘,适合「本机两个独立进程传一小段控制信息或日志流」。记住没有读者时写端会阻塞,以及单次写入别超过
PIPE_BUF(这台机器是 4096 字节)。 - 共享内存:零拷贝、大块数据反复读写最快的选择,代价是要自己配信号量做互斥,而且段不会自己消失——进程崩溃了它还在,这是它在小内存机器上最危险的地方。
- 信号量/消息队列:前者做锁,后者做有边界的异步消息(单条 ≤
msgmax)。
最后回到那台 1.9G 无 swap 的机器:一个泄漏的 200MB 共享内存段,会让 available 直接掉 200MB 而不体现在 used 上;如果你的服务莫名其妙开始吃 OOM,ipcs -m 和 free -m 的 shared 列值得排在 ps aux --sort=-rss 之前看一眼。
评论 (0)
暂无评论,快来抢沙发吧!