C# 开发里提到进程间通信,大多数教程的第一反应都是 TCP 或命名管道。这两种方案当然成熟稳定,可如果只是同一台机器上的两个进程要高频交换几十 KB 的状态数据,MemoryMappedFile 往往是被忽略但性价比极高的选项。我之前在跨平台系统的本地数据同步 Demo 里,需要让一个后台采集进程和一个 UI 展示进程保持接近实时的状态更新,先后试过 Socket 和 NamedPipe,延迟和 CPU 占用都不太理想,最后换到 MemoryMappedFile 才把性能压下去。这篇就以服务端和客户端两个进程的完整代码为主线,把创建共享区、读写数据、跨进程同步和绕坑过程一起讲清楚,适合刚接触 C# 进程间通信、又不想引入外部组件的开发者参考。
1. 同机进程通信方案对比:为什么选MemoryMappedFile
1.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) 没有优雅退出机制,用 Ctrl+C 结束。程序退出后,操作系统会清理命名对象,但建议在正式项目里加上退出信号处理,让主线程释放完资源再退出。
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 初始所有权的坑,两台测试机控制台都白屏,最后加日志才发现锁没释放。后来我习惯在每个进程启动时把“服务端先跑、客户端后跑”的顺序写进部署文档,并且把所有命名对象统一加前缀,排查成本明显降下来。项目里如果条件允许,把共享内存通信封装成一个通用类,写和读都走同一个崩溃恢复逻辑,复杂场景下能省不少事。