暗色模式

Linux 进程间通信:从命名管道 FIFO 到共享内存与信号量

技术教程
2026-09-13
2
0
本文要点
  • 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 -mshared 列从 0 跳到 200available 掉 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=$?"

mkfifo 创建出 prw-r--r-- 的 FIFO 文件;没有读者时写入被阻塞,timeout 2 秒后杀进程返回 124

两个信息点:

  • prw-r--r--p 就是 pipe,权限、属主和普通文件一样受管,所以「谁能往这个 FIFO 里写」是靠文件权限控制的,不需要额外协议;
  • 那条 echo 没有报错,也没有返回——它卡在 open() 上,这正是 timeout 在 2 秒后动手、退出码为 124 的原因(124timeout 自己的「超时了」约定值,不代表写入失败)。

「没有读者就阻塞」是 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

读写方式打开 FIFO 立即返回 0;配对读写成功传递数据;而 FIFO 文件本身 size=0 blocks=0,数据不在磁盘上

三行输出对应三件事:

  1. O_RDWRexec 3<>fifo)不阻塞。只读打开要等写者、只写打开要等读者,而读写方式打开两边都满足,于是立刻成功——这解释了为什么有的脚本用 < > 打开 FIFO 就「活」了,数据其实是被塞进管道缓冲区后丢掉了。
  2. 数据传过去了读者收到: hello over fifo
  3. FIFO 文件本身还是空的size=0 blocks=0。数据从来不在磁盘上,它只存在于内核的管道缓冲区里,读者取走就没了;blocks=0 说明连一个数据块都没分配。

顺带一个和写入安全相关的数字:这台机器上 getconf PIPE_BUF /tmp4096,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

写进程把 pid 写进共享内存段,读进程独立挂接后读到同一串内容;ipcs -m 显示 key/shmid/bytes,nattch 已经是 0

(命令开头那个 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           0

ipcrm -M 0x4201ecf3 把段删掉之后:

               total        used        free      shared  buff/cache   available
Mem:            1977         185         244           0        1548        1599

前后三组数字:

时刻totalusedfreesharedbuff/cacheavailable
分配前197717163017421613
200MB 段已分配并触碰19771487720017511435
ipcrm 之后1977185244015481599

注意第二行那对反向的数字:shared 涨了 200,used 反而降了 23used 是按 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 -s
Semaphore id: 1

------ Semaphore Arrays --------
key        semid      owner      perms      nsems
0xaa5f428a 1          root       644        2

已删除信号量集

------ Semaphore Arrays --------
key        semid      owner      perms      nsems

ipcmk -S 2 里的 2信号量个数,它回显的 Semaphore id: 1semid(和 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——比我们自己用 shmget0666 建出来的要严,多进程协作时留意这个差别。

顺带把内核的上限看一眼,方便估容量:

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) = 16384
cat /proc/sys/kernel/shmmax /proc/sys/kernel/shmmni /proc/sys/kernel/msgmax
18446744073692774399
4096
8192

这台机器上 shmmni=4096(最多 4096 个段)、msgmax=8192(单条消息最大 8KB)、msgmnb=16384(单个队列默认 16KB),而 shmmax18446744073692774399(约 16EB,等于没有限制)——也就是说内核不会拦你分配一块过大的共享内存,能拦住你的只有物理内存和 OOM Killer。这也是为什么前面那 200MB 的段必须记得清掉。

第六步:清理姿势与常用排查命令

把这一套 IPC 对象的生命周期和清理方式整理成一张表:

对象创建查看删除(按 key / 按 id)会自动消失吗
FIFOmkfifo /tmp/x.fifols -l(第一位 prm不会,就是文件系统里的一个文件
SysV 共享内存ipcmk -M 4096 / shmgetipcs -m/proc/sysvipc/shmipcrm -M <key> / ipcrm -m <id>不会,进程退出后段仍在
SysV 信号量ipcmk -S 2 / semgetipcs -sipcrm -S <key> / ipcrm -s <id>不会
SysV 消息队列ipcmk -Q / msggetipcs -qipcrm -Q <key> / ipcrm -q <id>不会
POSIX 共享内存shm_open(Python 的 shared_memoryls /dev/shmdf -h /dev/shmshm_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 避免误挂别人的段。

几个日常排查会用到的点:

小结

这三条路各自解决不同的问题,选型时可以按「数据要不要落盘、要不要跨机器、拷贝开销能不能忍」来分:

  • FIFO:有名字、靠文件权限管访问、数据不落盘,适合「本机两个独立进程传一小段控制信息或日志流」。记住没有读者时写端会阻塞,以及单次写入别超过 PIPE_BUF(这台机器是 4096 字节)。
  • 共享内存:零拷贝、大块数据反复读写最快的选择,代价是要自己配信号量做互斥,而且段不会自己消失——进程崩溃了它还在,这是它在小内存机器上最危险的地方。
  • 信号量/消息队列:前者做锁,后者做有边界的异步消息(单条 ≤ msgmax)。

最后回到那台 1.9G 无 swap 的机器:一个泄漏的 200MB 共享内存段,会让 available 直接掉 200MB 而不体现在 used 上;如果你的服务莫名其妙开始吃 OOM,ipcs -mfree -mshared 列值得排在 ps aux --sort=-rss 之前看一眼。

发表评论

暂无评论,快来抢沙发吧!