Theory · OS · Inter-Process Communication
隔离的代价与补全:管道 · 信号 · 消息队列 · 共享内存 · socket —— 按拷贝次数和同步方式建立坐标系
地址空间隔离换来了安全,通信就必须经过内核这座"桥"——桥的过路费就是拷贝与切换
两个轴定一切:数据要不要过内核(拷贝次数)+ 谁来保证时序(同步模型)——六大方式都能落进这张图
channel 是进程内的"IPC";真正跨进程时 Go 生态的标准答案是 exec + pipe 与 UDS
Why IPC Exists
// 进程 A:算好了一块数据,想把"地址"给 B struct Result *r = compute(); printf("%p\n", r); // 0x7f8a4c001000 send_to_B((unsigned long)r); // 进程 B:拿到同一个数字 struct Result *r = (struct Result*)0x7f8a4c001000; r->count++; // SIGSEGV,或者读到一堆垃圾
0x7f8a4c001000 在进程 A 和进程 B 里指向完全不同的物理内存。这不是 bug,而是操作系统刻意的设计——每个进程都有独立的虚拟地址空间,A 崩溃、A 被杀、A 越界,都伤不到 B。
隔离换来了安全,但也把进程变成了互不相通的孤岛:ls | wc -l 这种流水线根本写不出来,Nginx 无法把请求交给 PHP-FPM,docker 命令行也没法指挥守护进程。要协作,必须有一条受控的通道。
最朴素的办法是"写文件,另一个进程去读":能通信,但慢(要落盘或至少过文件系统)、没有边界(读到一半怎么办)、没有通知(只能轮询)。IPC 换掉的就是这三点——内核提供有边界、有通知、可控权限的通道。
既然通道必须由内核这个可信第三方来做,那每一次传递都可能要付三笔成本:数据拷贝(用户 ↔ 内核)、系统调用、上下文切换。本篇所有机制的差异,本质就是"这笔过路费怎么付、付几次"。
先给六种方式定坐标(两张地图)→ 再逐个拆机制(管道 / 信号 / 消息队列与共享内存 / socket)→ 补上同步配套 → 最后给选型总表与 Go 视角。读完你应该能回答"这个场景该选谁、为什么"。
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 进程隔离 | 每个进程有自己独立的虚拟地址空间,看不到也碰不到别的进程的内存 |
| IPC 进程间通信 | 进程之间交换数据或事件的所有机制的总称,本篇的主角 |
| 文件描述符 fd | 内核给"一个打开的对象"编的整数号,进程用它指代这个对象 |
| 管道 pipe / FIFO | 内核里的一段环形缓冲区:一头写、一头读,字节流单向流动 |
| 信号 signal | 内核发给进程的异步事件通知:只有编号,不携带数据 |
| 消息队列 | 内核维护的消息链表:消息有边界、带类型,生命周期独立于进程 |
| 共享内存 | 把同一块物理内存映射进多个进程的地址空间,读写就是普通访存 |
| 信号量 semaphore | 内核维护的原子计数器:P(取许可)/ V(还许可),只传许可不传数据 |
| socket / UDS | 一套统一的读写接口,既能本机(UDS)也能跨主机(TCP) |
| 同步 | 约定"什么时候能动":谁先谁后、能不能同时改,与"数据怎么到"是两回事 |
先读这两篇再回来,本 deck 默认你已经知道它们:
进程 · 线程 · 协程 → 进程是什么、fork 怎么复制
虚拟内存 → "地址空间"到底是怎么隔离出来的
通信 = 数据怎么到;同步 = 什么时候能动。每一种 IPC 都是这两个问题的一种答案组合:管道把同步内置在收发里(读空就睡、写满就睡),共享内存只管"到"不管"序",信号量干脆只回答"序"。
给六种方式定坐标只需要两个轴:① 数据要过几次内核(拷贝次数)② 同步由谁负责(内核还是你自己)。凡是"最快的方案",一定是在第一个轴上省了钱、在第二个轴上欠了债——没有免费午餐。
Why IPC · The Map
开场那个"指针传过去就失效"的问题,内核给的答案是:既然你们无法共享地址,那就由我当中间人。中间人只有三种做法。
pipe / FIFO
管道是内核内存里的一段环形缓冲区(Linux 默认 64KB = 16 页,可用 F_SETPIPE_SZ 调整):写端写入、读端读出,半双工(要双向就开两根),数据一旦读走就没了——不是文件。
pipe(fd[2]) 返回读/写两个 fd。没有名字、只能靠 fork 继承 fd 传给亲缘进程——这就是"匿名管道只能用于有血缘关系的进程"的原理。shell 的 cmd1 | cmd2 就是 pipe + dup2 把子进程的 stdout/stderr 接到管道上。
mkfifo /tmp/x 在文件系统里挂一个名字(inode 类型为 FIFO),无亲缘关系的进程按路径打开同一管道。注意:名字只是"接头暗号",数据仍在内核缓冲,不落盘。
缓冲满 → write 阻塞;缓冲空 → read 阻塞;写端全关 → read 返回 0(EOF,shell 管道据此收尾);读端全关 → write 触发 SIGPIPE(Go 里体现为 broken pipe)。
// shell 里 ls | wc -l 的等效 C 代码骨架 int fd[2]; pipe(fd); // fd[0] 读, fd[1] 写 if (fork() == 0) { // 子进程 1: ls dup2(fd[1], STDOUT_FILENO); // stdout 接到写端 close(fd[0]); close(fd[1]); execvp("ls", ...); } if (fork() == 0) { // 子进程 2: wc dup2(fd[0], STDIN_FILENO); // stdin 接到读端 close(fd[0]); close(fd[1]); execvp("wc", ...); } close(fd[0]); close(fd[1]); // 父进程关两端!
cmd.StdoutPipe() 帮你管理了这套 fd 生命周期。
Signals
信号是内核发给进程(或进程发给进程)的异步事件通知:只有编号没有载荷(SIGCHLD 可附带一点信息)。本质是"软件层面的中断"——响应点在指令边界,由内核在返回用户态前检查 pending 位图。
① 默认(SIG_DFL):终止 / 终止+core / 忽略 / 停止;② 捕获(注册 handler):注册处理函数,收到时跳去执行;③ 忽略(SIG_IGN)。例外:SIGKILL(9) 和 SIGSTOP 永远不可捕获、不可忽略——这是内核保留的"最后处分权"。
SIGHUP 挂断(常被 daemon 当重载)· SIGINT Ctrl-C · SIGTERM 优雅退出请求 · SIGKILL 强杀 · SIGSEGV 非法内存 · SIGPIPE 写无读端管道 · SIGCHLD 子进程退出 · SIGURG(Go runtime 拿它做异步抢占)。
// Go 的优雅退出(几乎每个服务都有) func main() { srv := &http.Server{Addr: ":8080"} go srv.ListenAndServe() quit := make(chan os.Signal, 1) // KILL 是收不到的——没有机会 signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) <-quit ctx, cancel := context.WithTimeout( context.Background(), 10*time.Second) defer cancel() srv.Shutdown(ctx) // 排空存量请求 }
Message Queue · Shared Memory
内核维护的消息链表:msgget 创建、msgsnd/msgrcv 收发。三个特点:① 消息有类型标记,可按类型随机读取(不像管道必须顺序消费);② 生命周期独立于进程(显式删除才消失);③ 内核保证消息边界与原子性。
每次收发都是用户 ↔ 内核两次拷贝;有单消息/队列容量上限(msgmnb 默认 16KB/队列)。适合"小消息、需要按类型分发、跨无亲缘进程"的场景;大吞吐场景让位给共享内存。
shmget/shmat 或 shm_open + mmap:把同一块物理内存映射进两个进程的地址空间。读写就是普通访存——零拷贝、零系统调用(数据面)。快不是魔法,只是把"过内核"省掉了。
内核不管时序:读写竞争、可见性全靠用户态自己解决——信号量、互斥锁(PTHREAD_PROCESS_SHARED)、或自己设计协议。共享内存 + 信号量是组合出现的经典搭配。
| 维度 | 消息队列 | 共享内存 |
|---|---|---|
| 数据拷贝 | 2 次(进内核 + 出内核) | 0 次 |
| 消息边界 | 有(格式化消息) | 无(自己定协议) |
| 同步 | 内核收发天然同步 | 用户态自行同步 |
| 生命周期 | 独立于进程,持久 | 随引用计数 / 显式删除 |
| 数据量 | 小消息为主 | 大批量数据首选 |
Copy Count Anatomy
Socket · Unix Domain Socket
其他 IPC 都绑定"本机 + 特定内核对象",socket 的 API 抽象统一了:同一套 read/write 语义,既能本机(AF_UNIX)也能跨主机(AF_INET)。进程从本机通信迁移到分布式,代码几乎不改——微服务架构的通信底座。
本机进程间走 socket 语义,但数据不经过 TCP/IP 协议栈:直接在内核 socket 缓冲之间搬运,少了三次握手、头封装、校验和与拥塞控制。同一个进程的 API,性能显著高于 TCP loopback。
通过辅助消息(SCM_RIGHTS)可以把打开的文件描述符发给另一个进程——权限分离的经典手法:特权父进程打开受控资源,把 fd 交给沙箱子进程使用。
Docker daemon(/var/run/docker.sock)、MySQL 本机连接、Nginx 与 FastCGI/PHP-FPM、容器 sidecar 的 gRPC over UDS、PostgreSQL unix socket——"本机高频 RPC 用 UDS"是通用工程实践。
| 维度 | TCP localhost | UDS |
|---|---|---|
| 协议栈 | 完整 TCP/IP(含校验、拥塞控制) | 内核内直接搬运,无协议栈 |
| 连接建立 | 三次握手 | 内核直接配对 |
| 寻址 | IP + 端口(可能被抢) | 文件系统路径(权限随文件) |
| 延迟 / 吞吐 | 较低延迟、受 loopback 限 | 更高吞吐、更低延迟 |
| fd 传递 | 不支持 | 支持(SCM_RIGHTS) |
net.Listen("unix", "/tmp/app.sock") 与 TCP 用法完全一致(net.Conn 抽象),gRPC 直接 grpc.NewServer(grpc.Creds(...)) 挂 UDS——netpoller 对 UDS 同样生效。
Sync Primitives for IPC
内核维护的原子计数器:P(减 1,为 0 则睡)与 V(加 1,唤醒等待者)。它不传数据、只传"许可"——把"资源有几个"与"谁在等"都交给内核仲裁。二元信号量(初值 1)即进程间互斥锁。
数据走共享内存(快),时序走信号量(稳):写者 V 通知、读者 P 等待——生产者消费者模型的进程间版本。POSIX 匿名信号量可放进共享内存(sem_init 带 pshared),比 System V 信号量集更轻。
以文件为仲裁媒介:flock(fd, LOCK_EX) 独占锁、LOCK_SH 共享锁。常用于"防多实例"(daemon 单例)、日志轮转协调。fcntl 的 POSIX 记录锁还能锁字节区间——数据库存储引擎的锁边界就借鉴这套语义。
| 原语 | 作用域 | 典型用法 |
|---|---|---|
| System V 信号量集 | 内核对象,key 标识 | 多进程生产者消费者 |
| POSIX 信号量(pshared) | 可驻共享内存 | shm 内的互斥/计数 |
| 进程共享互斥锁 | PTHREAD_PROCESS_SHARED + 共享内存 | shm 临界区保护 |
| flock | 打开的文件 | 单实例守护、crontab 互斥 |
| fcntl 记录锁 | 文件的字节区间 | 细粒度资源仲裁 |
IPC Cheat Sheet
| 方式 | 数据形态 | 拷贝次数 | 适用进程 | 典型场景 / 短板 |
|---|---|---|---|---|
| 匿名管道 | 无边界字节流 | 2 | 仅亲缘(fork 继承) | shell 流水线 / 单向小流量;半双工 |
| 命名管道 FIFO | 无边界字节流 | 2 | 任意(路径寻址) | 无亲缘的本机流;仍半双工 |
| 消息队列 | 有边界、带类型 | 2 | 任意 | 按类型分发、小消息;容量受限 |
| 共享内存 | 裸内存(自定义) | 0 | 任意 | 大数据量最高吞吐;同步自理 |
| 信号 | 仅事件编号 | — | 任意(有权限) | 通知/控制流;不能承载数据 |
| socket / UDS | 字节流或报文 | 2 | 任意 + 跨主机 | 通用性天花板;本机用 UDS 提速 |
Go × IPC
"Don't communicate by sharing memory; share memory by communicating"——channel 本质是进程内自带同步的队列:通信即同步(发送/接收就是会合点)。OS 层的管道哲学(字节流组合)与 CSP 的消息传递哲学在此汇合。
① exec + pipe:os/exec 的 StdoutPipe/StdinPipe 包装匿名管道,跑子进程抓输出;② UDS:net.Listen("unix", …),sidecar、docker.sock、本机 gRPC 的标准解;③ TCP:跨主机兜底,同一套 net.Conn;④ 信号:os/signal 做 SIGTERM 优雅退出。
shm/消息队列绕开 runtime 的调度与 GC 视野,跨进程共享内存还与 Go 的内存模型有坑(无 happens-before 保证);生态共识:跨进程直接上 UDS/TCP,把序列化成本当可读性保险。
// 子进程输出走匿名管道 cmd := exec.Command("ls", "-l") stdout, _ := cmd.StdoutPipe() cmd.Start() out, _ := io.ReadAll(stdout) cmd.Wait() // 本机 gRPC 挂 UDS lis, _ := net.Listen("unix", "/tmp/app.sock") grpcSrv.Serve(lis)
Cheat Sheet
| 缓冲中转 | 管道 / FIFO / 消息队列 —— 拷贝 2 次,同步内核包办 |
| 直接映射 | 共享内存 / mmap —— 拷贝 0 次,同步自己负责 |
| 通知与套接字 | 信号(只传事件)/ socket(可跨主机)/ 文件锁(只做协调) |
| 管道 / 消息队列 | 2 次拷贝 + 2 次系统调用 + 上下文切换 |
| 共享内存 | 数据面 0 次拷贝、0 次系统调用 |
| UDS vs TCP loopback | 拷贝同级,UDS 省的是协议栈处理 |
| 匿名管道 vs FIFO | 匿名靠 fork 继承 fd(必须亲缘);FIFO 有路径,谁都能 open |
| 管道 vs 消息队列 | 管道无边界、必须顺序消费;消息队列有边界、可按类型读、生命周期独立 |
| 信号量 vs 互斥锁 | 互斥锁有所有权(谁拿谁放);信号量是计数许可(A 的 V 能唤醒 B 的 P) |
| SIGTERM vs SIGKILL | SIGTERM 可捕获(能优雅退出);SIGKILL 内核直杀,没有执行机会 |
| UDS 快在哪 | 不是零拷贝,是省了握手/封包/校验/拥塞控制 |
flock(LOCK_EX|LOCK_NB)(进程死了内核自动放锁)Interview QA · Part 1
先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回速查页。
缓冲中转(管道/命名管道/消息队列)、直接映射(共享内存 + mmap)、通知与套接字(信号/socket/文件锁)。两个轴:数据拷贝几次、同步谁负责。
内核里 64KB 环形缓冲 + 一对 fd。匿名管道的 fd 只存在于打开它的进程,唯一传递方式是 fork 继承——所以必须亲缘。FIFO 有文件系统路径,谁都能 open。
同一物理页映射进双方地址空间,读写就是普通访存:数据面零拷贝、零系统调用。少掉的是内核的同步与边界服务——时序、竞争、可见性全要自己用信号量/互斥锁解决。
SIGTERM 可捕获可忽略,SIGKILL 由内核直接终止、进程无执行机会。优雅退出:捕获 SIGTERM → 停止接新请求 → 带超时排空存量请求 → 刷盘/释放资源 → 退出。K8s 终止宽限期结束时才 SIGKILL。
消息队列有消息边界、可带类型按类型读取(不必 FIFO 消费)、生命周期独立于进程;管道是无边界字节流、必须顺序消费、读走即逝。数据面两者都是 2 次拷贝。
UDS 走 socket API 但不进 TCP/IP 协议栈:无三次握手、无包头封装/校验/拥塞控制,内核缓冲直接对搬;地址是文件路径,还能借文件权限做访问控制、传 fd。数据面拷贝次数与 loopback 同级,省的是协议处理。
Interview QA · Part 2
同样建议先自答。这一页的题都需要"结论 + 一句代价/边界"才完整——只答结论会被追问。
理念同源(消息传递 + 内建同步),层次不同:channel 在进程内由 runtime 实现(hchan + 锁 + 等待队列),管道在进程间由内核实现(环形缓冲 + 睡醒队列)。channel 有类型有边界,更像"类型化消息队列"。
write 触发 SIGPIPE,默认终止进程。Go 特殊处理:stdin/stdout/stderr 之外的 fd 写出 SIGPIPE 转为 EPIPE 错误返回(不然一个 panic 的下游会杀掉整个 Go 服务)——这是 runtime 对信号的改写,面试答出即亮点。
共享内存(mmap 同一文件或 shm)+ 自己定协议:定长环形缓冲、写指针/读指针放原子变量、用信号量或 futex 通知。参考性能上限:内存带宽。工程上若吞吐要求不极端,UDS + 应用层协议更可维护。
启动时对锁文件 flock(LOCK_EX | LOCK_NB),拿不到即退出;进程死锁自动释放(内核随 fd 回收),比"check pid 文件再写"的竞态安全。systemd 内部同样是这套思路。
处理函数在任意指令边界异步打断主流程:只能调 async-signal-safe 函数(write 等),不能 malloc、不能拿非重入锁——否则可能死锁在"被打断时正持有的锁"上。Go 用 masked 线程收信号再转 channel,绕开了这套雷区。
preStop 钩子在 SIGTERM 前执行(摘流),SIGTERM 触发应用优雅退出,terminationGracePeriodSeconds 是总预算,超时 SIGKILL 强杀。三段齐全才能做到"不丢请求的滚动发布"——SIGKILL 一到什么优雅都来不及。
Related & References
同步与互斥 →(信号量/互斥的用户态版深度)
进程线程协程 →(隔离是 IPC 存在的理由)
I/O 模型与 epoll →(socket 就绪事件怎么等)
零拷贝 →(把"过内核"的拷贝继续省下去)
Channel 底层原理 →(CSP 的 runtime 实现)
TCP 连接管理 →(socket 跨主机侧的成本明细)
参考来源(本 deck 结论可溯源至下列一手材料)
| man 7 pipe / fifo / signal / sem_overview / unix | 管道缓冲 64KB、信号处置语义、UDS 与 SCM_RIGHTS 的一手定义 |
| OSTEP ch.26(Concurrency)+ TLPI ch.43–53 | IPC 与同步原语的系统化梳理(TLPI = Linux/UNIX 系统编程手册) |
| CSAPP ch.8(信号)/ ch.12 | 信号语义与进程级并发 |
| Go src/os/exec、src/net、src/runtime/signal_unix.go | StdoutPipe、UDS 监听、SIGPIPE 改写与 SIGURG 抢占 |
| xiaolincoding.com《图解系统》process_commu.html | 六种 IPC 的中文对比叙述 |
| Kubernetes docs: container lifecycle | SIGTERM / preStop / 宽限期 / SIGKILL 链路 |