This is the eighth part of the Concurrency series. For your convenience you can find other parts in the table of contents in Part 1 – Mutex performance in .NET
We know how to use global mutexes to synchronize processes. However, there is a big drawback — we don’t know who owns the mutex and we cannot get that information easily. There is https://docs.microsoft.com/en-us/windows/win32/debug/wait-chain-traversal API for reading that information but it is not easy to follow. Can we do better?
One common trick is to use memory mapped files to store the information. Let’s see the code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
using System; using System.Diagnostics; using System.IO.MemoryMappedFiles; using System.Linq; using System.Runtime.InteropServices; using System.Threading; namespace MutexUtils { public static class MutexUtils { [DllImport("kernel32.dll", SetLastError = true)] unsafe public static extern long InterlockedCompareExchange64(long* destination, long exchange, long comperand); public static unsafe bool DoWithMutex(string name, Action action, int timeoutInSeconds = int.MaxValue, int spinTimeout = 250) { // Store <PID><TID> as one field long myIdentifier = ((long)Process.GetCurrentProcess().Id << 32) + AppDomain.GetCurrentThreadId(); // Calculate timeout wall clock DateTime end = DateTime.UtcNow.AddSeconds(timeoutInSeconds); // Open memory mapped file initialized with zeros using (var memoryMappedFile = MemoryMappedFile.CreateOrOpen(name, 8)) using (var viewStream = memoryMappedFile.CreateViewStream(8, 8)) { var pointer = viewStream.SafeMemoryMappedViewHandle.DangerousGetHandle(); try { int holderPid = -1; int holderTid = -1; while (DateTime.UtcNow < end) { // Take lock only if it is not taken var currentLock = InterlockedCompareExchange64((long*)pointer, myIdentifier, 0); if (currentLock == 0) { action(); return true; } // Lock is taken, let's see who holds it holderTid = GetTid(currentLock); holderPid = GetPid(currentLock); bool exists = false; try { exists = Process.GetProcessById(holderPid).Threads.OfType<ProcessThread>().Any(t => t.Id == holderTid); } catch { } // If holding thread doesn't exist then the lock is abandoned if (!exists) { // Clear lock only if it is still held by previous owner var currentLock2 = InterlockedCompareExchange64((long*)pointer, 0, currentLock); if (currentLock == currentLock2) { Console.WriteLine($"Mutex {name} was abandoned by pid={holderPid} tid={holderTid}"); } } Thread.Sleep(spinTimeout); } Console.WriteLine($"Timeout when waiting on mutex {name} held by pid={holderPid} tid={holderTid}"); } finally { // Clear lock only if I'm the one holding it var currentLock = InterlockedCompareExchange64((long*)pointer, 0, myIdentifier); if (currentLock != myIdentifier) { Console.WriteLine($"I tried to release mutex held by someone else, pid={GetPid(currentLock)} tid={GetTid(currentLock)}"); } } } return false; } private static int GetPid(long value) { return (int)(value >> 32); } private static int GetTid(long value) { return (int)(value & 0xFFFFFFFF); } } } |
Magic, a lot. Let’s go part by part.
First, we want to store process ID and thread ID somewhere in the lock to be able to read it easily later. In line 18 we encode those in one 64-bit long variable which we can later replace using CAS operation.
In line 21 we just calculate the time when we should stop trying to get lock. This is for timeouts.
Next, we create memory mapped file (lines 24, 25) not backed by any physical file. This is to avoid permission problems — we cannot map the same file in two processes without copy-on-write semantics. We will need separate reader for debugging.
Next, we spin in the loop. Each time we try to take lock (line 37). If current lock value is zero (line 39) then it means that our lock is available and we have just locked it. We execute the action and then return (which is jump to finally).
However, if lock wasn’t available, we now have the owner of the lock (lines 46 and 47). So we need to check if the owner is still alive. We read process and look for thread in line 52.
If it didn’t exist, we try to clear the lock (line 62). We clear it only if it is still held by the same owner. And here is big warning — process ID and thread ID can be recycled so here we may inadvertently release still used mutex!
Then in line 69 we sleep for some timeout and loop again.
Ultimately, in line 77 we try to clear the lock if it is taken by us. We may end up in this line of code if some exception appears so we cannot just blindly release the mutex.
That’s all, you can verify that it should work pretty well. Just be aware of this chance of clearing up some other mutex, you may come up with different identifier if needed.
In theory, this can be solved by using CAS for 128 bits:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
public static class MutexUtils { public static void DoWithSemaphore(string name, int semaphoresCount, Action action) { bool checkedAll = false; string fullName; for (int index = 0; ; index = (index + 1) % semaphoresCount) { fullName = name + index; if (DoWithMutex(fullName, action, checkedAll ? 20 : 1)) { break; } if (index == semaphoresCount - 1) { checkedAll = true; } } } [DllImport("kernel32.dll", SetLastError = true)] unsafe public static extern bool InterlockedCompareExchange128(long* destination, long exchangeHigh, long exchangeLow, long* comperand); public static unsafe bool DoWithMutex(string name, Action action, int timeoutInSeconds = int.MaxValue, int spinTimeout = 250) { // Store <PID><TID> as one field long myIdentifier = ((long)Process.GetCurrentProcess().Id << 32) + AppDomain.GetCurrentThreadId(); long myTime = DateTime.UtcNow.Ticks; // Calculate timeout wall clock DateTime end = DateTime.UtcNow.AddSeconds(timeoutInSeconds); // Open memory mapped file initialized with zeros using (var memoryMappedFile = MemoryMappedFile.CreateOrOpen(name, 8)) using (var viewStream = memoryMappedFile.CreateViewStream(8, 8)) { var pointer = viewStream.SafeMemoryMappedViewHandle.DangerousGetHandle(); long* currentLock = stackalloc long[2]; currentLock[0] = 0; currentLock[1] = 0; try { int holderPid = -1; int holderTid = -1; while (DateTime.UtcNow < end) { // Take lock only if it is not taken var isLockFree = InterlockedCompareExchange128((long*)pointer, myIdentifier, myTime, currentLock); if (isLockFree) { action(); return true; } // Lock is taken, let's see who holds it holderTid = GetTid(currentLock[0]); holderPid = GetPid(currentLock[1]); bool exists = false; try { exists = Process.GetProcessById(holderPid).Threads.OfType<ProcessThread>().Any(t => t.Id == holderTid); } catch { } // If holding thread doesn't exist then the lock is abandoned if (!exists) { // Clear lock only if it is still held by previous owner var isLockStillOwnedByTheSameOwner = InterlockedCompareExchange128((long*)pointer, 0, 0, currentLock); if (isLockStillOwnedByTheSameOwner) { Console.WriteLine($"Mutex {name} was abandoned by pid={holderPid} tid={holderTid}"); } } Thread.Sleep(spinTimeout); } Console.WriteLine($"Timeout when waiting on mutex {name} held by pid={holderPid} tid={holderTid}"); } finally { // Clear lock only if I'm the one holding it currentLock[0] = myIdentifier; currentLock[1] = myTime; if (!InterlockedCompareExchange128((long*)pointer, 0, 0, currentLock)) { Console.WriteLine($"I tried to release mutex held by someone else, pid={GetPid(currentLock[0])} tid={GetTid(currentLock[1])}"); } } } return false; } private static int GetPid(long value) { return (int)(value >> 32); } private static int GetTid(long value) { return (int)(value & 0xFFFFFFFF); } } |
However, it doesn’t work on my machine, my kernel32.dll doesn’t support this CAS operation.
And since this is playing with very dangerous primitives, I take no responsibility for any failures in your systems if it doesn’t work. I tested it in my situation and it behaved correctly, though.