Skip to content

Instantly share code, notes, and snippets.

@cajuncoding
Last active September 6, 2023 16:49
Show Gist options
  • Save cajuncoding/a88f0d00847dcfc241ae80d1c7bafb1e to your computer and use it in GitHub Desktop.
Save cajuncoding/a88f0d00847dcfc241ae80d1c7bafb1e to your computer and use it in GitHub Desktop.
This is an async compatible reader / writer lock based on semaphores
/// <summary>
/// Adapted from original lightweight async reader/writer implementation on Stack Overflow:
/// https://stackoverflow.com/a/64757462/7293142
/// The answered question was then improved and posted via comment here:
/// https://github.com/copenhagenatomics/CA_DataUploader/pull/90/files#diff-24a9664c904fe9276878f37dc1438aae578a76b7ef34eabbebf6ac66eaad83e6
///
/// Released under the same Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) licensing as the original StackOverflow source:
/// https://creativecommons.org/licenses/by-sa/4.0/
///
/// This (@CajunCoding) version adds support for simplified using(){} notation via IDisposable so that Try/Finally blocks are not needed.
///
/// Additional Notes:
/// This is an async compatible reader / writer lock; this lock allows any amount of readers to enter the lock while only a single writer can do so at a time.
/// While the writer holds the lock, any/all readers are blocked until the writer releases the lock. It doesn't have a lot of protection, so keep its usage simple (e.g. logic flows
/// where try/finally can be used and no recursion or re-entry is required.
/// How it works:
/// - Two semaphores & a Count of readers in the lock are used to provide the above guarantees.
/// - To guarantee no new readers or writers can enter the lock while a writer is active, a write semaphore is used
/// - Both readers and writers acquire this semaphore first when trying to take the lock
/// - Readers then release the semaphore just after acquiring the read lock, so more readers can enter the lock (so technically acquiring of reader locks do not occur concurrently)
/// - To guarantee the writer does not enter the lock while there are still readers in the lock, a read semaphore is used
/// - Both the writer and the first reader acquire this semaphore when trying to take the lock, but they do this *after* they hold the write semaphore.
/// - The last active reader holding the lock, releases the read semaphore. Note it does not need to be the reader that acquired it first.
/// - To track if a reader acquiring/releasing a lock is the first/last one, a reader count is tracked when acquiring/releasing the read lock.
/// - Cancellation tokens are supported so that readers/writers can abort while waiting for an active writer to finish its job; which is easy to do with a timed expiration cancellation token.
/// </summary>
public sealed class AsyncReaderWriterLock : IDisposable
{
readonly SemaphoreSlim _readSemaphore = new SemaphoreSlim(1, 1);
readonly SemaphoreSlim _writeSemaphore = new SemaphoreSlim(1, 1);
int _readerCount;
public async Task<IDisposable> AcquireWriterLock(CancellationToken token = default)
{
await _writeSemaphore.WaitAsync(token).ConfigureAwait(false);
try
{
await _readSemaphore.WaitAsync(token).ConfigureAwait(false);
}
catch
{
_writeSemaphore.Release();
throw;
}
return new LockToken(ReleaseWriterLock);
}
private void ReleaseWriterLock()
{
_readSemaphore.Release();
_writeSemaphore.Release();
}
public async Task<IDisposable> AcquireReaderLock(CancellationToken token = default)
{
await _writeSemaphore.WaitAsync(token).ConfigureAwait(false);
if (Interlocked.Increment(ref _readerCount) == 1)
{
try
{
await _readSemaphore.WaitAsync(token).ConfigureAwait(false);
}
catch
{
Interlocked.Decrement(ref _readerCount);
_writeSemaphore.Release();
throw;
}
}
_writeSemaphore.Release();
return new LockToken(ReleaseReaderLock);
}
private void ReleaseReaderLock()
{
if (Interlocked.Decrement(ref _readerCount) == 0)
_readSemaphore.Release();
}
public void Dispose()
{
_writeSemaphore.Dispose();
_readSemaphore.Dispose();
}
private sealed class LockToken : IDisposable
{
private readonly Action _action;
public LockToken(Action action) => _action = action;
public void Dispose() => _action?.Invoke();
}
}
@freddyrios
Copy link

@cajuncoding is the test meant to be used as pass/failed, or is the text being printed supposed to be inspected? Asking because it does not fail if one remove the locks from the test.

@cajuncoding
Copy link
Author

cajuncoding commented Mar 27, 2023

@freddyrios you are right, the test is not fully failing as expected 👍

The entire second part after Task.WhenAll(tasks).... validates the sequence of results written into the thread-safe queue, however there is an assumption baked in that in a pool of ~1000 async tasks at least one would interrupt the writing sequence of the writer in the absence of locks.

But they are not being interrupted as expected, and it's likely due to the tight loop where my Task.Delay(...) was intended to force breathing room to be interrupted, but it's not in the ideal place to do so. The delay should be inside the writers loop, and it appears to make a much more consistent test scenario to be after the Write is enqueued.....

I also tweaked the delay timing a bit (to be long enough, but not take too long to run the test). Now the test is consistently failing without locks and passing when they are in place 👍

The test is now been updated with the Write lock block now being:

                        //Items divisible by 10 attempt to get write Locks so this means they must wait to
                        //  be at the back of the line and will add results last...
                        using (await asyncReadWriteLock.AcquireWriterLock().ConfigureAwait(false))
                        {
                            //ALL writes here MUST be GROUPED TOGETHER!
                            //NOTE: WE intentionally delay for a bit of time here to allow room for READs to attempt
                            //      to interrupt this process, which should be prevented if our Writer Lock is working effectively.
                            for (var r = 0; r < writeDuplicationCount; r++)
                            {
                                results.Enqueue((index, $"WRITE-{r:000}"));
                                await Task.Delay(delayedTime).ConfigureAwait(false);
                            }
                        }

The Code in the Gist has been updated ✅

@freddyrios
Copy link

@cajuncoding that makes sense.

I have added a modified version to my repository, it has good enough detection with much faster run time.

  • it uses some synchronization so that all reader/writer threads are ready to run before they start racing
  • reduced the task count to 100, but using 1k still gave me good run times (locally, did not try on build server).
  • removes the use of Task.Delay, but increases the write block size to 40
  • removes the label strings and instead uses simple values 0-10 and -1 for reads
  • it validates not only the value (r) is correct but also the index
  • uses the using var statement syntax for the lock
  • does not print anything on success, but on failures prints the whole reported results (so one can inspect clearly what was reported)

You can find it here https://github.com/copenhagenatomics/CA_DataUploader/pull/236/files

@freddyrios
Copy link

also extracted a method "ForRacingThreads" so how that is achieved is not mixed with the test itself https://github.com/copenhagenatomics/CA_DataUploader/blob/0988de3194b0917e4add92bb3eaae4b7330f8947/UnitTests/AsyncReadWriteLockTests.cs

@cajuncoding
Copy link
Author

@freddyrios thanks for sharing 👍

@cho-regin
Copy link

Is there a license for this @cajuncoding? I would like to use this if I may

@cajuncoding
Copy link
Author

cajuncoding commented Sep 6, 2023

@cho-regin It has the same free and open licensing on the usage that the original (code) being remixed/refactored from a Stack Overflow answer has…

I’ve updated with a licensing statement to clarify:

/// Released under the same Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) licensing as the original StackOverflow source:
/// https://creativecommons.org/licenses/by-sa/4.0/

So it’s completely free to use or modify in your project but with no support guarantee…Enjoy!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment