Kernel mutex, cross-process named mutexes, Global and Local prefixes, AbandonedMutexException, thread affinity, cost and single-instance apps.
A Mutex is an operating-system mutual-exclusion object. Unlike lock, it can be named and shared between processes on the same machine, which is its main use: single-instance applications or protecting a shared file. It is much slower than lock because every acquire involves the kernel, it is thread-affine (the owning thread must release it) and re-entrant. If the owner exits without releasing, the next waiter gets AbandonedMutexException, which tells you the protected state might be inconsistent. It is not suitable for async code.
"A
Mutexis an operating-system mutual-exclusion object. Inside one process it does the same job aslock, but much more expensively, since every acquire is a kernel call. So I don't use unnamed mutexes. Its real value is that it can be named and shared by processes on the same machine: single-instance applications, or making sure two tools never write the same local file at the same time. Likelockit's thread-affine (the owning thread must release it) and re-entrant (the owner can acquire again but must release once per acquire). It has one featurelocklacks: if the owner dies without releasing, the next waiter getsAbandonedMutexException. It still owns the mutex, but that's a warning that the protected state may be half-written. It's not usable acrossawait, and it's machine-local, so for multiple servers I use a lease-based distributed lock instead."
lock vs Mutex| Aspect | lock / Monitor |
Mutex |
|---|---|---|
| Scope | One process | Process, or machine-wide when named |
| Implementation | User-mode fast path (object header / Lock) |
Kernel object (Windows) / runtime-emulated on Unix |
| Cost (uncontended) | Tens of nanoseconds | Microseconds (system call) |
| Thread-affine | Yes | Yes |
| Re-entrant | Yes | Yes (release once per acquire) |
| Abandonment detection | No | AbandonedMutexException |
| Timeouts | Monitor.TryEnter |
WaitOne(timeout) |
| Async-friendly | No | No |
| Wait on several handles | No | WaitHandle.WaitAny/WaitAll |
using var mutex = new Mutex(initiallyOwned: false, @"Global\Contoso.InvoiceExporter");
if (!mutex.WaitOne(TimeSpan.Zero))
{
Console.WriteLine("Another instance is running");
return;
}
try { Run(); }
finally { mutex.ReleaseMutex(); }
| Name prefix (Windows) | Visibility |
|---|---|
Global\name |
All sessions on the machine (e.g. services + interactive users) |
Local\name or no prefix |
Current terminal-server session only |
On Linux and macOS, .NET emulates named mutexes with shared files in a temp location: they work across processes on the same machine that share that location. Separate containers don't share named mutexes unless they share the underlying filesystem/namespace.
| Situation | What happens |
|---|---|
Owner thread exits without ReleaseMutex |
OS releases it; next WaitOne throws AbandonedMutexException but the caller now owns the mutex |
| Owner process is killed | Same |
| Correct handling | Catch it, treat the mutex as owned, validate or repair the shared resource, then continue |
| Need | Tool |
|---|---|
| Threads in one process | lock / System.Threading.Lock |
| Async critical section | SemaphoreSlim(1, 1) |
| Single-instance desktop/CLI app | Named Mutex |
| Two processes on one machine touching one file | Named Mutex, or exclusive file open (FileShare.None) |
| Async across processes | File lock, database application lock |
| Multiple machines / pods | DB application lock (sp_getapplock), Redis lock, blob lease, coordination service, with expiry |
Our [scheduled invoice exporter] could be started both by Windows Task Scheduler and manually by support staff. Roughly [once a month] partners received garbled CSV files. Process start logs showed two exporter instances running at the same time, both writing the same output path. I added a Global\ named mutex acquired with WaitOne(TimeSpan.Zero), so a second instance logs "already running" and exits cleanly. I also handled AbandonedMutexException (if a previous run crashed mid-export, the new run validates and regenerates the partial file before continuing), and kept all mutex-owning work synchronous. The corruption stopped; we also alerted when a second instance attempted to start. When the exporter later moved to two Kubernetes pods, we replaced the mutex with a database application lock, because a mutex can't coordinate across machines.
Checks understanding of OS-level primitives and their scope.
Green flags
Global\/Local\.lock.Red flags
On Windows, new Mutex(...) calls CreateMutexEx, producing a kernel object with a handle in the process's handle table. WaitOne calls a wait function (WaitForSingleObjectEx); ReleaseMutex calls ReleaseMutex. Every operation crosses into kernel mode, which is why it costs microseconds, even uncontended. lock stays in user mode unless it must block.
mutex.WaitOne();
await Task.Delay(10); // may resume on another thread
mutex.ReleaseMutex(); // ApplicationException: "Object synchronization method was called from an unsynchronized block of code"
The original thread is still recorded as the owner. If it exits, the mutex becomes abandoned. Async code must never hold a mutex across await.
For single-instance apps, keep the mutex referenced (a static field) until exit. If it's only a local and the JIT/GC decides it's dead, it can be finalised and released early, allowing a second instance. GC.KeepAlive(mutex) at the end of Main or a static field avoids this.
WaitHandle[] handles = [mutex, shutdownEvent];
int index = WaitHandle.WaitAny(handles, TimeSpan.FromSeconds(30));
This is one capability lock doesn't offer: wait for "lock acquired OR shutdown requested". WaitAll with a mutex in an STA thread isn't supported.
Company.Product.Purpose).MutexAcl.Create, Windows only).| Option | Notes |
|---|---|
SQL Server sp_getapplock / PostgreSQL advisory locks |
Tied to a session/transaction; released if the connection dies |
Redis lock (single-instance SET NX PX, or Redlock) |
Needs expiry; beware of clock and failover edge cases |
| Azure Blob lease | Time-bounded lease, renewed while working |
| Kubernetes Lease / leader election | For "one active instance" patterns |
All require expiry + fencing: assume a lock holder can pause (GC, network) past its lease and design writes to be idempotent or guarded by a fencing token.
| Version | Change |
|---|---|
| .NET Framework 1.0 | Mutex |
| .NET Framework 2.0 | AbandonedMutexException thrown by default |
| .NET Core 2.0+ | Named mutexes on Linux/macOS (runtime emulation) |
| .NET 5+ | MutexAcl (Windows) for ACLs |
| .NET 10 | Same core semantics |
kill -9 / End Task it while a third instance blocks in WaitOne(); catch AbandonedMutexException in the third.lock vs Mutex.WaitOne/ReleaseMutex pairs.await Task.Delay(1) between WaitOne and ReleaseMutex with ConfigureAwait(false) and observe the exception.WaitHandle.WaitAny with a mutex and a ManualResetEvent for shutdown.using System.Threading;
const string Name = @"Global\Contoso.InvoiceExporter";
using var mutex = new Mutex(initiallyOwned: false, Name);
bool owned;
try
{
owned = mutex.WaitOne(TimeSpan.Zero); // do not wait: single instance
}
catch (AbandonedMutexException)
{
owned = true; // we own it, but the previous owner died mid-run
Console.WriteLine("Previous run crashed; validating output file first");
}
if (!owned)
{
Console.WriteLine("Another exporter is running. Exiting.");
return;
}
try
{
Console.WriteLine("Exporting invoices...");
Thread.Sleep(2000); // synchronous work only: the mutex is thread-affine
}
finally
{
mutex.ReleaseMutex();
}
Mutex instead of lockWhat happens: new Mutex() guards in-process state.
Why it is wrong: kernel calls every time; no benefit over lock.
Do instead: lock/Lock; use Mutex only for named, cross-process exclusion.
awaitWhat happens: WaitOne(); await ...; ReleaseMutex();.
Why it is wrong: the continuation may run on another thread → ApplicationException; the original thread still owns it.
Do instead: keep owning code synchronous, or use a file/DB lock for async work.
AbandonedMutexExceptionWhat happens: the exception is swallowed or crashes the app. Why it is wrong: you own the mutex, but the resource may be half-written. Do instead: catch it, treat as owned, validate/repair the resource.
What happens: "only one instance" logic is relied on in a scaled-out deployment. Why it is wrong: it's machine-local (and container-local). Do instead: distributed lock or lease with expiry and fencing.
What happens: an exception skips ReleaseMutex.
Why it is wrong: the mutex stays owned until the thread exits (then becomes abandoned).
Do instead: try/finally.
What happens: ReleaseMutex() after WaitOne(0) returned false.
Why it is wrong: throws ApplicationException.
Do instead: track whether you own it and release only then.
What happens: the mutex is a local in Main that the GC considers dead.
Why it is wrong: it can be finalised and released, so a second instance starts.
Do instead: static field or GC.KeepAlive.
What happens: new Mutex(false, "Global\\App").
Why it is wrong: collisions with other software, or hostile pre-creation.
Do instead: specific names; ACLs for sensitive cases.
initiallyOwned: true and ignoring createdNewWhat happens: code assumes it owns the mutex after construction.
Why it is wrong: if the mutex already existed, the caller doesn't own it.
Do instead: use out bool createdNew or call WaitOne explicitly.
What happens: code assumes waiters acquire in arrival order. Why it is wrong: OS wait ordering isn't a guarantee you should depend on. Do instead: design without fairness assumptions; use a queue if order matters.
Mutex is a machine-level tool. I use it for single-instance tools and shared local files, and I think about abandonment explicitly. For anything distributed or async I pick a lease-based lock with expiry instead.
| Scope of exclusion | Tool |
|---|---|
| Threads in one process | lock, SemaphoreSlim |
| Processes on one machine | Named Mutex, file lock |
| Many machines | Database app lock, Redis or blob lease |
| Async across processes | File or database lock |
lock (x) { ... } gives mutual exclusion: only one thread at a time runs the block for the same lock object, and entering and leaving act as memory barriers. For a normal object the compiler emits Monitor.Enter(x, ref lockTaken) in a try/finally with Monitor.Exit. Since .NET 9 and C# 13, if x is a System.Threading.Lock, the compiler instead uses x.EnterScope() and disposes the scope, which is a dedicated, slightly faster lock type. Locks are re-entrant on the same thread, you cannot await inside one (CS1996), and you should lock on a private, readonly object and keep the block short.
A semaphore holds a count of available slots: WaitOne takes a slot (blocking if the count is zero) and Release returns one, so up to N threads can be inside at once. System.Threading.Semaphore is the kernel-backed version; it can be named for cross-process use on Windows (named semaphores are not supported on Linux or macOS). Unlike a Mutex, it has no owner: any thread can release it, and it is not re-entrant. Releasing past the maximum throws SemaphoreFullException. For in-process and async throttling, use SemaphoreSlim instead.
How to tell it (STAR)
We had [monthly corrupt exports]. Logs showed two exporter instances at once, one from the scheduler and one started manually. I added a named global mutex so the second copy exits, and handled the abandoned case by validating the file. The corruption stopped.
Last updated 31 Oct 2026.