FRUITICECAKE REC · 00:00:00:00

[00:00:00] 技术文档 / Linux · IPC · 端侧 AI

进程间通信到底拷贝了几次

同一条消息走管道、消息队列、Unix 域 socket、本机 TCP、Binder 和共享内存,数据各被拷贝几次,实测差多少;各自的优缺点和使用场景;放到端侧 AI 上,模型权重、摄像头帧、推理结果和控制命令又该分别走哪条通道。

2026-11-0325 MIN READSRC · man-pages · 内核文档 · 实测
// TL;DRIPC 的差别主要看两件事:数据进不进内核,接收方怎么被叫醒。管道、消息队列、socket 每条消息拷两次,但同步由内核负责;共享内存省掉经过内核的拷贝,同步和通知要自己做。实测里 4 KiB 以下的消息差别几乎都被唤醒开销盖住,消息变大后共享内存才明显快。端侧 AI 不是选一种 IPC,而是按数据类型分通道:权重用 mmap,图像帧用 dma-buf 或共享内存,结果和控制命令用 socket。

这篇是我自己的复习笔记,讲 Linux 上几种进程间通信方式的数据路径、优缺点和选型,最后一章把它们用到端侧 AI 上。文中的耗时和内存数字都来自真实运行:x86-64 Ubuntu 24.04(WSL2,内核 5.10)、GCC 13.3、i5-12400F。这是虚拟机里的桌面 CPU,所以数字只用来比较相对差异,不能当成开发板上的性能。

文中的动画是图解,机制以 man 手册、内核文档和源码为准,出处都列在文末。

[MAP]

先分三类:传数据、共享数据、只负责通知

背清单容易漏,按「数据在哪里」分成三类就不容易漏:

类别方式数据在哪里谁负责同步
传数据管道 / FIFO、消息队列、socket、Binder经过内核,或由内核拷进对方的缓冲区内核
共享数据共享内存(POSIX shm_open、System V shmget、mmap 同一文件、memfd_create)一块物理内存映射进多个进程自己
只负责通知信号、信号量、eventfd、futex基本不带数据它们本身就是同步工具

第三类常被混进第二类的优点里。信号量和 eventfd 不传业务数据,它们是给共享内存配套用的。信号只传一个编号,用 sigqueue 最多再带一个整数或指针。

[COPY]

同一条消息,走七种路径

判断一种 IPC 的数据开销,就数这条消息被拷了几次,以及在哪里被拷。

MODULE_01 · 一条消息的拷贝路径SELECT / ← →
方式拷贝次数路径
管道 / FIFO2 次,都经过内核A → 管道缓冲区 → B
POSIX 消息队列2 次,都经过内核A → 内核消息队列 → B
Unix 域 socket2 次,都经过内核A → sk_buff,挂到对端接收队列 → B
本机 TCP2 次,都经过内核A → sk_buff,走完 TCP/IP 协议栈 → B
Binder1 次,经过内核驱动A → B 预先映射好的缓冲区,B 直接读
共享内存:先拷再通知1 次,在用户态A 用 memcpy 拷进共享区,B 直接读;通知只传 8 字节
共享内存:原地生成0 次A 直接在共享区里生成数据,B 直接读;通知只传 8 字节

画面按消息能一次放进内核缓冲区的情况画;大消息要多次读写,下文有实测。

有三点值得单独记:

「共享内存零拷贝」要看数据怎么来。 如果数据已经在私有缓冲区里(比如另一个库的输出),放进共享区还要 memcpy 一次。只有写方直接在共享区里生成数据,才是真的零拷贝。这也是 iceoryx2 这类框架的做法:写方向框架要一块共享内存,填好后只把偏移量发给读方。

Unix 域 socket 和本机 TCP 拷贝次数相同。 差别在 Unix 域 socket 不走 TCP/IP,数据直接挂到对端的接收队列上。

Binder 少的那次拷贝,靠的是映射。 接收方提前把一块内核管理的内存映射进来,驱动把事务拷进去之后,接收方在自己的地址空间里就能读到。代价是这块缓冲区不大,Android 文档写的是目前 1 MB,由进程里所有进行中的事务共用,所以大块数据不走 Binder。

用 strace -f -c 数系统调用,能看到数据有没有进内核,以及大消息要进几次(100 轮往返,共 200 条消息,下表已折算成每条):

方式4 KiB 消息1 MiB 消息进内核的内容
管道(默认 64 KiB 容量)1 次写,1 次读1 次写,16 次读全部数据
管道(调到 1 MiB 容量)未测1 次写,1 次读全部数据
POSIX 消息队列1 次发,1 次收超过默认上限,未测全部数据
Unix 域 socket1 次写,1 次读1 次写,约 3.4 次读全部数据
本机 TCP1 次写,1 次读1 次写,约 1.3 次读全部数据
共享内存 + eventfd1 次写,1 次读1 次写,1 次读只有 8 字节计数

管道的 16 次读正好是 1 MiB 除以 64 KiB:缓冲区满了写方就要睡,读方读走一批再把它叫醒。

[EACH]

每种方式的优点、缺点和使用场景

「能跨进程」不是任何一种方式的优点,所有 IPC 都能。下面只写它们之间的差别。

管道 / FIFO

  • 优点:用法最简单,普通的 read / write,能进 select / epoll;同步由内核负责;不超过 PIPE_BUF(Linux 上是 4096 字节)的写入是原子的,多个写方不会交错。
  • 缺点:单向;字节流没有消息边界,要自己分包;默认容量只有 64 KiB,大消息来回切换(可以用 F_SETPIPE_SZ 调大);匿名管道通常只用于有亲缘关系的进程,无亲缘关系的要用 FIFO。
  • 使用场景:父子进程之间传少量数据、重定向子进程的输出、shell 流水线。

POSIX 消息队列

  • 优点:有消息边界,不用自己分包;可以按优先级取;队列由内核保存,发送时接收方不必在运行。Linux 上描述符也能进 select / epoll。
  • 缺点:单条消息和队列长度都有上限,默认单条最大 8192 字节,要更大需要 root 调整 /proc/sys/fs/mqueue/msgsize_max;每条消息拷两次,不适合大数据。
  • 使用场景:长度有限的命令和事件,尤其需要优先级时。

Unix 域 socket

  • 优点:通用性最好。支持双向和多客户端;有 SOCK_STREAM、SOCK_DGRAM、SOCK_SEQPACKET 三种类型,后两种保留消息边界;能传文件描述符(SCM_RIGHTS);能查对端身份(SO_PEERCRED);对端退出时 read 返回 0,天然能发现。
  • 缺点:只能在本机;每条消息拷两次并且有一次唤醒,大块数据不划算。
  • 使用场景:本机客户端和服务端的默认选择。D-Bus 就建在它上面,代价是多经过一个总线进程。

本机 TCP / UDP

  • 优点:一套代码既能本机用,也能跨机器用;工具和调试手段最多(tcpdump、ss)。
  • 缺点:多走一遍协议栈,小消息比 Unix 域 socket 慢一点(下面实测两轮都多约 4 µs);要管端口,本机上还有被其他进程连接的安全问题。
  • 使用场景:将来可能拆到别的机器上的服务;要给其他语言或工具一个现成的接口时,比如本地模型服务常见的 HTTP 接口。

共享内存

  • 优点:大块数据最快,数据不用经过内核。一份数据能同时给多个读方,读方变多也不会多拷。
  • 缺点:同步、通知、崩溃处理都要自己做,下一章专门讲。两边还要约定内存布局,共享区里不能放普通指针,因为同一块内存在两个进程里的地址可能不同,要存偏移量。
  • 使用场景:图像帧、音频、张量这类大块高频数据。控制消息仍然走 socket。

信号、信号量、eventfd

  • 信号:异步打断目标进程,只适合 SIGTERM 这类通知。处理函数里能调用的函数很有限,不要用它传业务数据。
  • 信号量:计数和互斥,常配合共享内存。一方持有后崩溃,另一方可能永远等下去。
  • eventfd:内核里的一个 64 位计数器,读写都是 8 字节,能进 epoll,开销比用管道做通知小。是共享内存最常见的「敲门」方式。

[SHM]

共享内存省下的,要自己还回来

共享内存只解决了「数据在哪」。原本由内核负责的事情都回到了应用这边:

  1. 同步:两边同时读写,读方可能读到写了一半的数据。常用环形缓冲区加头尾索引,或信号量、进程间互斥锁。
  2. 通知:读方怎么知道有新数据。要么睡眠等 eventfd、futex,要么一直循环检查(自旋)。自旋延迟最低,但一直占着一个核。
  3. 崩溃:一方持锁时崩溃,普通互斥锁再也解不开。可以用进程间共享(PTHREAD_PROCESS_SHARED)的 PTHREAD_MUTEX_ROBUST 锁:下一个加锁的进程会收到 EOWNERDEAD,处理完调用 pthread_mutex_consistent。更根本的做法是设计成无锁环形缓冲区,再用一条 socket 感知对端退出。
  4. 不可信的对端:对方可以在你检查之后再改数据,也可以把共享区缩小,让你访问时收到 SIGBUS。memfd_create 配合文件封印(sealing)可以禁止缩小,也可以让一块写完的数据不能再改。不能封印的场景,通常要先拷出来再检查,这时零拷贝也就没有了。

测试程序里,数据和通知是分开的。下面是原地生成这种方式(ipc_bench.c,为了可读性去掉了错误处理):

/* 写方:直接写共享区,再用 eventfd 敲门,进内核的只有 8 字节 */
memset(c->region[side], stamp, c->size);
uint64_t one = 1;
write(c->tx[side], &one, sizeof(one));

/* 读方:睡在 eventfd 上,醒来直接读共享区 */
uint64_t value;
read(c->rx[side], &value, sizeof(value));
consume(c->region[peer], c->size, stamp);

这段是一问一答,所以同一时间只有一方在碰数据。真实系统里写方和读方各走各的,就要用环形缓冲区。写满时怎么办不是实现细节,而是业务决策:

MODULE_02 · 环形缓冲区:写方和读方速度不一样时POLICY / SPEED

三种策略:

  • 不同步:两边互不检查。写方追上读方会覆盖正在读的帧,读方追上写方会读到没写完的帧。这样的程序平时可能看不出问题。
  • 满了就等:写方等读方腾出位置,每帧都不丢。适合命令和结果,代价是读方慢时写方也被拖慢。
  • 满了丢最旧:写方不等,覆盖最旧的未读帧,读方总能拿到较新的数据。适合视频帧,一帧来晚了不如不要。

[BENCH]

实测:消息小的时候,大家差不多

测试方法:两个进程绑在两个不同的 CPU 核上(这是虚拟机里看到的拓扑),一问一答,取往返时间的一半作为单程。每个方向都做同样的三件事:生产数据(memset)、传输、读取数据(每 64 字节读一次),所以差别主要来自传输本身,也包括数据换核造成的缓存未命中。每组重复 5 次取中位数(调大容量的管道是后来补测的,重复 3 次)。最大的一档是一帧 1080p NV12 图像(1920 × 1080 × 1.5 = 3,110,400 字节)。

单程耗时中位数,单位微秒:

方式64 B4 KiB64 KiB1 MiB1080p 帧
基准:只生产和读取,不传输0.020.051.524154
管道(默认 64 KiB 容量)17.417.725.48032798
管道(调到 1 MiB 容量)未测未测24.92151219
POSIX 消息队列17.217.9超上限超上限超上限
Unix 域 socket15.616.218.7222829
本机 TCP19.620.327.6170804
共享内存 + eventfd,先拷再通知16.717.122.1122493
共享内存 + eventfd,原地生成17.116.920.976194
共享内存 + 自旋,原地生成0.080.686.657178
64 B · WAKEUP~17 µs所有要等唤醒的方式都在这个量级
64 B · SPIN0.08 µs不睡眠,一直占着一个核
1080P · UDS → IN PLACE829194 µs一帧 1080p 从 Unix 域 socket 换成原地生成,其中 154 µs 是生产和读取本身

这张表说明四件事:

小消息的成本主要是唤醒。 64 字节到 4 KiB,生产和读取几乎不花时间,所有要等唤醒的方式都在 15 到 20 微秒之间。共享内存加 eventfd 并不比 socket 快,因为它同样要睡下去再被叫醒。真正快两个数量级的是自旋,它省的是唤醒,不是拷贝。

大消息的成本主要是拷贝,以及缓冲区装不下造成的来回切换。 扣掉基准的 154 微秒,一帧 1080p 的传输成本是:原地生成约 40 微秒,先拷再通知约 340 微秒,Unix 域 socket 约 675 微秒。Unix 域 socket 拷两次,耗时也接近一次 memcpy 的两倍。这些数字里都含约 17 微秒的唤醒。

消息比管道缓冲区大时,切换的开销比拷贝还大。 把容量调到 1 MiB 之后,1 MiB 消息的拷贝次数没变,耗时却从 803 降到 215 微秒,和 Unix 域 socket 差不多,因为不用再来回切换 16 次。但这不是管道的全部差距:64 KiB 时调大容量没有变化,还是比 Unix 域 socket 慢;1080p 帧调大后是 1219 微秒,也比 Unix 域 socket 的 829 微秒慢,这时消息又超过了 1 MiB 的容量。

1 MiB 以上时,本机 TCP 不比 Unix 域 socket 慢。 这和直觉相反,但两轮都是这样;64 KiB 以下则是 Unix 域 socket 更快,小消息两轮都快约 4 微秒。

结果的局限:

  • 环境WSL2 虚拟机,唤醒要经过虚拟化,17 微秒的绝对值很可能偏高,换到实体机或开发板会变。
  • 重复性跑了两轮,中位数相差大多不超过 25%,表里是第一轮。例外是 4 KiB 的自旋,两轮分别是 0.68 和 0.10 微秒。
  • 一问一答同一时间只有一条消息,没有测高并发和多读方。
  • 热缓存缓冲区每轮复用,数据基本都在缓存里,真实系统里每帧换一块内存时结果会不同。
  • 自旋的数字依赖两个核的实际距离,0.08 微秒不能推广。能确定的只有「自旋省掉了唤醒」。
  • 延迟抖动p99 在虚拟机里噪声很大,同一组两轮能差几倍甚至十倍,所以表里只放中位数。

[PICK]

选型:按顺序问四个问题

  1. 要跨机器吗? 要,就用 TCP。以后可能要跨机器的,也先考虑 TCP。
  2. 数据大而且频繁吗? 单条上百 KiB 以上并且持续在传,考虑共享内存,通知用 eventfd。我这台机器上 64 KiB 时 Unix 域 socket 还略快,到 1 MiB 差距才明显,这个分界线要在目标设备上自己测。
  3. 有严格的延迟要求,而且能独占一个核吗? 能,就用共享内存加自旋。否则省不下唤醒这笔开销。
  4. 以上都不是? 默认用 Unix 域 socket。它够快、够通用,还能传文件描述符和发现对端退出。

只有父子进程之间传点数据,管道更省事。需要消息边界和优先级、但不想引入服务进程时,用 POSIX 消息队列。

实际系统通常是组合使用:控制走 socket,数据走共享内存。socket 负责建立连接、传共享内存的文件描述符、感知对方退出,共享内存只负责搬大块数据。下面的端侧 AI 就是这个结构。

[AI]

端侧 AI:不是选一种 IPC,而是按数据分通道

端侧 AI 指模型在设备本地运行,而不是把数据发到云端。嵌入式设备上常见的形式是:摄像头或麦克风采集数据,交给 NPU(专门做神经网络计算的硬件)推理,结果再交给业务程序。

「端侧 AI 该用哪种 IPC」这个问法要改一下。模型本身不选 IPC,要选的是系统里不同数据怎么走。这些数据的大小和频率相差很大,不会共用一种方式:

MODULE_03 · 端侧视觉系统的四条通道SELECT DATA
数据规模频率选什么为什么
模型权重几 MB 到几 GB,只读启动时加载一次mmap 模型文件多个进程共用页缓存,不用各存一份
图像帧1080p NV12 一帧约 3.1 MB持续,例如每秒 30 帧dma-buf;CPU 之间用共享内存数据量大,能少拷一次是一次;硬件之间可以完全不经过 CPU
推理结果通常是几 KB 以内的结构化数据跟帧率走Unix 域 socket小消息的成本主要是唤醒,共享内存省不了什么;一对多时要逐个发送或用发布订阅
控制命令几十到几百字节偶尔Unix 域 socket要可靠、有回复,还能顺便发现对端退出

问题 1:推理要不要单独成一个进程?

不一定。只有一个业务用一个模型时,放在同一个进程的线程里最简单,根本没有 IPC。下面几种情况才值得拆:

  • 多个业务要共用模型:否则每个进程都要各加载一份。
  • 要隔离故障:NPU 驱动或推理库出错时,不会带崩业务进程。代价是要处理推理进程重启。
  • 权限和升级要分开:摄像头数据、模型文件只让一个进程碰,模型也能单独更新。

Android 的做法可以参考:Gemini Nano 运行在 AICore 这个系统服务里,由它管理模型更新和安全,应用通过接口调用,模型不在应用进程里。

问题 2:几百 MB 的权重,多个进程怎么共享?

用 mmap 只读映射模型文件。文件页由内核放在页缓存里,多个进程映射到同一批物理页。我用两个进程加载同一个 256 MiB 的文件,各读遍所有页,然后看 /proc/<pid>/smaps_rollup:

加载方式Rss(每个进程)Pss(每个进程)Anonymous(每个进程)第二个进程加载并触碰每页的耗时
read 到 malloc 的内存262,640 kB262,287 kB262,244 kB151 ms
mmap 只读映射262,564 kB131,192 kB88 kB6 ms

Rss 看起来一样大,所以只看 Rss 会以为没省内存。Pss 把共享页按进程数平摊:两个进程合计,read 占约 512 MiB 私有内存(页缓存里另外还有一份文件,内存紧张时可以回收),mmap 只有约 256 MiB,就是页缓存里那一份。

耗时这一列做的事不一样:read 真的拷了 256 MiB,mmap 只建立映射,每页读一个字节。所以它只说明启动快,不说明后面读完所有数据也一样快。文件当时已经在页缓存里。

有几点要注意:

  • 内存紧张时会抖:文件页可以被回收,下次访问要重新从存储读。llama.cpp 社区的讨论里提到可以用 --mlock 或 --no-mmap 避免这一点。同一讨论还提到,文件映射拿不到透明大页,也有用户反馈 mmap 让推理变慢,这些是用户报告,不是定论。
  • NPU 可能再拷一份:如果推理运行时要把权重放进设备能访问的内存,mmap 只省了文件这一层。这要看具体运行时。瑞芯微的 RKNN 接口里有 rknn_dup_context 和 RKNN_FLAG_SHARE_WEIGHT_MEM,从接口看是在 context 之间共享权重。

所以多个业务共用模型时,更稳妥的结构通常是只让一个推理进程持有模型,其他进程发请求给它(见问题 5)。

问题 3:摄像头帧怎么送到 NPU 才不多拷一次?

用 dma-buf。它和共享内存的区别是:共享内存让多个 CPU 进程看到同一块内存,dma-buf 让多个硬件设备用同一块内存,并且以文件描述符的形式出现,可以在进程之间传递。

流程是这样:

  1. 程序对摄像头设备(V4L2)调用 VIDIOC_EXPBUF,把驱动分配的采集缓冲区导出成文件描述符。
  2. 用 Unix 域 socket 的 SCM_RIGHTS 把描述符传给推理进程。
  3. 推理运行时把它绑定成 NPU 的输入。RKNN 对应的接口是 rknn_create_mem_from_fd 和 rknn_set_io_mem。
  4. 摄像头写的那块物理内存,NPU 直接读,CPU 不碰像素。之后每一帧只用传一个缓冲区编号。

要注意三个问题:

  • 格式要对得上:模型的输入通常是固定尺寸和格式,和摄像头输出不一样。如果用 CPU 缩放和转格式,零拷贝就断了。最好用硬件缩放模块,它同样可以走 dma-buf。
  • CPU 要读时要同步缓存:CPU 访问前后要调 DMA_BUF_IOCTL_SYNC。漏掉的话可能读到旧数据,这类问题很难查。
  • 什么时候不值得:按上面的实测,在我的桌面 CPU 上,每秒 30 帧时 Unix 域 socket 比原地生成每秒只多花约 19 ms((829 − 194) µs × 30)。这笔账在桌面上不大,但开发板的内存带宽小得多,而且 CPU 拷贝还会占缓存和总线,这部分没有计入。要不要做零拷贝,先在目标板上测。

问题 4:推理结果和大模型的 token 流用什么传?

用 Unix 域 socket 就够了,本机 HTTP 也可以。算一笔账:假设每秒生成 100 个 token,每个 token 按 16 字节算(这是假设的数字),也只有 1.6 KB/s。两个 token 间隔 10 毫秒,而我测的 4 KiB 消息单程约 16 微秒,IPC 只占约 0.16%。

llama.cpp 自带的服务程序就是走 HTTP,流式输出用 Server-Sent Events。这里最重要的是接口通用,能被别的程序直接调用,省下的那点传输时间没有意义。

结果类数据也要有消息边界。流式 socket 要自己加长度前缀,或者直接用 SOCK_SEQPACKET。

问题 5:多个业务进程共用一个模型,IPC 怎么设计?

做成一个推理服务,就是前面说的「控制走 socket,数据走共享内存」:

  • 连接和请求:每个客户端一条 Unix 域 socket。服务端可以用 SO_PEERCRED 查对方身份。
  • 传大数据:客户端把 memfd 或 dma-buf 的描述符通过 SCM_RIGHTS 传过来,之后每次请求只传缓冲区编号。
  • 排队和限流:多个请求同时到达时要排队。视频类请求积压时丢旧帧,命令类请求可以让客户端等待或返回繁忙。
  • 结果分发:结果很小时逐个连接发送。多个进程要读同一份大结果时,可以用共享内存发布订阅,iceoryx2 就是这个思路。
  • 客户端崩溃:连接断开时 read 返回 0,服务端据此回收它占用的缓冲区。这就是控制通道要用 socket 的原因之一。

Android 上是什么样

结论一样,只是工具不同:控制走 Binder,大块数据走共享内存或硬件缓冲区。Binder 只拷一次,但缓冲区只有 1 MB,所以图像这类数据不会直接放进事务,一般只传描述符。

[NOTE]

结论与速查

几个容易说错的地方:

容易说成准确说法
共享内存的优点是能跨进程所有 IPC 都能跨进程。它的优点是大数据不经过内核拷贝
共享内存就是零拷贝省掉的是经过内核的拷贝;写方先写在私有缓冲区时还要拷一次
共享内存一定最快小消息加上 eventfd 和 socket 差不多,成本都在唤醒
socket 慢是因为走协议栈Unix 域 socket 不走 TCP/IP;它的成本是两次拷贝和唤醒
共享内存支持广播它只是一块内存;订阅和广播是上层框架做的
信号量是一种传数据的 IPC它只负责同步,通常和共享内存配合
端侧 AI 该用共享内存按数据分:权重 mmap,帧用 dma-buf,结果和控制用 socket

面试被问到时可以这样答:

进程间通信分三类:传数据的有管道、消息队列、socket,每条消息经过内核拷两次,同步由内核负责;共享内存让多个进程映射同一块物理内存,大数据不用经过内核拷贝,但同步和通知要自己做,一般配信号量或 eventfd;信号和信号量只负责通知和同步。选型上,本机默认用 Unix 域 socket,跨机器用 TCP,大块高频数据用共享内存,实际系统常是控制走 socket、数据走共享内存。
  1. 先问数据在哪里。传数据、共享数据、只通知,三类分清楚,方式就不会列漏,也不会把别人的优点安错。
  2. 小消息看唤醒,大消息看拷贝。优化之前先判断成本在哪一边,不然换了共享内存也不会更快。
  3. 「零拷贝」要说清是哪一段。经过内核的拷贝、用户态的拷贝、设备之间的拷贝是三回事。
  4. 缓冲区大小也是性能参数。管道调大容量后,1 MiB 消息的耗时降到约四分之一,拷贝次数并没有变,省下的是来回切换。
  5. 不要给整个系统选一种 IPC。控制和数据的要求不同,分开走通常更简单,也更快。

[REF]

参考