在Windows及跨平台.NET环境中,当多个进程需要排他性地访问某个共享资源(例如同一日志文件、本地数据库文件或串口设备)时,普通的lock关键字或Monitor只能约束同一进程内的线程,无法跨进程生效。此时应当使用操作系统级别的同步原语,而C#中的Mutex正是基于内核对象实现的进程间互斥锁。

一、Mutex的基本原理与进程间工作机制
Mutex全称Mutual Exclusion(互斥体),在.NET中对应System.Threading.Mutex类。它与EventWaitHandle同属内核同步对象,由操作系统统一管理,因此可以被不同进程通过名称打开并共享。当一个进程中的线程获取了Mutex后,其他进程或同一进程内的其他线程调用WaitOne都会被阻塞,直到持有者调用ReleaseMutex。
具名Mutex通过构造函数中的name参数创建。如果该名称的Mutex已存在于系统中,后续打开操作会指向同一个内核对象;如果不存在,则新建。利用这一特性,我们可以让完全独立的两个exe程序协同加锁。需要特别注意的是,Mutex分为本地Mutex(无名称)和全局Mutex(有名称),只有具名Mutex才能用于进程间同步。在Linux等平台,.NET通过模拟方式提供类似语义,但本质仍依赖系统IPC机制。
1.1 与Monitor的对比
Monitor(即lock语法糖)是用户态同步,速度快但不能跨越进程边界;Mutex是内核态同步,涉及系统调用,开销更大,但作用域是整个系统。在单纯多线程场景优先用lock,在多进程排他场景必须用Mutex。下面用表格列出核心差异:
| 特性 | Monitor(lock) | Mutex |
|---|---|---|
| 作用范围 | 单一进程内线程 | 系统级,跨进程 |
| 性能 | 高 | 较低(内核切换) |
| 异常退出处理 | 自动释放 | 可能产生abandoned状态 |
| 命名共享 | 不支持 | 支持具名 |
二、C#中使用Mutex实现进程间互斥的完整示例
下面展示一个经典场景:两个独立的C#控制台程序在启动时都要写入同一个文件,我们用名为“GlobalMyAppLock”的Mutex保证同一时间只有一个进程能写。注意在Windows上,若希望所有会话都能访问,通常加“Global”前缀;非Windows平台可忽略此前缀。
using System;
using System.IO;
using System.Threading;
class Program
{
static void Main(string[] args)
{
// 创建或打开名为 GlobalMyAppLock 的互斥体
bool createdNew;
using (var mutex = new Mutex(false, @"GlobalMyAppLock", out createdNew))
{
try
{
// 等待获取锁,最多等待5秒
if (mutex.WaitOne(5000))
{
try
{
// 临界区:写入共享文件
File.AppendAllText("shared.log",
$"进程 {Environment.ProcessId} 在 {DateTime.Now} 写入n");
Console.WriteLine("写入成功");
}
finally
{
// 必须释放,否则其他进程无法进入
mutex.ReleaseMutex();
}
}
else
{
Console.WriteLine("等待超时,未获取到互斥锁");
}
}
catch (AbandonedMutexException)
{
// 其他进程崩溃未释放时会抛此异常,当前线程已获得所有权
Console.WriteLine("检测到 abandoned mutex,已接管锁");
mutex.ReleaseMutex();
}
}
}
}
上述代码使用using语句确保Mutex对象在退出时 Dispose,但需注意 Dispose 不等同于 ReleaseMutex;若已在临界区内则应显式释放。我们传入initialOwner为false,让当前线程不立即持有,再通过WaitOne竞争,这是更安全的写法。
如果某进程在持有Mutex时非正常终止(如任务管理器杀掉),系统会将该Mutex标记为 abandoned,下一个获取者会收到AbandonedMutexException。如示例所示,捕获后通常应释放以避免死锁。这是进程间锁比线程锁更脆弱的地方,因此关键业务必须加超时和异常处理。
2.1 多实例单例启动控制
Mutex也常用于限制程序只运行一个实例。以下代码在程序启动时尝试以初始所有者创建Mutex,若已存在则退出:
using System;
using System.Threading;
class SingleInstance
{
static void Main()
{
bool createdNew;
// 初始所有者为true,若已存在则createdNew为false
var mutex = new Mutex(true, @"GlobalMySingleApp", out createdNew);
if (!createdNew)
{
Console.WriteLine("程序已在运行");
return;
}
try
{
Console.WriteLine("程序启动,按回车退出");
Console.ReadLine();
}
finally
{
mutex.ReleaseMutex();
mutex.Dispose();
}
}
}
这种方式比检查进程列表更可靠,因为内核对象生命周期由系统管理,不受当前用户会话隔离影响(使用Global前缀时)。但需注意,若程序崩溃未释放,下次启动可能仍报已存在,可结合超时或异常处理缓解。
三、常见误区与最佳实践
不少开发者误以为Mutex和lock一样可以重入。实际上,同一线程连续调用两次WaitOne而未释放,第二次会阻塞自己,造成死锁。Mutex不具备递归计数功能(除非用相同的Mutex对象配合ownership跟踪自己实现)。因此临界区应保持简短,避免跨方法调用中重复获取。
另一个误区是忽略名称前缀和权限。在Windows服务与桌面程序交互时,若服务创建Local前缀Mutex,桌面程序用Global打开会失败。跨用户场景务必统一前缀并考虑访问控制。此外,.NET Core/.NET 5+在Linux下使用Mutex时,底层依赖pthread或文件锁,命名规则略有差异,建议测试目标平台行为。
3.1 释放保护与超时设定
永远为WaitOne设置合理超时,不要把超时设为-1无限等待,否则对方崩溃又abandoned处理不当就会卡死。推荐模式是try-catch-finally包裹,并在finally中判断当前线程是否持有再释放。若使用using,则应在释放锁之后才让using退出Dispose,防止对象提前销毁导致句柄无效。
综合来看,C#的Mutex是实现进程间互斥最直接的内置方案。理解其内核对象本质、abandoned异常以及命名规则,就能在日志写入、设备独占、单例启动等场景中写出健壮的多进程协作代码。