C#进程间通信:用MemoryMappedFile实现高性能共享内存同步

发布时间:2026/10/15 16:04:01
C#进程间通信:用MemoryMappedFile实现高性能共享内存同步
C# 开发里提到进程间通信大多数教程的第一反应都是 TCP 或命名管道。这两种方案当然成熟稳定可如果只是同一台机器上的两个进程要高频交换几十 KB 的状态数据MemoryMappedFile 往往是被忽略但性价比极高的选项。我之前在跨平台系统的本地数据同步 Demo 里需要让一个后台采集进程和一个 UI 展示进程保持接近实时的状态更新先后试过 Socket 和 NamedPipe延迟和 CPU 占用都不太理想最后换到 MemoryMappedFile 才把性能压下去。这篇就以服务端和客户端两个进程的完整代码为主线把创建共享区、读写数据、跨进程同步和绕坑过程一起讲清楚适合刚接触 C# 进程间通信、又不想引入外部组件的开发者参考。1. 同机进程通信方案对比为什么选MemoryMappedFile1.1 四种常见方案的适用边界先聊方案选型。同一个进程内用线程、静态变量、队列都很顺手但两个独立进程之间没法直接访问对方内存必须通过操作系统提供的 IPC 机制。常见的做法有这么几类TCP Socket 是最容易想到的。跨机器也能用生态成熟但本机同样要走完整的网络协议栈包含监听端口、三次握手、连接维护和收发缓冲这些开销在本地通信场景里大部分都是白花的。实测下来同机高频小包通信的延迟和 CPU 占用都比共享内存高一个量级。命名管道 NamedPipe 比 TCP 更适合本机通信接口也友好Windows 下由内核直接实现。但它本质是数据流每次发送和接收都伴随内核态的拷贝与上下文切换数据量一旦变大或者消息频率升高容易看到明显的吞吐瓶颈。最关键的是它天然是点对点的一个服务端一个客户端多进程共享数据时得自己搭分发模型。消息队列和中间件方案功能强、可靠投递、有削峰能力问题是引入外部依赖。为了两个本地进程互相传几个字节而部署一套队列服务实在没必要而且多一跳就多一次序列化和网络传输延迟不占优。这时候 MemoryMappedFile 的优势就出来了它把一块内存映射区域暴露给多个进程大家读写的是同一片物理内存不需要序列化也没有网络协议栈开销。写到共享区的数据另一端进程只要在合适的时机读拿到的就是完整的最新状态。1.2 选型背后的性能与复杂度权衡我的判断标准很简单先看数据量再看频率最后看是否需要跨机器。如果数据量在几 KB 到几 MB频率每秒几十次以内而且只是在同一台服务器或同一台工控机上做进程间同步MemoryMappedFile 是首选。它的延迟通常在微秒级因为没有协议解析也没有中间进程转发。读者可以把它类比成一个多人实时协作文档大家都看同一份内容谁改谁存其他人刷新就有最新版本。TCP 类似通过邮箱寄信命名管道像一个专人跑腿中间件则是驿站中转——都能送达但共享内存明显更快。复杂度上MemoryMappedFile 真正的难点不是创建和读写而是跨进程同步。共享区本身不保证原子性一个进程写一半另一个进程读一半就可能读到坏数据。所以必须配合 Mutex 或 EventWaitHandle 这类同步原语。这也是这篇文章要重点展开的部分。从工程角度看选型还有一个隐形收益MemoryMappedFile 是 .NET 基础类库自带的不需要额外 NuGet 包Windows、Linux 都能跑只是平台语义略有差异。在一个目标部署环境比较受限的项目里这个优势很关键。下面我们就深入机制再给出完整范例。2. 写代码前必须搞懂的内存映射机制2.1 原理共享同一份物理内存页面MemoryMappedFile 底层做的事情可以这样理解操作系统把一段文件或分页文件区域映射到进程的虚拟地址空间进程读写这一区域时实际就是在读写内存。多个进程如果把同一个命名对象映射进各自的地址空间它们看到的其实是同一批物理内存页面。这里面有一个细节值得强调并不是说每个进程都复制了一份数据。不同进程的虚拟地址可能不同但页表最终指向同一个物理页。因此 A 进程在某地址写入的数据B 进程在自己的映射地址上读就能看到不需要任何数据拷贝。这正是它比管道和 Socket 快的根本原因。实际开发时我们几乎不感知物理页。我们只需要创建或打开一个命名映射对象然后获取视图访问器把它当成一个可以随机存取的字节数组来用。命名对象名就是跨进程的钥匙所有进程用同一个名称才能映射到同一块区域。2.2 MemoryMappedFile、ViewAccessor、同步对象的三角关系C# 里三个核心类型要分清楚MemoryMappedFile 代表整个映射区管创建、打开和容量。SharedMemory 服务的“地基”就是它。MemoryMappedViewAccessor 是访问视图的核心对象可以按偏移量读写 byte、int、long、结构体以及数组。它负责把物理映射区包装成可读写的字节流接口。命名同步对象包括 Mutex 和 EventWaitHandle负责解决“谁先写、谁先读、写入有没有完成”的问题。它们不承载业务数据但离开它们共享区很快就会出乱子。三个角色配合起来才是完整可靠的内存映射通信方案。只看 MemoryMappedFile 而不谈同步等于建了仓库没配管理员货车可以随意进出迟早事故。2.3 Accessor还是ViewStream访问共享区有两种常见方式MemoryMappedViewAccessor 和 MemoryMappedViewStream。Accessor 适合做有固定偏移的随机读写尤其适合“长度前缀 负载”这样的自定义报文格式。我可以在偏移 0 写一个 long 表示数据长度在偏移 8 写实际数据读的时候反过来。这种方式对结构体的读写支持也不错可以配合 Marshal 直接映射一个类。ViewStream 是流式语义适合把 MemoryMappedFile 当成内存版文件来用比如配合 StreamReader、BinaryWriter 或序列化器。它的优势是 API 熟悉缺点是流式游标在读多段数据时不如偏移写清楚处理并发也更容易出错。还有一种方式是获取指针用不安全代码直接操作内存性能最高但要求 fixed 固定内存风险也大。对绝大多数业务场景Accessor 足够完整范例里用的就是 Accessor。3. 服务端实现创建共享区并持续写入3.1 命名约定与容量设计动手写服务端之前先定命名和容量。共享区名就相当于一个全局标识服务端和客户端必须完全一致。我的习惯是不用简单字符串而是带业务前缀和进程角色后缀比如mmf_demo_shm、mmf_demo_mutex、mmf_demo_ready。共享区和同步对象各用各的名字避免混在一起。命名空间上如果只是同一个用户 session 内的进程通信可以只用普通名称如果想要跨终端会话或跨 session 可见就要加Global\前缀。范例统一用了Global\这样无论在普通控制台还是远程 session 下测试行为都一致。需要注意的是跨 session 和跨用户还涉及权限问题这个放到常见问题里说。容量也不是越大越好。分配 1 MB 够大多数场景用只有要传大文件时才需要划到几十 MB 甚至几百 MB。容量一旦创建后续如果要扩容重建共享区是件很麻烦的事因此最好一次性按业务上限来定。我习惯预留 20% 到 30% 的余量减少后期改动。3.2 数据协议长度前缀共享区里的数据结构要提前定好否则两个进程各写各的连解析方式都凑不到一起。最简单可靠的是“长度前缀 字节负载”偏移 0 写入一个 long表示后面真实数据占多少字节偏移 8 开始写入数据本体。为什么不直接用固定大小结构体因为业务消息可能是变长的。固定结构体要么浪费空间要么一改字段就得改协议。长度前缀是成本最低、可扩展性最好的协议方案几乎所有自研共享内存通信都会这么做。序列化方面示例用 UTF-8 字符串做演示。真实项目里可以把对象序列化成 JSON 字节或二进制字节写进去之前拿长度读出来之后反序列化思路完全一致。重点是把长度校验做好防止读端拿到非法长度后疯狂申请内存导致进程崩溃。3.3 双事件同步机制详解这是整个范例里最有价值的部分。我要用一个 Mutex 保护共享区不让读写同时进行再用两个 EventWaitHandle 做“数据就绪”和“数据已消费”的握手。Mutex 的作用是互斥访问。写端拿锁之后写入写完释放读端拿锁之后读取读完释放。这样就不会出现读端读到一半、写端又在改同一个位置的情况。EventWaitHandle 解决的是“什么时候读”。只靠 Mutex读端不知道写端什么时候写完只能不断轮询浪费 CPU。用dataReady事件写端写完后 Set读端 WaitOne 到信号就去读。用dataConsumed事件读端读完后再 Set写端 WaitOne 到信号再写下一轮避免覆盖掉还没被读走的数据。两个事件配合起来就像接力棒的交接写端跑完第一棒把棒子交出去读端接到棒子跑完自己的路程再通知写端可以准备下一棒。这套机制保证了每一轮数据都能被完整消费不会互相踩脚。事件类型我用 ManualReset而不是 AutoReset。ManualReset 需要手动 Reset正好适合在一轮开始前清理上一轮的状态。AutoReset 在 WaitOne 返回后自动恢复非信号虽然代码更简洁但容易因为触发时序问题丢失信号调试起来很头疼。3.4 服务端完整代码下面给出服务端完整代码基于 .NET 6 控制台项目使用 C# 8 的 using var 语法。完整代码可以直接复制到Program.cs运行。using System; using System.IO.MemoryMappedFiles; using System.Text; using System.Threading; class SharedMemoryServer { private const string MmfName Global\mmf_demo_shm; private const long Capacity 1024 * 1024; // 1 MB private const string MutexName Global\mmf_demo_mutex; private const string ReadyEventName Global\mmf_demo_ready; private const string ConsumedEventName Global\mmf_demo_consumed; static void Main() { using var mmf MemoryMappedFile.CreateOrOpen(MmfName, Capacity); using var accessor mmf.CreateViewAccessor(0, Capacity); using var mutex new Mutex(false, MutexName); using var dataReady new EventWaitHandle(false, EventResetMode.ManualReset, ReadyEventName); using var dataConsumed new EventWaitHandle(false, EventResetMode.ManualReset, ConsumedEventName); Console.WriteLine(服务端共享内存已就绪等待客户端...); int counter 0; while (true) { string message $心跳消息 #{counter}; byte[] bytes Encoding.UTF8.GetBytes(message); // 一次完整写周期 dataConsumed.Reset(); dataReady.Reset(); mutex.WaitOne(); try { accessor.Write(0, (long)bytes.LongLength); // 长度前缀 accessor.WriteArray(8, bytes, 0, bytes.Length); // 数据本体 } finally { mutex.ReleaseMutex(); } dataReady.Set(); Console.WriteLine($服务端写入 {bytes.Length} 字节 - {message}); // 等待客户端确认本轮数据被读完 bool consumed dataConsumed.WaitOne(TimeSpan.FromSeconds(3)); if (!consumed) { Console.WriteLine(警告3 秒内客户端未读取本轮数据可能被下一轮覆盖。); } Thread.Sleep(500); // 模拟业务生产间隔 } } }几个注意事项实战后才发现重要。Mutex 初始化时第二个参数传 false表示创建后当前线程不持有锁之后每次 WaitOne 再获取。如果误把 initiallyOwned 设为 true创建线程会一直持有锁后续 WaitOne 直接把自己堵死这类死锁问题排查起来非常隐蔽。我自己第一次跑这个范例就是在这里栽了跟头控制台白屏半天最后才发现是锁没释放。Console.WriteLine用了英文字符串是为了避免中文乱码干扰大家看核心逻辑实际项目里直接写中文没问题。服务端 while(true) 没有优雅退出机制用 CtrlC 结束。程序退出后操作系统会清理命名对象但建议在正式项目里加上退出信号处理让主线程释放完资源再退出。4. 客户端实现打开共享区并可靠读取4.1 打开已有共享区客户端第一件事不是创建共享区而是打开已有的命名共享区。用MemoryMappedFile.OpenExisting名称必须和服务端完全一致包括Global\前缀。如果共享区不存在这个方法会抛 FileNotFoundException所以启动顺序通常是服务端先跑客户端再连。打开共享区后客户端还要打开同步对象。Mutex 和 EventWaitHandle 也有 OpenExisting 方法名字同样不能写岔。有人会误以为同步对象和共享区同名其实它们是不同的内核对象各自有独立的命名空间但为了避免混淆最好统一加后缀区分。访问视图大小也很重要。客户端调用CreateViewAccessor(0, Capacity)时Capacity 一定要和服务端创建时的容量匹配。如果客户端指定的视图范围超过共享区实际容量会抛 ArgumentOutOfRangeException。更稳妥的做法是打开共享区后查一下SafeMemoryMappedFileHandle的容量再定视图范围但示例里直接用常量前提是两边约定好。4.2 读取流程与合法性校验读取流程倒着走写流程先等待 dataReady 信号拿到 Mutex 后从共享区偏移 0 读长度再从偏移 8 读数据释放 Mutex最后设置 dataConsumed 通知服务端本轮的共享区可以复用了。这里有个很容易忽略的点等待事件和获取 Mutex 之间有竞态。写端 WriteArray 完成后会 Set(dataReady)随后等待 dataConsumed。客户端收到 dataReady 信号并不意味着 Mutex 一定可用。如果服务端在释放 Mutex 之前 Set 了事件代码里没有这样做但重构时容易改乱客户端就会卡在 WaitOne(Mutex) 上。所以代码里先 ReleaseMutex 再 Set(dataReady)这个顺序必须保持。合法性校验不能省。偏移 0 读出来的 long 如果是一个异常值比如负数或者超过共享区容量减 8直接拿它 new byte[] 会触发 OutOfMemoryException。共享区数据可能被写端写坏也可能被其他进程错误覆盖所以读端一定要做边界检查。我自己写的读端如果发现长度非法直接设置数据已消费事件再跳过本轮避免把服务端拖死。4.3 客户端完整代码与运行验证客户端完整代码如下同样基于 .NET 6 控制台项目。using System; using System.IO.MemoryMappedFiles; using System.Text; using System.Threading; class SharedMemoryClient { private const string MmfName Global\mmf_demo_shm; private const long Capacity 1024 * 1024; // 1 MB private const string MutexName Global\mmf_demo_mutex; private const string ReadyEventName Global\mmf_demo_ready; private const string ConsumedEventName Global\mmf_demo_consumed; static void Main() { using var mmf MemoryMappedFile.OpenExisting(MmfName); using var accessor mmf.CreateViewAccessor(0, Capacity); using var mutex Mutex.OpenExisting(MutexName); using var dataReady EventWaitHandle.OpenExisting(ReadyEventName); using var dataConsumed EventWaitHandle.OpenExisting(ConsumedEventName); Console.WriteLine(客户端已连接共享区按任意键退出。); while (!Console.KeyAvailable) { bool signaled dataReady.WaitOne(TimeSpan.FromSeconds(1)); if (!signaled) { // 超时说明当前没有新数据继续等 continue; } byte[] buffer null; mutex.WaitOne(); try { long len accessor.ReadInt64(0); if (len 0 || len Capacity - 8) { Console.WriteLine($客户端发现非法长度 {len}本轮跳过。); dataConsumed.Set(); continue; } buffer new byte[len]; accessor.ReadArray(8, buffer, 0, buffer.Length); } finally { mutex.ReleaseMutex(); } dataConsumed.Set(); string text Encoding.UTF8.GetString(buffer); Console.WriteLine($客户端收到 {buffer.Length} 字节 - {text}); } } }运行验证步骤很简单。先启动服务端看到“共享内存已就绪”后启动客户端两边控制台会交替打印写入和收到的日志。服务端每 500 毫秒生产一条心跳消息客户端收到后打印然后服务端继续下一轮。如果停止客户端服务端会在 3 秒后打印警告说明客户端在超时窗口内没有消费数据。这个行为是刻意设计的它演示了共享内存通信里最常见的“可靠投递 vs 最新值”取舍下一节展开。5. 并发同步的典型陷阱与优化方向5.1 Mutex初始所有权与事件复位是个重灾区先讲一个有代表性的坑。Mutex 构造函数里有两个重载new Mutex(bool initiallyOwned, string name)和new Mutex(false, string name, out bool createdNew)。把 initiallyOwned 设为 true 时创建线程立刻拥有互斥体如果后续忘记在初始化阶段 ReleaseMutex那当前线程之后每次 WaitOne 都会无限等待。最麻烦的是这种死锁不一定在服务端单独运行时暴露客户端连上来之后才卡住定位起来非常耗时间。我在范例里统一用new Mutex(false, MutexName)创建后不持有锁每次 WaitOne 都交给业务代码手动控制语义清晰得多。事件复位顺序同样敏感。每次服务端写周期开始要同时 Reset 两个事件。为什么必须 Reset dataConsumed因为上一轮读端可能已经 Set 了它如果不 Reset服务端下一轮WaitOne(dataConsumed)会立即返回相当于多写了一轮可能直接覆盖掉还没读走的数据。为什么必须 Reset dataReady因为上一轮 Set 的信号如果还挂着客户端可能在下一帧数据写入完成前就提前读到旧数据。复位放在写入之前再配合 Mutex才能保证“读到的永远是完整的最新帧”。5.2 可靠投递和最新值两套语义两套写法共享内存通信不像网络协议那样天然保证可靠投递。双事件握手能实现一端写、另一端确认读完再写下一轮的可靠效果但代价是吞吐量受限。如果写太快读太慢服务端会一直等整体节奏被最慢的一端牵制。如果业务只需要“最新状态”不要求每条消息都消费比如 UI 界面的帧率刷新、监控大屏的实时指标就可以放弃 dataConsumed 等待写端写完 Set(dataReady)读端只要发现信号就去读读不到就跳帧。这样写端永远不阻塞吞吐量会高不少但代价是可能丢中间帧。设计时要想清楚自己到底要哪套语义。我遇到不少开发者把共享内存当成消息队列用要求每条都收到又不加确认握手结果数据被覆盖后双方互相甩锅。共享内存最适合的是“最新值同步”如果业务强依赖可靠投递最好改用命名管道或消息队列不要硬掰。5.3 双缓冲优化双事件握手在单缓冲区方案下会限制并发因为读端必须先读完写端才能写。高频场景可以用双缓冲把读写解耦。思路是在共享区里划分两块区域写端交替写入 A 区和 B 区读端每次读取“当前最新未锁”的那块。写端写 A 区时读端可以同时读 B 区写端下一轮写 B 区时读端读 A 区。两个缓冲区互不干扰理论上可以做到读写并行。实现上需要在共享区头部用一个原子字段或版本号标记当前最新块。读端读的时候先读版本号再读对应块最后再检查版本号有没有变化如果变了说明读的过程中发生了切换要重读。这套逻辑比双事件握手复杂但对于追求极限吞吐的场景非常值得。第一次做双缓冲时版本号校验经常写漏导致读端读到两帧拼接的脏数据排查了很久才想明白是“先读版本、再读数据、后验版本”的顺序没守住。5.4 句柄释放与生命周期管理共享内存对象、视图访问器、Mutex、事件句柄都是系统资源不释放会泄漏。虽然进程退出时操作系统会回收但长时间运行的服务如果频繁创建共享区和同步对象却不释放句柄数会只增不减最终可能触发资源不足。范例里用 using var 在 Main 方法退出时统一释放代码简洁。真实项目里共享区往往由某个后台服务长期持有建议把共享内存服务封装成一个实现了 IDisposable 的类在 Dispose 里按顺序释放 accessor、mmf、mutex、event。释放顺序也有一点讲究先释放访问器再释放映射文件最后释放同步对象。如果反过来可能出现其他进程正在等待 Mutex 而映射文件已经被回收的竞态异常。还有一个隐含风险是重复创建时残留旧句柄。服务端进程异常崩溃后命名 Mutex 和事件可能仍然存在于内核中新进程启动时 OpenExisting 或 CreateOrOpen 会打开同一个底层对象如果里面数据协议版本不一致很容易读到垃圾数据。稳妥的做法是共享区里额外放一个“协议版本号”客户端打开后先读版本版本对不上就提示重建。6. 常见问题与排查速查表6.1 现象对照表自己写代码加帮同事排查共享内存问题遇到过不少重复的坑整理成一张速查表供参考。现象可能原因解决办法客户端 OpenExisting 抛 FileNotFoundException服务端还没创建共享区或名称不匹配先启动服务端检查 MMF 名称是否完全一致包括 Global 前缀OpenExisting 抛 UnauthorizedAccessException客户端和服务端运行在不同用户、不同权限级别统一用相同用户运行跨用户时给命名对象配置安全描述符服务端创建共享区后客户端一直收不到数据事件名称不一致或事件用 AutoReset 导致信号丢失核对 Mutex、Event 名称改用 ManualReset 并手动 Reset读取时长度偶发巨大甚至崩溃读端没有做长度边界校验增加 len 0客户端读到数据是错的、半截的读写时没有加 Mutex或 Mutex 名不一致用同一个命名 Mutex 保护读写全过程服务端写一轮后卡死Mutex initiallyOwned 参数误设为 true或 dataConsumed 事件没有 Set检查 Mutex 初始化确保读端在 finally 中释放锁并设置 consumed客户端退出后服务端一直等待dataConsumed.WaitOne 没有超时保护给 WaitOne 设置超时时间超时后按业务策略处理跨 session 访问不到共享区MMF 或同步对象没有加 Global 前缀或双方前缀不统一统一在名称前加 Global并确保权限允许6.2 两个实用排查思路第一个思路是给 WaitOne 全部加上超时。共享内存的卡死往往来自事件没被 Set或者 Mutex 被别的进程长期持有。如果所有 WaitOne 都带超时至少能把卡死变成可观测的警告配合日志能快速定位是哪一端出问题。范例里服务端和客户端的所有 WaitOne 都带了超时就是这个目的。第二个思路是检查系统资源里的命名对象。Windows 下可以用任务管理器看进程句柄数如果句柄数持续增长先怀疑共享内存相关对象没有释放。想看得更细可以用系统自带的性能监视器筛选对象名观察命名 Mutex 和事件的句柄变化。我在排查一个服务进程长期运行后无法创建新共享区的故障时就是用这个办法确认是句柄泄漏最终定位到 ViewAccessor 没有正确 Dispose。这里顺便说一句命名内核对象在进程崩溃后不一定立刻消失。Windows 会在最后一个句柄关闭时才真正销毁对象所以“旧进程崩溃后新进程连不上同名共享区”这类问题本质上都是旧句柄还残留。重启进程前建议把相关进程全部结束避免命名冲突带来的诡异表现。最后说点实在的如果只用共享内存做两个进程之间的最新状态同步双事件握手其实可以简化成“互斥锁加版本号轮询”省掉等待确认的环节吞吐会更好。如果你确实需要每条消息都可靠送达保留双事件但一定要给 WaitOne 加超时否则一个客户端进程崩溃就能把服务端拖死。我第一次跑这个范例时就踩了 Mutex 初始所有权的坑两台测试机控制台都白屏最后加日志才发现锁没释放。后来我习惯在每个进程启动时把“服务端先跑、客户端后跑”的顺序写进部署文档并且把所有命名对象统一加前缀排查成本明显降下来。项目里如果条件允许把共享内存通信封装成一个通用类写和读都走同一个崩溃恢复逻辑复杂场景下能省不少事。