Say you want to have the functionality of a reader/writer lock, but have it work cross-process. The built-in SRWLOCK works only within a single process. Can we build a reader/writer lock that works across processes?
For convenience, let’s say that you want to support a maximum of N simultaneous readers, for some fixed value N. We can do this:
- Create a semaphore with a token count of N. Share this semaphore with all of the processes, either by giving it a name or by duplicating the handle into each of the processes.
- To take a read lock, claim one token from the semaphore. To release the lock, release the token.
- To take a write lock, claim N tokens from the semaphore. To release the lock, release N tokens.
The idea for the write lock is that it’s accomplished by claiming all the read locks, thereby ensuring that nobody else can get a read lock.
#define MAX_SHARED 100
HANDLE sharedSemaphore;
void AcquireShared()
{
WaitForSingleObject(sharedSemaphore, INFINITE);
}
void ReleaseShared()
{
ReleaseSemaphore(sharedSemaphore, 1, nullptr);
}
void AcquireExclusive()
{
for (unsigned i = 0; i < MAX_SHARED; i++) {
WaitForSingleObject(sharedSemaphore, INFINITE);
}
}
void ReleaseExclusive()
{
ReleaseSemaphore(sharedSemaphore, MAX_SHARED, nullptr);
}
Since we are using WaitÂForÂSingleÂObject, we can also add a timeout, so that the caller can decide to abandon the operation if they can’t claim the lock.
bool AcquireSharedWithTimeout(DWORD timeout)
{
return WaitForSingleObject(sharedSemaphore, timeout) == WAIT_OBJECT_0;
}
bool AcquireExclusiveWithTimeout(DWORD timeout)
{
DWORD start = GetTickCount();
for (unsigned i = 0; i < MAX_SHARED; i++) {
DWORD elapsed = GetTickCount() - start;
if (elapsed > timeout ||
WaitForSingleObject(sharedSemaphore, timeout - elapsed) == WAIT_TIMEOUT)) {
// Restore the tokens we already claimed.
if (i > 0) {
ReleaseSemaphore(sharedSemaphore, i, nullptr);
}
return false;
}
}
return true;
}
Exclusive acquisition is tricky because we have to call WaitÂForÂSingleÂObject multiple times, with decreasing timeouts as time passes. If we run out of time, then we need to give back the tokens we had prematurely claimed.
There’s still a problem here. We’ll look at it next time.
I’m really looking forward to this series, as I recently had to implement my own cross-process R/W lock. Of course, in my case I had the luxury of not needing to make it fit for general use.
The dotnet runtime team believes you should never release a lock from a thread other than the one that owns it. When we get to the end of this series it will become obvious why someone would want to do this.
I assume the second ReleaseShared() is supposed to actually be called ReleaseExclusive()?
And is there only ever one writer process/thread? Since obviously this approach won’t work if more than one thread wants to take the exclusive lock at once.
Yeah, that second ReleaseShared() was a copy/pasta bug. And we’ll look into the topic more in part 2.
It wouldn’t be very exclusive if more than one consumer could take the lock at the same time…
Sorry, I didn't phrase that very clearly.
If there's only one thread (or process here) ever that does all the writing, and all other processes only ever plan to read, then I think this approach has a chance of working (though maybe there's more gotchas in there, I haven't thought through everything). That is, some single "producer" process that's the one that tries to take the exclusive lock, and then multiple "consumer" processes (though that's probably the wrong term) will only ever read using a shared lock and not ever try to get the exclusive lock.
But if more than one process...
The issue that immediately jumps out at me is: what if two processes try to AcquireExclusive at the same time? As long as each of them gets at least 1 semaphore token it’s a deadlock.
👀 to see how closely this follows my own attempt, so far so "good":
Use a semaphore
Nope, multiple named mutexes
Multi-threading leads to scaling back reader lifetime which leads to giving up on RW semantics completely