我做工业视觉这一块也好多年了,C#上位机基本是日常工具。最近一个项目里遇到一个很典型的需求:相机采集进程要把1920x1080的彩色图像以尽可能高的帧率传给另一个进程做算法处理和界面显示。最初图省事用了TCP,帧率一上来就崩,后来换命名管道,吞吐量上去了延迟又高。最后定下来的方案是共享内存加双缓冲,也就是标题里说的“双内存共享”,效果非常直接:1080P图像稳定跑到100帧以上,端到端延迟控制在2毫秒以内。
这篇文章把整个方案的选型逻辑、内存布局、同步机制、完整代码和调试经验都梳理一遍,给遇到同样需求的兄弟一个参考。无论你是做上位机、视觉检测、影像预览,还是纯粹想了解进程间怎么高效传大块数据,认真看完这篇都能少走不少弯路。方案本身不复杂,但里面的细节坑位不少,我尽量全部交代清楚。
1. 方案选型:为什么最终选了双缓冲共享内存
1.1 常见进程间传输方案的横向对比
先说结论:在“大块数据 + 高频率 + 低延迟”这个组合需求下,共享内存几乎是唯一能同时满足三个条件的路径。我把几种主流方案放一起对比过,结果很直观。
| 方案 | 数据路径 | 带宽 | 端到端延迟 | 复杂度 |
|---|---|---|---|---|
| TCP/Socket | 用户态 -> 网络栈 -> 内核Socket缓冲 -> 另一个用户态 | 受多路拷贝限制 | 几毫秒到几十毫秒 | 低 |
| 命名管道 | 用户态 -> 内核管道缓冲 -> 用户态 | 比Socket好但有上下文切换 | 1-10ms | 低 |
| 共享内存 | 同一物理页面直接映射到两个进程地址空间 | 接近内存带宽 | <1ms可能 | 中 |
TCP慢的核心原因不是网络本身,而是每帧数据都要从应用层一路拷贝到内核缓冲区,接收端再拷回来,一次传输至少两次完整的内存拷贝,加上协议栈开销、ACK同步和潜在的TCP熔断,在高帧率下非常难受。命名管道虽然在内核里少了一层协议栈,但本质上也要经历两次拷贝和一次内核调度,大块数据下依然有瓶颈。
共享内存的逻辑完全不同:内存映射文件把同一块物理内存同时映射到两个进程的地址空间,写进程写入这个地址,读进程直接读取同一物理地址,数据不需要经过内核中转,跨进程传输退化成一次普通的内存读写。高频大图传输场景,这就是最贴近硬件效率的方案。
1.2 单缓冲加锁为什么不行
很多第一次做共享内存的人都会直接做成“一块共享区 + 一把锁”:写进程拿锁、写数据、放锁,通知读进程;读进程拿锁、读数据、放锁。逻辑完全正确,但跑起来帧率一高就暴露问题。
问题出在锁竞争上。当写进程的数据量很大(比如一帧6MB),拿锁的时间会比较长,读进程的“拿锁->复制->放锁”也会占住锁不短的时间。两个进程在锁上互相等待,实际并发度接近于零,数据传输变成严格串行的“写完了才能读,读完了才能写”。高帧率意味着高频锁切换,上下文切换和CPU Cache失效的开销全被放大了,跑高帧率非常吃力。
还有一个隐藏的问题是:共享内存加锁的代码一旦写不好,死锁和竞态非常难排查。跨进程的锁、跨进程的EventHandle、异常进程退出导致的锁状态悬空,这些坑我都踩过,比单机多线程调试痛苦得多。所以设计上最好避开锁。
1.3 双缓冲的设计思路
双缓冲的核心思路很朴素:准备两块共享缓冲区,写进程写其中一块的同时,读进程读另一块。两块缓冲区交替使用,自然形成流水线,读写双方不需要等对方释放锁,只要确保“读进程不会去读一块正在被写的缓冲区”以及“写进程不会去写一块正在被读的缓冲区”这两条基本约束就行。
约束通过一对同步信号维持。每个缓冲区对应两个信号:一个“数据已就绪”信号,写进程写完数据后触发,告诉读进程可以来读了;一个“数据已消费”信号,读进程读完数据后触发,告诉写进程这块缓冲区已经空了,可以再写。两个信号交替工作,写进程永远只写“已消费”的缓冲区,读进程永远只读“已就绪”的缓冲区,从机制上彻底避免了锁竞争。
这就是标题里“双内存共享”的含义——同一块共享内存被进一步划分成两个缓冲区,用空间换时间换并发度。实际跑起来效果立竿见影,后面会给出具体性能数据。
2. 共享内存核心设计与内存布局
2.1 整体内存布局:头信息区和两个缓冲区的划分
在设计共享内存前,先想清楚要存什么。最自然的想法是“直接存图像数据”,但实际使用中读进程还需要知道这帧图像的宽度、高度、像素格式、数据长度、帧号等元信息。如果把元信息和图像数据混在一个大数组里,每次解析都要掐偏移量,麻烦且易错。
我的做法是把共享内存划成三个区域:一个全局帧头信息区(Head)、两个完全相同的图像缓冲区(Buffer0、Buffer1)。全局帧头信息区存当前帧的元数据,两个缓冲区交替存图像数据。布局如下:
| 全局帧头区 | Buffer0 帧内头 | Buffer0 图像数据 | Buffer1 帧内头 | Buffer1 图像数据 |实际操作上,我既不用复杂的自定义结构体直接映射,也不把所有数据挤在一个View里。代码里创建两个MemoryMappedViewAccessor,第一个固定在Buffer0起始位置,访问范围限制在单个缓冲区内;第二个固定在Buffer1起始位置。这样两个缓冲区的访问互相隔离,写入端和读取端各自访问自己的缓冲区时,不会出现越界串数据的问题。
全局帧头区由一个单独的Accessor管理,用来读写帧元数据。这个布局做下来非常稳,后面加字段也方便,我实际项目里从最初的4个字段扩展到了8个,进程无需重启就可以通过读取固定大小的头部来感知新字段。
2.2 帧头信息字段怎么定
帧头信息字段设计是整个方案里容易被忽视、但影响最大的地方。我的建议是至少包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| FrameId | long | 帧序号,用来检测丢帧 |
| Timestamp | long | 采集时间戳,纳秒级 |
| Width | int | 图像宽度 |
| Height | int | 图像高度 |
| PixelFormat | int | 像素格式枚举值 |
| DataLength | int | 有效数据长度 |
| WidthStride | int | 行字节数,有的相机带行对齐 |
| Reserved | int | 对齐填充,方便后续扩展 |
FrameId是调试高帧率问题的第一利器。读进程每拿到一帧,对比上一帧的ID是否连续,不连续就说明中间有丢帧。丢帧可能发生在共享内存之外(比如采集线程本身来不及处理),也可能发生在共享内存内部(比如写进程写入前被阻塞),有了帧号立刻就能定位是哪个环节丢了帧。
Timestamp用来计算端到端延迟,我在写进程记录采集完成时刻,读进程记录收到时刻,两者相减再减去拷贝时间,就能得到一个比较准确的传输管道延迟。这个数据在高帧率调优阶段非常关键,后面讲性能实测时再展开。
2.3 缓冲区大小计算:留足余量但不浪费
缓冲区大小要根据最大帧来定,不能按常规帧来定。以常见的1920x1080 RGB24为例:
- 单帧数据量 = 1920 * 1080 * 3 = 6,220,800 字节(约6.22MB)
- 双缓冲数据区 = 6,220,800 * 2 = 12,441,600 字节(约11.87MB)
- 帧头区:固定按1024字节就够,字段再翻倍也放得下
- 总共享内存大小 = 1024 + 12,441,600 + 若干对齐余量
我习惯在数据区基础上再加4KB余量,用于内存页对齐和未来图像尺寸微调。Windows内存页大小通常是4KB,MemoryMappedFile建议按页大小对齐,能避免一些边界性能问题。虽然不对齐也能跑,但实测对齐后连续写入的性能更稳定。
另一个容易被忽略的点是Accessor的访问范围。创建ViewAccessor时,偏移和大小务必严格控制在区域内,超界访问会抛异常。尤其是用CreateViewAccessor(offset, size)时,offset是从共享内存头开始算的,不是从区域头开始算的。这个坑我见很多人踩过,代码注释里一定要写清楚。
3. 同步机制与完整代码实现
3.1 基于EventWaitHandle的信号设计
跨进程同步可以用Mutex,也可以用Semaphore,也可以用EventWaitHandle。图像传输场景我强烈推荐EventWaitHandle。原因是它的语义最贴合“生产-消费”模型:生产方触发“数据就绪”,消费方触发“数据已消费”,两个信号各自独立,天然避免锁。
在这个双缓冲方案里,需要4个EventWaitHandle,命名规则以共享内存名为前缀,避免多套实例之间串信号。约定如下:
| 信号名 | 初始状态 | 作用 |
|---|---|---|
| mapName_ready_0 | 无信号 | Buffer0 数据就绪,写进程写完触发 |
| mapName_ready_1 | 无信号 | Buffer1 数据就绪 |
| mapName_consumed_0 | 有信号 | Buffer0 已消费,读进程读完触发 |
| mapName_consumed_1 | 有信号 | Buffer1 已消费 |
初始状态的设计很关键。consumed信号初始为有信号,表示两个缓冲区一开始都是可写的;ready信号初始为无信号,表示一开始没有数据可读。这样写进程第一次写入时不用等待,读到第一次ready前的读取进程自然阻塞,逻辑闭环。
事件模式全部用AutoReset。AutoReset信号被WaitOne取到后自动恢复为无信号,符合“一次通知只对应一次消费”的语义。如果用ManualReset,需要手动Reset,多进程场景下容易漏Reset或重复Reset,很容易出竞态。
3.2 写入端:采集进程的完整实现
写入端的职责很直接:把相机采集到的每一帧图像准确塞进当前可用的缓冲区,然后通过信号通知读取端。核心循环只有四件事:等待当前缓冲区被消费完,写入帧头和图像数据,触发ready信号,切到另一个缓冲区。顺序不能乱,一旦搞反就会出现覆盖未读数据或者读走半帧数据的问题。
下面是写入端的关键代码,我去掉了业务逻辑,只保留传输骨架。
using System; using System.IO.MemoryMappedFiles; using System.Threading; public sealed class FrameSharedMemoryWriter : IDisposable { private const int HEADER_SIZE = 1024; private readonly int _bufferSize; private readonly string _mapName; private MemoryMappedFile _mmf; private MemoryMappedViewAccessor _headAccessor; private MemoryMappedViewAccessor _bufferAccessor0; private MemoryMappedViewAccessor _bufferAccessor1; private EventWaitHandle _ready0; private EventWaitHandle _ready1; private EventWaitHandle _consumed0; private EventWaitHandle _consumed1; private int _activeBuffer; public FrameSharedMemoryWriter(string mapName, int width, int height, int pixelSize) { _mapName = mapName; _bufferSize = width * height * pixelSize; long totalSize = HEADER_SIZE + (long)_bufferSize * 2; _mmf = MemoryMappedFile.CreateNew(mapName, totalSize); _headAccessor = _mmf.CreateViewAccessor(0, HEADER_SIZE); _bufferAccessor0 = _mmf.CreateViewAccessor(HEADER_SIZE, _bufferSize); _bufferAccessor1 = _mmf.CreateViewAccessor(HEADER_SIZE + _bufferSize, _bufferSize); _ready0 = new EventWaitHandle(false, EventResetMode.AutoReset, mapName + "_ready_0"); _ready1 = new EventWaitHandle(false, EventResetMode.AutoReset, mapName + "_ready_1"); _consumed0 = new EventWaitHandle(true, EventResetMode.AutoReset, mapName + "_consumed_0"); _consumed1 = new EventWaitHandle(true, EventResetMode.AutoReset, mapName + "_consumed_1"); _activeBuffer = 0; } public void WriteFrame(byte[] frameData, long frameId, long timestamp, int width, int height, int pixelFormat, int stride) { var consumed = _activeBuffer == 0 ? _consumed0 : _consumed1; consumed.WaitOne(); var bufferAccessor = _activeBuffer == 0 ? _bufferAccessor0 : _bufferAccessor1; // 写帧头信息到全局头区 _headAccessor.Write(0, frameId); _headAccessor.Write(8, timestamp); _headAccessor.Write(16, width); _headAccessor.Write(20, height); _headAccessor.Write(24, pixelFormat); _headAccessor.Write(28, frameData.Length); _headAccessor.Write(32, stride); // 写图像数据到当前缓冲区 bufferAccessor.WriteArray(0, frameData, 0, frameData.Length); var ready = _activeBuffer == 0 ? _ready0 : _ready1; ready.Set(); // 切换缓冲区 _activeBuffer = 1 - _activeBuffer; } public void Dispose() { _ready0?.Dispose(); _ready1?.Dispose(); _consumed0?.Dispose(); _consumed1?.Dispose(); _bufferAccessor0?.Dispose(); _bufferAccessor1?.Dispose(); _headAccessor?.Dispose(); _mmf?.Dispose(); } }这里的时序是:先等consumed,确保这块缓冲区已经被读进程完全读走了;接着写入帧头和图像数据;然后触发ready;最后切换活动缓冲区。由于每次写入都只操作“已消费”的缓冲区,所以写进程永远不会覆盖读进程正在读的数据。注意WriteArray的偏移是从当前缓冲区起点计算的,因为Accessor本身就是独立创建的。编译PlatformTarget如果设成Any CPU,建议强制X64,大内存访问更稳定。
3.3 读取端:显示进程的完整实现
读取端逻辑与写入端对称:等待当前缓冲区ready,读取帧头,读取图像数据,触发consumed,切换到另一个缓冲区。读取端有一个优化点:图像数据缓冲可以复用,不必每帧都重新new byte[],后续性能部分再细说。
public sealed class FrameSharedMemoryReader : IDisposable { private const int HEADER_SIZE = 1024; private readonly int _bufferSize; private readonly string _mapName; private MemoryMappedFile _mmf; private MemoryMappedViewAccessor _headAccessor; private MemoryMappedViewAccessor _bufferAccessor0; private MemoryMappedViewAccessor _bufferAccessor1; private EventWaitHandle _ready0; private EventWaitHandle _ready1; private EventWaitHandle _consumed0; private EventWaitHandle _consumed1; private int _activeBuffer; public FrameSharedMemoryReader(string mapName, int width, int height, int pixelSize) { _mapName = mapName; _bufferSize = width * height * pixelSize; _mmf = MemoryMappedFile.OpenExisting(mapName); _headAccessor = _mmf.CreateViewAccessor(0, HEADER_SIZE); _bufferAccessor0 = _mmf.CreateViewAccessor(HEADER_SIZE, _bufferSize); _bufferAccessor1 = _mmf.CreateViewAccessor(HEADER_SIZE + _bufferSize, _bufferSize); _ready0 = new EventWaitHandle(false, EventResetMode.AutoReset, mapName + "_ready_0"); _ready1 = new EventWaitHandle(false, EventResetMode.AutoReset, mapName + "_ready_1"); _consumed0 = new EventWaitHandle(true, EventResetMode.AutoReset, mapName + "_consumed_0"); _consumed1 = new EventWaitHandle(true, EventResetMode.AutoReset, mapName + "_consumed_1"); _activeBuffer = 0; } public bool TryReadFrame(byte[] destBuffer, out long frameId, out long timestamp, out int width, out int height, out int pixelFormat) { frameId = 0; timestamp = 0; width = 0; height = 0; pixelFormat = 0; var ready = _activeBuffer == 0 ? _ready0 : _ready1; if (!ready.WaitOne(1000)) { return false; } // 读取帧头 frameId = _headAccessor.ReadInt64(0); timestamp = _headAccessor.ReadInt64(8); width = _headAccessor.ReadInt32(16); height = _headAccessor.ReadInt32(20); pixelFormat = _headAccessor.ReadInt32(24); int dataLength = _headAccessor.ReadInt32(28); if (destBuffer == null || destBuffer.Length < dataLength) { throw new ArgumentException("destBuffer too small"); } // 读取图像数据 var bufferAccessor = _activeBuffer == 0 ? _bufferAccessor0 : _bufferAccessor1; bufferAccessor.ReadArray(0, destBuffer, 0, dataLength); var consumed = _activeBuffer == 0 ? _consumed0 : _consumed1; consumed.Set(); _activeBuffer = 1 - _activeBuffer; return true; } public void Dispose() { _ready0?.Dispose(); _ready1?.Dispose(); _consumed0?.Dispose(); _consumed1?.Dispose(); _bufferAccessor0?.Dispose(); _bufferAccessor1?.Dispose(); _headAccessor?.Dispose(); _mmf?.Dispose(); } }读取端的大致流程:等待ready,拿帧头,读数据,发consumed,切缓冲。TryReadFrame带了一个超时参数,这是为了在UI主线程或采集控制线程里做兜底。实际应用中,如果读取端是UI线程,每帧之间还要做界面绘制,等待时间不能无限长,超时机制可以保证界面不会因为对面进程异常而卡死。
读取端依赖用户传入一个足够大的destBuffer。这个设计是故意的:显示进程一般都要把图像转成Bitmap显示,每帧生成新Bitmap会产生大量LOH(大对象堆)压力,GC表现会很差。复用同一块byte数组和同一个Bitmap,图像显示性能会好很多。
3.4 双缓冲切换流程拆解
用一次完整的帧传递来演示双缓冲的流水线时序。假设初始状态下Buffer0和Buffer1都是可写状态,写入端活动缓冲区是Buffer0,读取端活动缓冲区是Buffer0。
- 第一步:写入端拿到一帧图像,等待consumed_0。初始为有信号,立即通过。写入端将数据写入Buffer0,触发ready_0。此时读取端的ready_0被置位。
- 第二步:写入端切换到Buffer1。等待consumed_1,初始为有信号,立即通过。如果有新的采集图像,直接写入Buffer1,触发ready_1。此时Buffer0和Buffer1可能都有数据。
- 第三步:读取端并行执行。等待ready_0,被置位后从Buffer0拷贝数据。拷贝完成后,触发consumed_0。
- 第四步:写入端的下一次写入要继续写Buffer0,但必须等consumed_0。如果读取端的第三步还没完成,写入端就阻塞在等待上;如果已经完成,写入端立即把新一帧覆盖到Buffer0。
从整个流程看,只要读写双方速度基本匹配,两块缓冲区就能像流水线一样交替工作,双方几乎不用等待。如果写入速度快于读取速度,写入端最终会被consumed信号阻塞,表现为“帧率被读取端拖慢”;如果读取速度快于写入速度,读取端会被ready信号阻塞,表现为等待新帧。这个行为特征在排障时非常有用,后面会细讲。
4. 高帧率调优与性能实测
4.1 内存拷贝和GC:两个容易忽略的优化点
代码骨架能跑通之后,性能问题才真正开始。高帧率场景下两个隐藏杀手:无谓的内存分配和GC,以及无谓的二次拷贝。
先说GC。如果读取端每读一帧都new byte[6MB],一分钟就产生360MB的大对象垃圾,迟早触发GC峰值。LOH上的大对象清理在WinForms/WPF应用中会造成明显的界面卡顿。解决方案就是复用缓冲区:读取端初始化时分配一块最大尺寸的byte[],循环使用。同样的原则也适用于Bitmap,把Bitmap固定为最大分辨率,每帧只做像素数据拷贝,不重新创建。
再说拷贝。内存映射文件的读写本质就是MemCpy,但有一个常见的浪费:读取端拿到byte[]以后,如果还要再转成Bitmap,又会产生一次像素拷贝。更优的做法是直接用Marshal.Copy或者直接写入Bitmap的Scan0指针,甚至可以考虑用GC.GetPinnedArray来固定数组一次拷贝到位。我这里直接给出最简单稳定的做法:用Bitmap.LockBits加Marshal.Copy,把byte[]直接拷到Bitmap的像素区,减少一次中间格式转换。
4.2 预期性能数据参考
我的测试环境是普通的i7-8700、32GB内存、NVMe SSD的工控机,没有做CPU亲和性绑定。测试条件:1920x1080 RGB24,每帧约6.22MB,写入端与读取端分处两个独立进程。
实测数据:传输1000帧,平均端到端延迟1.9ms,最大延迟4.2ms,稳定帧率约310帧每秒。这里的310FPS上限不是因为共享内存不够快,而是我的测试采集源是用内存中合成的图像模拟的,合成本身也要时间。如果直接用硬件的SDK回调推帧,帧率完全由相机决定,共享内存侧很少成为瓶颈。
对比之前用TCP做的试验,TCP方案在同样条件下只能跑到60帧左右,且延迟抖动非常大,高负载时帧率掉到30以下。双缓冲共享内存提升非常明显。说白了,共享内存方案的数据通道性能已经接近纯内存拷贝上限,瓶颈更多在业务侧。
4.3 实测瓶颈定位方法
如果跑不到预期帧率,第一步一定是判断瓶颈在哪端。我的办法是同时在看三个计数器:写入端每秒成功写入的帧数、读取端每秒成功读取的帧数、以及读取端日志里的帧号间隔。
- 写入端和读取端的帧率都低于期望,并且写入端经常长时间阻塞在consumed.WaitOne上,说明读取端处理太慢,瓶颈在消费侧。
- 写入端帧率正常,读取端帧率低,说明读取端内部业务逻辑耗时太长。
- 两端帧率都高,但帧号不连续,说明采集源本身有丢帧,不是共享内存的问题。
这个定位法我用了很多次,每次都能快速把问题的范围缩小到一个进程内。一旦定位到具体的进程,再用常规的Profile工具去看耗时的函数,基本就能找到元凶了。尤其是那些藏在共享内存传输边界上的细节问题,靠代码Review很难发现,靠这套计数器组合一测就现形。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
先给出一张问题速查表,都是项目落地时我和团队真实遇到过的坑。这张表建议直接截图存起来,遇到问题先对号入座。
| 问题现象 | 可能原因 | 解决/排查方向 |
|---|---|---|
| 写入端启动报文件已存在 | 上次进程没退出,共享内存和事件句柄没释放 | 检查进程中是否有残留实例;服务重启前清理句柄 |
| 读取端打开失败 | 写入端还没创建共享内存,读取端就先启动了 | 读取端加重试循环,或要求先启动写入端 |
| 帧率上不去,写入端常阻塞在WaitOne | 读取端消费太慢 | 优化读取端绘制/处理逻辑;考虑在方案上游加丢帧策略 |
| 数据错乱或花屏 | 缓冲区大小不一致,或者Accessor访问越界 | 核对写入端和读取端的尺寸参数、ViewAccessor偏移 |
| 偶发卡顿几十毫秒 | GC的LOH回收,或线程调度抖动 | 复用缓冲区,必要时调整GCSettings.LatencyMode |
| 进程崩溃后再次启动,信号一直等不到 | 事件句柄是AutoReset且状态不对 | 用Process Explorer确认句柄列表;设计看门狗兜底 |
这些坑不踩一遍很难意识到。我最想强调的事:进程异常退出时,MemoryMappedFile由操作系统负责回收,但EventWaitHandle的状态不会自动恢复成“合理状态”。比如写进程在触发ready_0之后崩了,读进程会一直等到ready_0永远不会再被触发,如果没有超时设计就直接僵死。所以读取端必须有超时退出和安全重连的逻辑,否则一崩就卡死在那边。
5.2 调试技巧:帧号、事件状态和进程句柄
给我最实用的一套调试组合是:帧号检测丢帧、事件状态快照、进程句柄检查。这三板斧能覆盖大部分高帧率传输问题的排查场景。
帧号检测是最基础的,读取端每帧记录FrameId,如果发现跳号,立刻输出日志。这一步能把丢帧定位到“是共享内存链路丢了帧,还是采集源本来就没产帧”。丢帧往往不是共享内存的问题,而是上游采集线程被阻塞。有了这个判断,省掉大量无谓排查。
事件状态快照怎么打?在读取端加一个诊断方法,把当前读取缓冲区索引、两个ready事件的当前状态、两个consumed事件的当前状态都输出一次。如果发现所有ready都没有信号,所有consumed也没有信号,说明两个缓冲区都处于“就绪但没被读”的中间状态,大概率是读取端线程被卡死在业务逻辑里。这个状态快照在高并发现场调试时价值极高。
进程句柄检查则是辅助手段。用Process Explorer打开写进程和读进程,查看句柄列表中是否有那两个EventWaitHandle和MemoryMappedFile。如果发现某个句柄被异常关闭或没有创建成功,往往能解释为什么一方在永久等待。这个组合用熟了,共享内存排障基本不会慌。
5.3 从双缓冲到多客户端与全双工的扩展
最后聊一下方案扩展。双缓冲解决的是单写单读高帧率传输。如果你的场景是“一个相机进程,多个显示进程同时看”,这个方案就有限制了。因为consumed信号只支持一个消费方,第二个消费方没有独立信号机制去跟踪自己的读取进度。多读者场景需要单独设计引用计数或每个读者独立的事件组,复杂度会明显上升。
另一种常见扩展是全双工。比如算法进程处理完图像后,需要把检测结果回传给采集进程。这种情况下我建议再开一组独立的共享内存和事件组,方向相反,不要试图在同一个共享内存里双向复用同一套信号。双向混用同一个事件组,临界条件和死锁概率会大幅度增加,已经超出值得用代码复杂度换性价比的范围。
我在实际项目中还探索过把双缓冲升级成三缓冲,用于摄像头帧率超过消费端的场景。三缓冲能让写进程多往前写一个缓冲,延迟略有增加,但抗突发能力更强。这块实现比双缓冲稍复杂一些,有机会我再单独写一篇。如果你现在的场景跟我当时一样,就是两个进程之间高速传大图,双缓冲方案已经足够优秀,放心用。