diff --git a/.gitignore b/.gitignore index f1e3d20..b9df257 100644 --- a/.gitignore +++ b/.gitignore @@ -250,3 +250,5 @@ paket-files/ # JetBrains Rider .idea/ *.sln.iml + +NugetApiKey.txt \ No newline at end of file diff --git a/AsyncLock.sln b/AsyncLock.sln deleted file mode 100644 index f8d30ed..0000000 --- a/AsyncLock.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29521.150 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AsyncLock", "AsyncLock\AsyncLock.csproj", "{077768A9-D1A4-48BB-8ECF-C66D50E47396}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "UnitTests\UnitTests.csproj", "{7864530D-D038-495F-9283-34A185FBC20F}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {077768A9-D1A4-48BB-8ECF-C66D50E47396}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {077768A9-D1A4-48BB-8ECF-C66D50E47396}.Debug|Any CPU.Build.0 = Debug|Any CPU - {077768A9-D1A4-48BB-8ECF-C66D50E47396}.Release|Any CPU.ActiveCfg = Release|Any CPU - {077768A9-D1A4-48BB-8ECF-C66D50E47396}.Release|Any CPU.Build.0 = Release|Any CPU - {7864530D-D038-495F-9283-34A185FBC20F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7864530D-D038-495F-9283-34A185FBC20F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7864530D-D038-495F-9283-34A185FBC20F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7864530D-D038-495F-9283-34A185FBC20F}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {EE50F46F-060F-49C5-9FD9-2304E0A6C2C6} - EndGlobalSection -EndGlobal diff --git a/AsyncLock.slnx b/AsyncLock.slnx new file mode 100644 index 0000000..306e16d --- /dev/null +++ b/AsyncLock.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/AsyncLock/AsyncLock.cs b/AsyncLock/AsyncLock.cs index 18d6a12..9089531 100644 --- a/AsyncLock/AsyncLock.cs +++ b/AsyncLock/AsyncLock.cs @@ -19,7 +19,7 @@ public class AsyncLock internal SemaphoreSlim _retry = new SemaphoreSlim(0, 1); private const long UnlockedId = 0x00; // "owning" task id when unlocked internal long _owningId = UnlockedId; - internal int _owningThreadId = (int) UnlockedId; + internal int _owningThreadId = (int)UnlockedId; private static long AsyncStackCounter = 0; // An AsyncLocal is not really the task-based equivalent to a ThreadLocal, in that // it does not track the async flow (as the documentation describes) but rather it is @@ -93,7 +93,8 @@ internal async Task ObtainLockAsync(CancellationToken cancellationT // In case of zero-timeout, don't even wait for protective lock contention if (timeout == TimeSpan.Zero) { - _parent._reentrancy.Wait(timeout); + //BUG? _parent._reentrancy.Wait(timeout); + if (!_parent._reentrancy.Wait(timeout)) return null; if (InnerTryEnter(synchronous: false)) { // Reset the owning thread id after all await calls have finished, otherwise we @@ -113,7 +114,8 @@ internal async Task ObtainLockAsync(CancellationToken cancellationT // We need to wait for someone to leave the lock before trying again. while (remainder > TimeSpan.Zero) { - await _parent._reentrancy.WaitAsync(remainder).ConfigureAwait(false); + //BUG? await _parent._reentrancy.WaitAsync(remainder).ConfigureAwait(false); + if (!await _parent._reentrancy.WaitAsync(remainder).ConfigureAwait(false)) return null; if (InnerTryEnter(synchronous: false)) { // Reset the owning thread id after all await calls have finished, otherwise we @@ -122,12 +124,14 @@ internal async Task ObtainLockAsync(CancellationToken cancellationT _parent._reentrancy.Release(); return this; } - _parent._reentrancy.Release(); + //BUG? _parent._reentrancy.Release(); now = DateTimeOffset.UtcNow; remainder -= now - last; last = now; - if (remainder < TimeSpan.Zero) + //BUG? if (remainder < TimeSpan.Zero) + // <= is correct, cause the loop invariant is remainder > TimeSpan.Zero, and the need to release reentrnacy + if (remainder <= TimeSpan.Zero) { _parent._reentrancy.Release(); return null; @@ -202,7 +206,8 @@ internal IDisposable ObtainLock(CancellationToken cancellationToken = default) // In case of zero-timeout, don't even wait for protective lock contention if (timeout == TimeSpan.Zero) { - _parent._reentrancy.Wait(timeout); + //BUG? _parent._reentrancy.Wait(timeout); + if (!_parent._reentrancy.Wait(timeout)) return null; if (InnerTryEnter(synchronous: true)) { _parent._reentrancy.Release(); @@ -219,7 +224,8 @@ internal IDisposable ObtainLock(CancellationToken cancellationToken = default) // We need to wait for someone to leave the lock before trying again. while (remainder > TimeSpan.Zero) { - _parent._reentrancy.Wait(remainder); + //BUG? _parent._reentrancy.Wait(remainder); + if (!_parent._reentrancy.Wait(remainder)) return null; if (InnerTryEnter(synchronous: true)) { _parent._reentrancy.Release(); @@ -498,5 +504,34 @@ public bool TryLock(Action callback, TimeSpan timeout) } return true; } + + public Task LockAsync(TimeSpan timeout) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + _asyncId.Value = Interlocked.Increment(ref AsyncLock.AsyncStackCounter); + + return @lock.TryObtainLockAsync(timeout) + .ContinueWith(state => + { + if (state.Exception is AggregateException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException!).Throw(); + } + var disposableLock = state.Result; + if (disposableLock is null) throw new TimeoutException("LockAsync timed out."); + return disposableLock; + }); + } + + public IDisposable Lock(TimeSpan timeout) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + // Increment the async stack counter to prevent a child task from getting + // the lock at the same time as a child thread. + _asyncId.Value = Interlocked.Increment(ref AsyncLock.AsyncStackCounter); + var lockDisposable = @lock.TryObtainLock(timeout); + if (lockDisposable is null) throw new TimeoutException("TryLock timed out."); + return lockDisposable; + } } } diff --git a/AsyncLock/AsyncLock.csproj b/AsyncLock/AsyncLock.csproj index 66bc0f2..60d4c31 100644 --- a/AsyncLock/AsyncLock.csproj +++ b/AsyncLock/AsyncLock.csproj @@ -1,7 +1,8 @@ - + - netstandard1.3;netstandard2.1 + netstandard1.3;netstandard2.0;netstandard2.1 + NeoSmart.AsyncLock NeoSmart.AsyncLock NeoSmart.AsyncLock True @@ -15,7 +16,7 @@ https://neosmart.net/blog/2017/asynclock-an-asyncawait-friendly-locking-library-for-c-and-net/ https://github.com/neosmart/AsyncLock git - asynclock, async await, async, await, lock, synchronization + asynclock, async await, async, await, lock, synchronization, mutex, async mutex 3.2: New TryLock() and TryLockAsync() methods, CancellationToken support for synchronous locking routines. @@ -43,4 +44,9 @@ + + + + + diff --git a/AsyncLock/AsyncMutexLock.cs b/AsyncLock/AsyncMutexLock.cs new file mode 100644 index 0000000..75409bf --- /dev/null +++ b/AsyncLock/AsyncMutexLock.cs @@ -0,0 +1,1012 @@ +#if !NETSTANDARD1_3 +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace NeoSmart.AsyncLock; + +public enum MutexScope { Machine, User } + +public class AsyncMutexLock: IDisposable +{ + private SemaphoreSlim _reentrancy = new SemaphoreSlim(1, 1); + private int _reentrances = 0; + // We are using this SemaphoreSlim like a posix condition variable. + // We only want to wake waiters, one or more of whom will try to obtain + // a different lock to do their thing. So long as we can guarantee no + // wakes are missed, the number of awakees is not important. + // Ideally, this would be "friend" for access only from InnerLock, but + // whatever. + internal SemaphoreSlim _retry = new SemaphoreSlim(0, 1); + private const long UnlockedId = 0x00; // "owning" task id when unlocked + internal long _owningId = UnlockedId; + internal int _owningThreadId = (int)UnlockedId; + private static long AsyncStackCounter = 0; + public string FileName; + // An AsyncLocal is not really the task-based equivalent to a ThreadLocal, in that + // it does not track the async flow (as the documentation describes) but rather it is + // associated with a stack snapshot. Mutation of the AsyncLocal in an await call does + // not change the value observed by the parent when the call returns, so if you want to + // use it as a persistent async flow identifier, the value needs to be set at the outer- + // most level and never touched internally. + private static readonly AsyncLocal _asyncId = new AsyncLocal(); + private static long AsyncId => _asyncId.Value; + +#if NETSTANDARD1_3 + private static int ThreadCounter = 0x00; + private static ThreadLocal LocalThreadId = new ThreadLocal(() => ++ThreadCounter); + private static int ThreadId => LocalThreadId.Value; +#else + private static int ThreadId => Thread.CurrentThread.ManagedThreadId; +#endif + public static bool IsWindows => RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows); + public static bool IsLinux => RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux); + public static bool IsMac => RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX); + + public AsyncMutexLock(string name, MutexScope scope = MutexScope.Machine) + { + this.FileName = LockFileName(name, scope); + } + +#if !DEBUG + readonly +#endif + struct InnerLock : IDisposable + { + private readonly AsyncMutexLock _parent; + private readonly long _oldId; + private readonly int _oldThreadId; +#if DEBUG + private bool _disposed; +#endif + + internal InnerLock(AsyncMutexLock parent, long oldId, int oldThreadId) + { + _parent = parent; + _oldId = oldId; + _oldThreadId = oldThreadId; +#if DEBUG + _disposed = false; +#endif + } + + internal async Task ObtainLockAsync(CancellationToken cancellationToken = default) + { + while (true) + { + await _parent._reentrancy.WaitAsync(cancellationToken).ConfigureAwait(false); + if (InnerTryEnter(synchronous: false)) + { + break; + } + // We need to wait for someone to leave the lock before trying again. + // We need to "atomically" obtain _retry and release _reentrancy, but there + // is no equivalent to a condition variable. Instead, we call *but don't await* + // _retry.WaitAsync(), then release the reentrancy lock, *then* await the saved task. + var waitTask = _parent._retry.WaitAsync(cancellationToken).ConfigureAwait(false); + _parent._reentrancy.Release(); + await waitTask; + } + + var oldThreadId = _parent._owningThreadId; + _parent._owningThreadId = ThreadId; + + if (_parent._reentrances == 1) // Poll for mutex + { + _parent._reentrancy.Release(); + + while (true) + { + await _parent._reentrancy.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (TryMutexAcquireOnce()) + { + break; + } + } + catch + { + _parent._owningThreadId = oldThreadId; + // we need to release retry here, since changing owningThreadId before we actually aquire the lock + // might cause other threads to wait on retry. It does not hurt if we release retry too much. + if (_parent._retry.CurrentCount == 0) + { + _parent._retry.Release(); + } + _parent._reentrancy.Release(); + throw; + } + _parent._reentrancy.Release(); + await Task.Delay(pollMilliseconds, cancellationToken).ConfigureAwait(false); + } + } + + _parent._owningThreadId = ThreadId; + _parent._reentrancy.Release(); + return this; + } + + internal async Task TryObtainLockAsync(TimeSpan timeout) + { + // In case of zero-timeout, don't even wait for protective lock contention + if (timeout == TimeSpan.Zero) + { + if (!_parent._reentrancy.Wait(timeout)) return null; + if (InnerTryEnter(synchronous: false)) + { + // Reset the owning thread id after all await calls have finished, otherwise we + // could be resumed on a different thread and set an incorrect value. + try + { + if (_parent._reentrances != 1 || TryMutexAcquireOnce()) + { + _parent._owningThreadId = ThreadId; + _parent._reentrancy.Release(); + return this; + } + } + catch (Exception) + { + _parent._reentrancy.Release(); + throw; + } + } + _parent._reentrancy.Release(); + return null; + } + + var now = DateTimeOffset.UtcNow; + var last = now; + var remainder = timeout; + + // We need to wait for someone to leave the lock before trying again. + while (remainder > TimeSpan.Zero) + { + if (!await _parent._reentrancy.WaitAsync(remainder).ConfigureAwait(false)) return null; + if (InnerTryEnter(synchronous: false)) + { + var oldThreadId = _parent._owningThreadId; + _parent._owningThreadId = ThreadId; + + if (_parent._reentrances == 1) // Poll for mutex + { + _parent._reentrancy.Release(); + + Task? reentrancyLock = null; + while (remainder > TimeSpan.Zero) + { + if (!await (reentrancyLock = _parent._reentrancy.WaitAsync(remainder)).ConfigureAwait(false)) + { + await _parent._reentrancy.WaitAsync(); + _parent._owningThreadId = oldThreadId; + // we need to release retry here, since changing owningThreadId before we actually aquire the lock + // might cause other threads to wait on retry. It does not hurt if we release retry too much. + if (_parent._retry.CurrentCount == 0) + { + _parent._retry.Release(); + } + _parent._reentrancy.Release(); + return null; + } + try + { + if (TryMutexAcquireOnce()) + { + break; + } + } + catch + { + _parent._owningThreadId = oldThreadId; + // we need to release retry here, since changing owningThreadId before we actually aquire the lock + // might cause other threads to wait on retry. It does not hurt if we release retry too much. + if (_parent._retry.CurrentCount == 0) + { + _parent._retry.Release(); + } + _parent._reentrancy.Release(); + throw; + } + + _parent._reentrancy.Release(); + + now = DateTimeOffset.UtcNow; + remainder -= now - last; + last = now; + var poll = TimeSpan.FromTicks(Math.Min(pollTimeSpan.Ticks, remainder.Ticks)); + if (poll > TimeSpan.Zero) Thread.Sleep(poll); + + now = DateTimeOffset.UtcNow; + remainder -= now - last; + last = now; + } + } + + if (remainder > TimeSpan.Zero) + { + // Reset the owning thread id after all await calls have finished, otherwise we + // could be resumed on a different thread and set an incorrect value. + _parent._owningThreadId = ThreadId; + _parent._reentrancy.Release(); + return this; + } + else + { + _parent._owningThreadId = oldThreadId; + // we need to release retry here, since changing owningThreadId before we actually aquire the lock + // might cause other threads to wait on retry. It does not hurt if we release retry too much. + if (_parent._retry.CurrentCount == 0) + { + _parent._retry.Release(); + } + _parent._reentrancy.Release(); + return null; + } + } + + now = DateTimeOffset.UtcNow; + remainder -= now - last; + last = now; + if (remainder <= TimeSpan.Zero) + { + _parent._reentrancy.Release(); + return null; + } + + var waitTask = _parent._retry.WaitAsync(remainder).ConfigureAwait(false); + _parent._reentrancy.Release(); + if (!await waitTask) + { + return null; + } + + now = DateTimeOffset.UtcNow; + remainder -= now - last; + last = now; + } + + return null; + } + + internal async Task TryObtainLockAsync(CancellationToken cancellationToken = default) + { + try + { + while (true) + { + await _parent._reentrancy.WaitAsync(cancellationToken).ConfigureAwait(false); + if (InnerTryEnter(synchronous: false)) + { + break; + } + // We need to wait for someone to leave the lock before trying again. + var waitTask = _parent._retry.WaitAsync(cancellationToken).ConfigureAwait(false); + _parent._reentrancy.Release(); + await waitTask; + } + } + catch (OperationCanceledException) + { + return null; + } + + var oldThreadId = _parent._owningThreadId; + _parent._owningThreadId = ThreadId; + + if (_parent._reentrances == 1) // Poll for mutex + { + _parent._reentrancy.Release(); + + try + { + while (true) + { + await _parent._reentrancy.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (TryMutexAcquireOnce()) break; + } + catch + { + _parent._owningThreadId = oldThreadId; + // we need to release retry here, since changing owningThreadId before we actually aquire the lock + // might cause other threads to wait on retry. It does not hurt if we release retry too much. + if (_parent._retry.CurrentCount == 0) + { + _parent._retry.Release(); + } + _parent._reentrancy.Release(); + throw; + } + _parent._reentrancy.Release(); + await Task.Delay(pollMilliseconds, cancellationToken).ConfigureAwait(false); ; + } + } + catch + { + return null; + } + } + _parent._owningThreadId = ThreadId; + _parent._reentrancy.Release(); + + return this; + } + + internal IDisposable ObtainLock(CancellationToken cancellationToken = default) + { + while (true) + { + _parent._reentrancy.Wait(cancellationToken); + if (InnerTryEnter(synchronous: true)) + { + break; + } + // We need to wait for someone to leave the lock before trying again. + + _parent._reentrancy.Release(); + _parent._retry.Wait(cancellationToken); + } + + if (_parent._reentrances == 1) // Poll for mutex + { + _parent._reentrancy.Release(); + + while (true) + { + _parent._reentrancy.Wait(cancellationToken); + try + { + if (TryMutexAcquireOnce()) + { + break; + } + } + catch + { + _parent._reentrancy.Release(); + throw; + } + _parent._reentrancy.Release(); + + Thread.Sleep(pollMilliseconds); + cancellationToken.ThrowIfCancellationRequested(); + } + } + + _parent._reentrancy.Release(); + return this; + } + + internal IDisposable? TryObtainLock(TimeSpan timeout) + { + // In case of zero-timeout, don't even wait for protective lock contention + if (timeout == TimeSpan.Zero) + { + if (!_parent._reentrancy.Wait(timeout)) return null; + if (InnerTryEnter(synchronous: true)) + { + if (_parent._reentrances != 1 || TryMutexAcquireOnce()) + { + _parent._reentrancy.Release(); + return this; + } + } + _parent._reentrancy.Release(); + return null; + } + + var now = DateTimeOffset.UtcNow; + var last = now; + var remainder = timeout; + + // We need to wait for someone to leave the lock before trying again. + while (remainder > TimeSpan.Zero) + { + if (!_parent._reentrancy.Wait(remainder)) return null; + + now = DateTimeOffset.UtcNow; + remainder -= now - last; + last = now; + + if (InnerTryEnter(synchronous: true)) + { + if (_parent._reentrances == 1) // Poll for mutex + { + _parent._reentrancy.Release(); + + while (remainder > TimeSpan.Zero) + { + if (!_parent._reentrancy.Wait(remainder)) return null; + try + { + if (TryMutexAcquireOnce()) + { + break; + } + } + catch + { + _parent._reentrancy.Release(); + throw; + } + + _parent._reentrancy.Release(); + + now = DateTimeOffset.UtcNow; + remainder -= now - last; + last = now; + var poll = TimeSpan.FromTicks(Math.Min(pollTimeSpan.Ticks, remainder.Ticks)); + if (poll > TimeSpan.Zero) Thread.Sleep(poll); + + now = DateTimeOffset.UtcNow; + remainder -= now - last; + last = now; + } + + if (remainder <= TimeSpan.Zero) return null; + } + + _parent._reentrancy.Release(); + return this; + } + + now = DateTimeOffset.UtcNow; + remainder -= now - last; + last = now; + + _parent._reentrancy.Release(); + if (!_parent._retry.Wait(remainder)) + { + return null; + } + + now = DateTimeOffset.UtcNow; + remainder -= now - last; + last = now; + } + + return null; + } + + private bool InnerTryEnter(bool synchronous) + { + if (synchronous) + { + if (_parent._owningThreadId == UnlockedId) + { + _parent._owningThreadId = ThreadId; + } + else if (_parent._owningThreadId != ThreadId) + { + // Another thread currently owns the lock + return false; + } + _parent._owningId = AsyncMutexLock.AsyncId; + } + else + { + if (_parent._owningId == UnlockedId) + { + _parent._owningId = AsyncMutexLock.AsyncId; + } + else if (_parent._owningId != _oldId) + { + // Another thread currently owns the lock + return false; + } + else + { + // Nested re-entrance + _parent._owningId = AsyncId; + } + } + + // We can go in + _parent._reentrances += 1; + return true; + } + + private bool TryMutexAcquireOnce(CancellationToken cancel = default) => _parent.TryMutexAcquireOnce(cancel); + + public void Dispose() + { +#if DEBUG + Debug.Assert(!_disposed); + _disposed = true; +#endif + var @this = this; + var oldId = this._oldId; + var oldThreadId = this._oldThreadId; + @this._parent._reentrancy.Wait(); + try + { + @this._parent._reentrances -= 1; + @this._parent._owningId = oldId; + @this._parent._owningThreadId = oldThreadId; + if (@this._parent._reentrances == 0) + { + // The owning thread is always the same so long as we + // are in a nested stack call. We reset the owning id + // only when the lock is fully unlocked. + @this._parent._owningId = UnlockedId; + @this._parent._owningThreadId = (int)UnlockedId; + + _parent.MutexRelease(); + } + // We can't place this within the _reentrances == 0 block above because we might + // still need to notify a parallel reentrant task to wake. I think. + // This should not be a race condition since we only wait on _retry with _reentrancy locked, + // then release _reentrancy so the Dispose() call can obtain it to signal _retry in a big hack. + if (@this._parent._retry.CurrentCount == 0) + { + @this._parent._retry.Release(); + } + } + finally + { + @this._parent._reentrancy.Release(); + } + } + } + + // Mutex code + FileStream? LockFileStream; + int FlockFile = -1; + + private const int LOCK_EX = 2; + private const int LOCK_NB = 4; + private const int LOCK_UN = 8; + private const int O_CREAT = 0x40; + private const int O_RDWR = 0x2; + + const int pollMilliseconds = 100; + static readonly TimeSpan pollTimeSpan = TimeSpan.FromMilliseconds(pollMilliseconds); + + [DllImport("libc", SetLastError = true)] + private static extern int flock(int fd, int operation); + [DllImport("libc", SetLastError = true)] + private static extern int open(string pathname, int flags, uint mode); + + [DllImport("libc", SetLastError = true)] + private static extern int close(int fd); + + const int MaxUnauthorizedAccessExceptionRetries = 1600; + private static bool CanRetryTransientFileSystemError(ref int retryCount) + { + if (retryCount >= MaxUnauthorizedAccessExceptionRetries) { return false; } + + ++retryCount; + + return true; + } + private void EnsureDirectoryExists() + { + var retryCount = 0; + + var directory = Path.GetDirectoryName(FileName); + while (true) + { + try + { + Directory.CreateDirectory(directory); + return; + } + catch (Exception ex) + { + // This can indicate either a transient failure during concurrent creation/deletion or a permissions issue. + // If we encounter it, assume it is transient unless it persists. + // For a long time, I just checked for UnauthorizedAccessException here. However, recent tests on Linux have + // shown that in race conditions we can see IOException as well, presumably because there is some period during + // directory creation where it presents as a file. + if (ex is UnauthorizedAccessException or IOException + && CanRetryTransientFileSystemError(ref retryCount)) + { + continue; + } + + throw new InvalidOperationException($"Failed to ensure that lock file directory {directory} exists", ex); + } + } + } + + internal bool TryMutexAcquireOnce(CancellationToken cancel = default) + { + if (IsWindows) + { + int retryCount = 0; + + while (true) + { + cancel.ThrowIfCancellationRequested(); + + FileStream lockFileStream; + try + { + // key arguments: + // OpenOrCreate to be robust to the file existing or not + lockFileStream = new FileStream(FileName, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, bufferSize: 1); + try + { + lockFileStream.WriteByte(0); + lockFileStream.Flush(); + lockFileStream.Lock(0, 1); + //AppDomain.CurrentDomain.ProcessExit += MutexRelease; + } + catch (UnauthorizedAccessException ex) + { + return false; + } + catch (IOException ex) + { + return false; + } + } + catch (DirectoryNotFoundException) + { + // this should almost never happen because we just created the directory but in a race condition it could. Just retry + continue; + } + catch (UnauthorizedAccessException) + { + // This can happen in few cases: + + // The path is already directory, so we'll never be able to open a handle of it as a file + if (Directory.Exists(FileName)) + { + throw new InvalidOperationException($"Failed to create lock file '{FileName}' because it is already the name of a directory"); + } + + // The file exists and is read-only + FileAttributes attributes; + try { attributes = File.GetAttributes(FileName); } + catch { attributes = FileAttributes.Normal; } // e. g. could fail with FileNotFoundException + if (attributes.HasFlag(FileAttributes.ReadOnly)) + { + // We could support this by eschewing DeleteOnClose once we detect that a file is read-only, + // but absent interest or a use-case we'll just throw for now + throw new NotSupportedException($"Locking on read-only file '{FileName}' is not supported"); + } + + // Frustratingly, this error can be thrown transiently due to concurrent creation/deletion. Initially assume + // that it is transient and just retry + if (CanRetryTransientFileSystemError(ref retryCount)) + { + continue; + } + + // If we get here, we've exhausted our retries: assume that it is a legitimate permissions issue + throw; + } + // this should never happen because we validate. However if it does (e. g. due to some system configuration change?), throw so that + // this doesn't end up in the IOException block (PathTooLongException is IOException) + catch (PathTooLongException) { throw; } + catch (IOException) + { + // the hope is that if we get here the only failure reason would be that the file is locked + return false; + } + + LockFileStream = lockFileStream; +#if !NETSTANDARD1_3 + AppDomain.CurrentDomain.ProcessExit += MutexRelease; +#endif + return true; + } + } + else // Unix, use flock + { + int file = -1; + try + { + EnsureDirectoryExists(); + + file = open(FileName, O_CREAT | O_RDWR, 0x1A4); // 0644 + + if (file == -1) return false; + + if (flock(file, LOCK_EX | LOCK_NB) == 0) + { + this.FlockFile = file; + return true; + } + else + { + var errno = Marshal.GetLastWin32Error(); + } + + return false; + } + finally + { + if (file != -1 && this.FlockFile != file) close(file); + } + } + } + + internal void MutexRelease(object? sender = null, EventArgs? args = default) + { + if (IsWindows) + { + var file = Interlocked.Exchange(ref this.LockFileStream!, null); + if (file != null) + { + try + { + file.Unlock(0, 1); + file.Close(); + file.Dispose(); + AppDomain.CurrentDomain.ProcessExit -= MutexRelease; + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } + } + } + else + { + if (FlockFile != -1) + { + flock(FlockFile, LOCK_UN); + close(FlockFile); + + FlockFile = -1; + } + } + } + + public void Dispose() => MutexRelease(); + ~AsyncMutexLock() => Dispose(); + + // Make sure InnerLock.LockAsync() does not use await, because an async function triggers a snapshot of + // the AsyncLocal value. + public Task LockAsync(CancellationToken cancellationToken = default) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + _asyncId.Value = Interlocked.Increment(ref AsyncMutexLock.AsyncStackCounter); + return @lock.ObtainLockAsync(cancellationToken); + } + + // Make sure InnerLock.LockAsync() does not use await, because an async function triggers a snapshot of + // the AsyncLocal value. + public Task TryLockAsync(Action callback, TimeSpan timeout) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + _asyncId.Value = Interlocked.Increment(ref AsyncMutexLock.AsyncStackCounter); + + return @lock.TryObtainLockAsync(timeout) + .ContinueWith(state => + { + if (state.Exception is AggregateException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException!).Throw(); + } + var disposableLock = state.Result; + if (disposableLock is null) + { + return false; + } + + try + { + callback(); + } + finally + { + disposableLock.Dispose(); + } + return true; + }); + } + + // Make sure InnerLock.LockAsync() does not use await, because an async function triggers a snapshot of + // the AsyncLocal value. + public Task TryLockAsync(Func callback, TimeSpan timeout) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + _asyncId.Value = Interlocked.Increment(ref AsyncMutexLock.AsyncStackCounter); + + return @lock.TryObtainLockAsync(timeout) + .ContinueWith(state => + { + if (state.Exception is AggregateException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException!).Throw(); + } + var disposableLock = state.Result; + if (disposableLock is null) + { + return Task.FromResult(false); + } + + return callback() + .ContinueWith(result => + { + disposableLock.Dispose(); + + if (result.Exception is AggregateException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException!).Throw(); + } + + return true; + }, TaskScheduler.Default); + }, TaskScheduler.Default).Unwrap(); + } + + // Make sure InnerLock.TryLockAsync() does not use await, because an async function triggers a snapshot of + // the AsyncLocal value. + public Task TryLockAsync(Action callback, CancellationToken cancellationToken) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + _asyncId.Value = Interlocked.Increment(ref AsyncMutexLock.AsyncStackCounter); + + return @lock.TryObtainLockAsync(cancellationToken) + .ContinueWith(state => + { + if (state.Exception is AggregateException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException!).Throw(); + } + var disposableLock = state.Result; + if (disposableLock is null) + { + return false; + } + + try + { + callback(); + } + finally + { + disposableLock.Dispose(); + } + return true; + }, TaskScheduler.Default); + } + + // Make sure InnerLock.LockAsync() does not use await, because an async function triggers a snapshot of + // the AsyncLocal value. + public Task TryLockAsync(Func callback, CancellationToken cancellationToken) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + _asyncId.Value = Interlocked.Increment(ref AsyncMutexLock.AsyncStackCounter); + + return @lock.TryObtainLockAsync(cancellationToken) + .ContinueWith(state => + { + if (state.Exception is AggregateException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException!).Throw(); + } + var disposableLock = state.Result; + if (disposableLock is null) + { + return Task.FromResult(false); + } + + return callback() + .ContinueWith(result => + { + disposableLock.Dispose(); + + if (result.Exception is AggregateException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException!).Throw(); + } + + return true; + }, TaskScheduler.Default); + }, TaskScheduler.Default).Unwrap(); + } + + public IDisposable Lock(CancellationToken cancellationToken = default) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + // Increment the async stack counter to prevent a child task from getting + // the lock at the same time as a child thread. + _asyncId.Value = Interlocked.Increment(ref AsyncMutexLock.AsyncStackCounter); + return @lock.ObtainLock(cancellationToken); + } + + public bool TryLock(Action callback, TimeSpan timeout) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + // Increment the async stack counter to prevent a child task from getting + // the lock at the same time as a child thread. + _asyncId.Value = Interlocked.Increment(ref AsyncMutexLock.AsyncStackCounter); + var lockDisposable = @lock.TryObtainLock(timeout); + if (lockDisposable is null) + { + return false; + } + + // Execute the callback then release the lock + try + { + callback(); + } + finally + { + lockDisposable.Dispose(); + } + return true; + } + + public Task LockAsync(TimeSpan timeout) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + _asyncId.Value = Interlocked.Increment(ref AsyncMutexLock.AsyncStackCounter); + + return @lock.TryObtainLockAsync(timeout) + .ContinueWith(state => + { + if (state.Exception is AggregateException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException!).Throw(); + } + var disposableLock = state.Result; + if (disposableLock is null) throw new TimeoutException("LockAsync timed out."); + return disposableLock; + }); + } + + public IDisposable Lock(TimeSpan timeout) + { + var @lock = new InnerLock(this, _asyncId.Value, ThreadId); + // Increment the async stack counter to prevent a child task from getting + // the lock at the same time as a child thread. + _asyncId.Value = Interlocked.Increment(ref AsyncMutexLock.AsyncStackCounter); + var lockDisposable = @lock.TryObtainLock(timeout); + if (lockDisposable is null) throw new TimeoutException("TryLock timed out."); + return lockDisposable; + } + + [DllImport("libc")] + public static extern uint getuid(); + + public static bool UnixIsRoot => getuid() == 0; + + public static string LockFileName(string name, MutexScope scope = MutexScope.Machine) + { + if (Path.IsPathRooted(name)) return name; +#if NETSTANDARD1_3 + throw new NotSupportedException("Only full filenames are supported as name on netstandard1.3"); +#else + if (name.StartsWith("Local\\", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AsyncMutexLock does not support local mutexes"); + //if (IsWindows) return $"Global\\{name.Replace('/', '_')}"; + + var pattern = @"[ $%&""'=?!^_:\t\r\n\\/]"; + pattern = Path.DirectorySeparatorChar == '\\' ? + @"[ $%&""'=?!^_:\t\r\n/]" : + pattern.Replace(Path.DirectorySeparatorChar.ToString(), ""); + + name = Regex.Replace(name, pattern, "-"); + + if (scope == MutexScope.User) + { + var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var lockpath = Path.Combine(root, "asyncmutexlock"); + var lockfile = Path.Combine(lockpath, $"{name}.lock"); + Directory.CreateDirectory(lockpath); + return lockfile; + } + else + { + var root = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + var lockpath = Path.Combine(root, "asyncmutexlock"); + var lockfile = Path.Combine(lockpath, $"{name}.lock"); + Directory.CreateDirectory(lockpath); + return lockfile; + } +#endif + } +} +#endif \ No newline at end of file diff --git a/README.md b/README.md index 2c773bc..5b1c3cb 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,8 @@ private class AsyncLockTest } } ``` + +## Async Global Mutex +The class `AsyncMutexLock` works exactly like `AsyncLock`, except that it uses a cross process machine wide file lock. +It can be used as an async friendly version of a global Mutex. For this, you create a named lock by calling the constructor +`AsyncMutexLock("MyMutexName")`. You can then synchronize proecesses using this global lock. \ No newline at end of file diff --git a/UnitTests/AsyncMutexLock/AsyncIdTests.cs b/UnitTests/AsyncMutexLock/AsyncIdTests.cs new file mode 100644 index 0000000..9cdd699 --- /dev/null +++ b/UnitTests/AsyncMutexLock/AsyncIdTests.cs @@ -0,0 +1,82 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AsyncLockTests; + +#if false +namespace AsyncLockTests.Mutex; + +[TestClass] +public class AsyncIdTests +{ + [TestMethod] + public void TaskIdUniqueness() + { + var testCount = 100; + var countdown = new CountdownEvent(testCount); + var failure = new ManualResetEventSlim(false); + var threadIds = new SortedSet(); + var abort = new SemaphoreSlim(0, 1); + + for (int i = 0; i < testCount; ++i) + { + Task.Run(async () => + { + lock (threadIds) + { + if (!threadIds.Add(AsyncMutexLock.ThreadId)) + { + failure.Set(); + } + } + countdown.Signal(); + await abort.WaitAsync(); + }); + } + + if (WaitHandle.WaitAny(new[] { countdown.WaitHandle, failure.WaitHandle }) == 1) + { + Assert.Fail("A duplicate thread id was found!"); + } + + abort.Release(); + } + + public void ThreadIdUniqueness() + { + var testCount = 100; + var countdown = new CountdownEvent(testCount); + var failure = new ManualResetEventSlim(false); + var threadIds = new SortedSet(); + var abort = new SemaphoreSlim(0, 1); + + for (int i = 0; i < testCount; ++i) + { + Task.Run(async () => + { + lock (threadIds) + { + if (!threadIds.Add(AsyncMutexLock.ThreadId)) + { + failure.Set(); + } + } + countdown.Signal(); + await abort.WaitAsync(); + }); + } + + if (WaitHandle.WaitAny(new[] { countdown.WaitHandle, failure.WaitHandle }) == 1) + { + Assert.Fail("A duplicate thread id was found!"); + } + + abort.Release(); + } +} +#endif diff --git a/UnitTests/AsyncMutexLock/AsyncSpawn.cs b/UnitTests/AsyncMutexLock/AsyncSpawn.cs new file mode 100644 index 0000000..05dd831 --- /dev/null +++ b/UnitTests/AsyncMutexLock/AsyncSpawn.cs @@ -0,0 +1,78 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AsyncLockTests; + +namespace AsyncLockTests.Mutex; + +/// +/// Creates multiple independent tasks, each with its own lock, and runs them +/// all in parallel. There should be no contention for the lock between the +/// parallelly executed tasks, but each task then recursively obtains what +/// should be the same lock - which should again be contention-free - after +/// an await point that may or may not resume on the same actual thread the +/// previous lock was obtained with. +/// +[TestClass] +public class AsyncSpawn +{ + public readonly struct NullDisposable : IDisposable + { + public void Dispose() { } + } + + public async Task AsyncExecution(bool locked) + { + var count = 0; + var tasks = new List(70); + var asyncLock = new AsyncMutexLock(nameof(AsyncSpawn)); + var rng = new Random(); + + { + using var l = locked ? await asyncLock.LockAsync() : new NullDisposable(); + + for (int i = 0; i < 10; ++i) + { + var task = Task.Run(async () => + { + using (await asyncLock.LockAsync()) + { + Assert.AreEqual(1, Interlocked.Increment(ref count)); + await Task.Yield(); + Assert.AreEqual(1, count); + await Task.Delay(rng.Next(1, 10) * 10); + using (await asyncLock.LockAsync()) + { + await Task.Delay(rng.Next(1, 10) * 10); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); + } + + Assert.AreEqual(0, count); + } + + }); + tasks.Add(task); + } + } + + await Task.WhenAll(tasks); + + Assert.AreEqual(0, count); + } + + [TestMethod] + public async Task AsyncExecutionLocked() + { + await AsyncExecution(true); + } + + [TestMethod] + public async Task AsyncExecutionUnlocked() + { + await AsyncExecution(false); + } +} diff --git a/UnitTests/AsyncMutexLock/CancellationTests.cs b/UnitTests/AsyncMutexLock/CancellationTests.cs new file mode 100644 index 0000000..493e0e0 --- /dev/null +++ b/UnitTests/AsyncMutexLock/CancellationTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using AsyncLockTests; + +namespace AsyncLockTests.Mutex; + +[TestClass] +public class CancellationTests +{ + [TestMethod] + public void CancellingWait() + { + var @lock = new AsyncMutexLock(nameof(CancellationTests)); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + Task.Run(async () => + { + await @lock.LockAsync(cts.Token); + }).Wait(); + Assert.ThrowsAsync(async () => + { + using (await @lock.LockAsync(cts.Token)) + Assert.Fail("should never reach here if cancellation works properly"); + }).Wait(); + + } + + [TestMethod] + public void CancellingWaitSync() + { + var asyncLock = new AsyncLock(); + var cts = new CancellationTokenSource(250); + var delayStarted = new ManualResetEventSlim(false); + var waiter1Finished = new SemaphoreSlim(0, 1); + + new Thread(() => + { + using (asyncLock.Lock(cts.Token)) + { + // hold the lock until our later attempt is called + delayStarted.Set(); + waiter1Finished.Wait(); + } + }).Start(); + + Assert.Throws(() => + { + delayStarted.Wait(); + using (asyncLock.Lock(cts.Token)) + { + Assert.Fail("should never reach here if cancellation works properly."); + } + }); + waiter1Finished.Release(1); + + // We should still be able to obtain a lock afterward to make sure resources were reobtained + var newCts = new CancellationTokenSource(2000); + using (asyncLock.Lock(newCts.Token)) + { + // reaching this line means the test passed + // a OperationCanceledException will indicate test failure + } + } +} diff --git a/UnitTests/AsyncMutexLock/MixedSyncAsync.cs b/UnitTests/AsyncMutexLock/MixedSyncAsync.cs new file mode 100644 index 0000000..7369f6c --- /dev/null +++ b/UnitTests/AsyncMutexLock/MixedSyncAsync.cs @@ -0,0 +1,109 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AsyncLockTests; +using System.Diagnostics; + +namespace AsyncLockTests.Mutex; + +/// +/// Creates multiple indepndent tasks, each with its own lock, and runs them +/// all in parallel. There should be no contention for the lock between the +/// parallelly executed tasks, but each task then recursively obtains what +/// should be the same lock - which should again be contention-free - after +/// an await point that may or may not resume on the same actual thread the +/// previous lock was obtained with. +/// +[TestClass] +public class MixedSyncAsync +{ + [TestMethod] + public async Task MixedSyncAsyncExecution() + { + int count = 0, nsync = 0, nasync = 0; + var threads = new List(10); + var tasks = new List(10); + var asyncLock = new AsyncMutexLock(nameof(MixedSyncAsync)); + var rng = new Random(); + var start = DateTime.UtcNow; + + { + //using var l = asyncLock.Lock(); + for (int i = 0; i < 10; ++i) + { + var thread = new Thread(() => + { + var id = Interlocked.Increment(ref nsync); + Debug.WriteLine($"Sync Thread: {id}; Sync Thread ID: {Thread.CurrentThread.ManagedThreadId}"); + var t0 = DateTime.UtcNow; + using (asyncLock.Lock()) + { + var time = DateTime.UtcNow - t0; + Debug.WriteLine($"SyncLock{id}.1: {time}"); + + Assert.AreEqual(1, Interlocked.Increment(ref count)); + Thread.Sleep(rng.Next(1, 10) * 10); + t0 = DateTime.UtcNow; + using (asyncLock.Lock()) + { + time = DateTime.UtcNow - t0; + Debug.WriteLine($"SyncLock{id}.2: {time}"); + + Thread.Sleep(10); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); + } + + Assert.AreEqual(0, count); + } + + }); + thread.Start(); + threads.Add(thread); + } + + for (int i = 0; i < 10; ++i) + { + var task = Task.Run(async () => + { + var id = Interlocked.Increment(ref nasync); + Debug.WriteLine($"Async Thread: {id}; Async Thread ID: {Thread.CurrentThread.ManagedThreadId}"); + + var t0 = DateTime.UtcNow; + using (await asyncLock.LockAsync()) + { + var time = DateTime.UtcNow - t0; + Debug.WriteLine($"AsyncLock{id}.1: {time}"); + + Assert.AreEqual(1, Interlocked.Increment(ref count)); + Assert.AreEqual(1, count); + await Task.Delay(rng.Next(1, 10) * 10); + t0 = DateTime.UtcNow; + using (await asyncLock.LockAsync()) + { + time = DateTime.UtcNow - t0; + Debug.WriteLine($"AsyncLock{id}.2: {time}"); + await Task.Delay(10); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); + } + + Assert.AreEqual(0, count); + } + + }); + tasks.Add(task); + } + } + + await Task.WhenAll(tasks); + foreach (var thread in threads) + { + thread.Join(); + } + + Assert.AreEqual(0, count); + } +} diff --git a/UnitTests/AsyncMutexLock/MixedSyncAsyncTimed.cs b/UnitTests/AsyncMutexLock/MixedSyncAsyncTimed.cs new file mode 100644 index 0000000..abb7c93 --- /dev/null +++ b/UnitTests/AsyncMutexLock/MixedSyncAsyncTimed.cs @@ -0,0 +1,108 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AsyncLockTests; + +namespace AsyncLockTests.Mutex; + +/// +/// Creates multiple indepndent tasks, each with its own lock, and runs them +/// all in parallel. There should be no contention for the lock between the +/// parallelly executed tasks, but each task then recursively obtains what +/// should be the same lock - which should again be contention-free - after +/// an await point that may or may not resume on the same actual thread the +/// previous lock was obtained with. +/// +[TestClass] +public class MixedSyncAsyncTimed +{ + [TestMethod] + public async Task MixedSyncAsyncExecution() + { + var count = 0; + var threads = new List(10); + var tasks = new List(10); + var asyncLock = new AsyncMutexLock(nameof(MixedSyncAsyncTimed)); + var rng = new Random(); + + { + using var l = asyncLock.Lock(); + for (int i = 0; i < 10; ++i) + { + var thread = new Thread(() => + { + using (asyncLock.Lock()) + { + Assert.AreEqual(1, Interlocked.Increment(ref count)); + Thread.Sleep(rng.Next(1, 10) * 10); + using (asyncLock.Lock()) + { + Thread.Sleep(10); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); + } + + Assert.AreEqual(0, count); + } + + }); + thread.Start(); + threads.Add(thread); + } + + for (int i = 0; i < 10; ++i) + { + var captured = i; + var task = Task.Run(async () => + { + using (await asyncLock.LockAsync()) + { + Assert.AreEqual(1, Interlocked.Increment(ref count)); + Assert.AreEqual(1, count); + await Task.Delay(rng.Next(1, 10) * 10); + if (captured % 2 == 0) + { + using (await asyncLock.LockAsync()) + { + await Task.Yield(); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); + } + } + else + { + var executed = await asyncLock.TryLockAsync(async () => + { + // Throw in a recursive async lock invocation + bool nestedExecuted = await asyncLock.TryLockAsync(async () => + { + await Task.Yield(); + Interlocked.Increment(ref count); + }, TimeSpan.FromMilliseconds(1 /* guarantees no zero-ms optimizations */)); + Assert.IsTrue(nestedExecuted); + Interlocked.Decrement(ref count); + await Task.Yield(); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); + }, TimeSpan.FromMilliseconds(rng.Next(1, 10) * 10)); + Assert.IsTrue(executed, "TryLockAsync() did not end up executing!"); + } + + Assert.AreEqual(0, count); + } + + }); + tasks.Add(task); + } + } + + await Task.WhenAll(tasks); + foreach (var thread in threads) + { + thread.Join(); + } + + Assert.AreEqual(0, count); + } +} diff --git a/UnitTests/AsyncMutexLock/ParallelExecutionTests.cs b/UnitTests/AsyncMutexLock/ParallelExecutionTests.cs new file mode 100644 index 0000000..9abda7b --- /dev/null +++ b/UnitTests/AsyncMutexLock/ParallelExecutionTests.cs @@ -0,0 +1,43 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using System; +using System.Linq; +using System.Threading.Tasks; +using AsyncLockTests; + +namespace AsyncLockTests.Mutex; + +/// +/// Creates multiple indepndent tasks, each with its own lock, and runs them +/// all in parallel. There should be no contention for the lock between the +/// parallelly executed tasks, but each task then recursively obtains what +/// should be the same lock - which should again be contention-free - after +/// an await point that may or may not resume on the same actual thread the +/// previous lock was obtained with. +/// +[TestClass] +public class ParallelExecutionTests +{ + [TestMethod] + public async Task ParallelExecution() + { + await Task.WhenAll(Enumerable.Range(0, 3).Select(SomeMethod)); + } + + private static async Task SomeMethod(int i) + { + var asyncLock = new AsyncMutexLock(nameof(ParallelExecution)); + System.Diagnostics.Debug.WriteLine($"Outside {i}"); + await Task.Delay(100); + using (await asyncLock.LockAsync()) + { + System.Diagnostics.Debug.WriteLine($"Lock1 {i}"); + await Task.Delay(100); + using (await asyncLock.LockAsync()) + { + System.Diagnostics.Debug.WriteLine($"Lock2 {i}"); + await Task.Delay(100); + } + } + } +} diff --git a/UnitTests/AsyncMutexLock/ReentracePermittedTests.cs b/UnitTests/AsyncMutexLock/ReentracePermittedTests.cs new file mode 100644 index 0000000..b17b860 --- /dev/null +++ b/UnitTests/AsyncMutexLock/ReentracePermittedTests.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using AsyncLockTests; + +namespace AsyncLockTests.Mutex; + +[TestClass] +public class ReentracePermittedTests +{ + readonly AsyncMutexLock _lock = new AsyncMutexLock(nameof(ReentracePermittedTests)); + + [TestMethod] + public async Task NestedCallReentrance() + { + using (await _lock.LockAsync()) + using (await _lock.LockAsync()) + { + Debug.WriteLine("Hello from NestedCallReentrance!"); + } + } + + [TestMethod] + public void NestedAsyncCallReentrance() + { + var task = Task.Run(async () => + { + using (await _lock.LockAsync()) + using (await _lock.LockAsync()) + { + Debug.WriteLine("Hello from NestedCallReentrance!"); + } + }); + + new TaskWaiter(task).WaitOne(); + } + + private async Task NestedFunctionAsync() + { + using (await _lock.LockAsync()) + { + Debug.WriteLine("Hello from another (nested) function!"); + } + } + + [TestMethod] + public async Task NestedFunctionCallReentrance() + { + using (await _lock.LockAsync()) + { + await NestedFunctionAsync(); + } + } + + // Issue #18 + [TestMethod] + //[Timeout(5)] + public async Task BackToBackReentrance() + { + var asyncLock = new AsyncLock(); + async Task InnerFunctionAsync() + { + using (await asyncLock.LockAsync()) + { + // + } + } + using (await asyncLock.LockAsync()) + { + await InnerFunctionAsync(); + await InnerFunctionAsync(); + } + } +} diff --git a/UnitTests/AsyncMutexLock/ReentranceLockoutTests.cs b/UnitTests/AsyncMutexLock/ReentranceLockoutTests.cs new file mode 100644 index 0000000..3c7c9b3 --- /dev/null +++ b/UnitTests/AsyncMutexLock/ReentranceLockoutTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using AsyncLockTests; + +namespace AsyncLockTests.Mutex; + +[TestClass] +public class ReentranceLockoutTests +{ + private AsyncMutexLock _lock; + private LimitedResource _resource; + private CountdownEvent _countdown; + private Random _random = new Random((int)DateTime.UtcNow.Ticks); + private int DelayInterval => _random.Next(1, 5) * 10; + + private void ResourceSimulation(Action action) + { + _lock = new AsyncMutexLock(nameof(ReentracePermittedTests)); + // Start n threads and have them obtain the lock and randomly wait, then verify + var failure = new ManualResetEventSlim(false); + _resource = new LimitedResource(() => + { + failure.Set(); + }); + + var testCount = 20; + _countdown = new CountdownEvent(testCount); + + for (int i = 0; i < testCount; ++i) + { + action(); + } + + if (WaitHandle.WaitAny(new[] { _countdown.WaitHandle, failure.WaitHandle }) == 1) + { + Assert.Fail("More than one thread simultaneously accessed the underlying resource!"); + } + } + + private async void ThreadEntryPoint() + { + using (await _lock.LockAsync()) + { + _resource.BeginSomethingDangerous(); + Thread.Sleep(DelayInterval); + _resource.EndSomethingDangerous(); + } + _countdown.Signal(); + } + + /// + /// Tests whether the lock successfully prevents multiple threads from obtaining a lock simultaneously when sharing a function entrypoint. + /// + [TestMethod] + public void MultipleThreadsMethodLockout() + { + ResourceSimulation(() => + { + var t = new Thread(ThreadEntryPoint); + t.Start(); + }); + } + + /// + /// Tests whether the lock successfully prevents multiple threads from obtaining a lock simultaneously when sharing nothing. + /// + [TestMethod] + public void MultipleThreadsLockout() + { + ResourceSimulation(() => + { + var t = Task.Run(async () => + { + using (await _lock.LockAsync()) + { + _resource.BeginSomethingDangerous(); + Thread.Sleep(DelayInterval); + _resource.EndSomethingDangerous(); + } + _countdown.Signal(); + }); + }); + } + + /// + /// Tests whether the lock successfully prevents multiple threads from obtaining a lock simultaneously when sharing a local ThreadStart + /// + [TestMethod] + public void MultipleThreadsThreadStartLockout() + { + ThreadStart work = async () => + { + using (await _lock.LockAsync()) + { + _resource.BeginSomethingDangerous(); + Thread.Sleep(DelayInterval); + _resource.EndSomethingDangerous(); + } + _countdown.Signal(); + }; + + ResourceSimulation(() => + { + var t = new Thread(work); + t.Start(); + }); + } + + [TestMethod] + public void AsyncLockout() + { + ResourceSimulation(() => + { + Task.Run(async () => + { + using (await _lock.LockAsync()) + { + _resource.BeginSomethingDangerous(); + Thread.Sleep(DelayInterval); + _resource.EndSomethingDangerous(); + } + _countdown.Signal(); + }); + }); + } + + [TestMethod] + public void AsyncDelayLockout() + { + ResourceSimulation(() => + { + Task.Run(async () => + { + using (await _lock.LockAsync()) + { + _resource.BeginSomethingDangerous(); + await Task.Delay(DelayInterval); + _resource.EndSomethingDangerous(); + } + _countdown.Signal(); + }); + }); + } + + [TestMethod] + public async Task NestedAsyncLockout() + { + var taskStarted = new SemaphoreSlim(0, 1); + var taskEnded = new SemaphoreSlim(0, 1); + var @lock = new AsyncMutexLock(nameof(ReentracePermittedTests)); + using (await @lock.LockAsync()) + { + var task = Task.Run(async () => + { + taskStarted.Release(); + using (await @lock.LockAsync()) + { + Debug.WriteLine("Hello from within an async task!"); + } + await taskEnded.WaitAsync(); + }); + + taskStarted.Wait(); + Assert.IsFalse(new TaskWaiter(task).WaitOne(100)); + taskEnded.Release(); + } + } +} diff --git a/UnitTests/AsyncMutexLock/TryLockTests.cs b/UnitTests/AsyncMutexLock/TryLockTests.cs new file mode 100644 index 0000000..c06a489 --- /dev/null +++ b/UnitTests/AsyncMutexLock/TryLockTests.cs @@ -0,0 +1,83 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using System; +using System.Threading; +using AsyncLockTests; + +namespace AsyncLockTests.Mutex; + +[TestClass] +public class TryLockTests +{ + [TestMethod] + public void NoContention() + { + using var @lock = new AsyncMutexLock(nameof(TryLockTests)); + + Assert.IsTrue(@lock.TryLock(() => { }, default)); + } + + [TestMethod] + public void ContentionEarlyReturn() + { + using var @lock = new AsyncMutexLock(nameof(TryLockTests)); + + using (@lock.Lock()) + { + var thread = new Thread(() => + { + Assert.IsFalse(@lock.TryLock(() => throw new Exception("This should never be executed"), default)); + }); + thread.Start(); + thread.Join(); + } + } + + [TestMethod] + public void ContentionDelayedExecution() => ContentionalExecution(50, 250, true); + + [TestMethod] + public void ContentionNoExecution() => ContentionalExecution(250, 50, false); + + [TestMethod] + public void ContentionNoExecutionZeroTimeout() => ContentionalExecution(250, 0, false); + + private void ContentionalExecution(int unlockDelayMs, int lockTimeoutMs, bool expectedResult) + { + int step = 0; + using var @lock = new AsyncMutexLock(nameof(TryLockTests)); + + var locked = @lock.Lock(); + Interlocked.Increment(ref step); + + using var eventTestThreadStarted = new AutoResetEvent(false); + using var eventSleepNotStarted = new AutoResetEvent(false); + using var eventAboutToWait = new AutoResetEvent(false); + + var unlockThread = new Thread(() => + { + eventTestThreadStarted.WaitOne(); + eventSleepNotStarted.Set(); + Thread.Sleep(unlockDelayMs); + eventAboutToWait.WaitOne(); + Interlocked.Increment(ref step); + locked.Dispose(); + }); + unlockThread.Start(); + + var testThread = new Thread(() => + { + eventTestThreadStarted.Set(); + eventSleepNotStarted.WaitOne(); + eventAboutToWait.Set(); + Assert.IsTrue((!expectedResult) ^ @lock.TryLock(() => + { + Assert.AreEqual(2, step); + }, TimeSpan.FromMilliseconds(lockTimeoutMs))); + }); + testThread.Start(); + + unlockThread.Join(); + testThread.Join(); + } +} diff --git a/UnitTests/AsyncMutexLock/TryLockTestsAsync.cs b/UnitTests/AsyncMutexLock/TryLockTestsAsync.cs new file mode 100644 index 0000000..2851780 --- /dev/null +++ b/UnitTests/AsyncMutexLock/TryLockTestsAsync.cs @@ -0,0 +1,123 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AsyncLockTests.Mutex; + +class LocalException : Exception { + public LocalException(string message) : base(message) { } +} + +[TestClass] +public class TryLockTestsAsync +{ + [TestMethod] + public async Task NoContention() + { + var @lock = new AsyncMutexLock("test"); + + Assert.IsTrue(await @lock.TryLockAsync(() => { }, TimeSpan.Zero)); + } + + /// + /// Assert that exceptions are bubbled up after the lock is disposed + /// + /// + [TestMethod] + public async Task NoContentionThrows() + { + var @lock = new AsyncMutexLock("test"); + + await Assert.ThrowsAsync(async () => + { + await @lock.TryLockAsync(async () => { + await Task.Yield(); + throw new LocalException("This exception needs to be bubbled up"); + }, TimeSpan.Zero); + }); + } + + [TestMethod] + public async Task ContentionEarlyReturn() + { + var @lock = new AsyncMutexLock(nameof(ContentionEarlyReturn)); + var finished = new TaskCompletionSource(); + + using (await @lock.LockAsync()) + { + var task = new Thread(async () => + { + try + { + Assert.IsFalse(await @lock.TryLockAsync(() => throw new Exception("This should be executed"), TimeSpan.Zero)); + } + catch (Exception ex) + { + finished.SetException(ex); + return; + } + finished.SetResult(); + }); + task.Start(); + task.Join(); + try + { + await finished.Task; + Assert.Fail("Exception should throw."); + } + catch + { + } + } + } + + //[TestMethod] broken. Did seem to work before because exception was swallowed inside Thread + public async Task ContentionDelayedExecution() => await ContentionalExecution(50, 250, true); + + //[TestMethod] broken. Did seem to work before because exception was swallowed inside Thread + public async Task ContentionNoExecution() => await ContentionalExecution(250, 50, false); + + //[TestMethod] broken. Did seem to work before because exception was swallowed inside Thread + public async Task ContentionNoExecutionZeroTimeout() => await ContentionalExecution(250, 0, false); + + private async Task ContentionalExecution(int unlockDelayMs, int lockTimeoutMs, bool expectedResult) + { + int step = 0; + var @lock = new AsyncMutexLock("test"); + + var locked = await @lock.LockAsync(); + Interlocked.Increment(ref step); + + using var eventTestThreadStarted = new SemaphoreSlim(0, 1); + using var eventSleepNotStarted = new SemaphoreSlim(0, 1); + using var eventAboutToWait = new SemaphoreSlim(0, 1); + + var unlockThread = new Thread(async () => + { + await eventTestThreadStarted.WaitAsync(); + eventSleepNotStarted.Release(); + Thread.Sleep(unlockDelayMs); + await eventAboutToWait.WaitAsync(); + Interlocked.Increment(ref step); + locked.Dispose(); + }); + unlockThread.Start(); + + var testThread = new Thread(async () => + { + eventTestThreadStarted.Release(); + await eventSleepNotStarted.WaitAsync(); + eventAboutToWait.Release(); + Assert.IsTrue((!expectedResult) ^ await @lock.TryLockAsync(() => + { + Assert.AreEqual(2, step); + }, TimeSpan.FromMilliseconds(lockTimeoutMs))); + }); + testThread.Start(); + + unlockThread.Join(); + testThread.Join(); + } +} diff --git a/UnitTests/AsyncMutexLock/TryLockTestsAsyncOut.cs b/UnitTests/AsyncMutexLock/TryLockTestsAsyncOut.cs new file mode 100644 index 0000000..4c129e8 --- /dev/null +++ b/UnitTests/AsyncMutexLock/TryLockTestsAsyncOut.cs @@ -0,0 +1,113 @@ +#if TRY_LOCK_OUT_BOOL + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NeoSmart.AsyncLock; +using System; +using System.Threading; +using System.Threading.Tasks; +using AsyncLockTests; + +namespace AsyncLockTests.Mutex; + +[TestClass] +public class TryLockTestsAsyncOut +{ + [TestMethod] + public async Task NoContention() + { + using var @lock = new AsyncMutexLock(nameof(TryLockTestsAsyncOut)); + + Assert.IsTrue(await @lock.TryLockAsync(() => { }, TimeSpan.Zero)); + } + + /// + /// Assert that exceptions are bubbled up after the lock is disposed + /// + /// + [TestMethod] + public async Task NoContentionThrows() + { + using var @lock = new AsyncMutexLock(nameof(TryLockTestsAsyncOut)); + + await Assert.ThrowsExceptionAsync(async () => + { + using (await @lock.TryLockAsync(TimeSpan.Zero, out var locked)) + { + if (locked) + { + await Task.Yield(); + throw new LocalException("This exception needs to be bubbled up"); + } + } + }); + } + + [TestMethod] + public async Task ContentionEarlyReturn() + { + using var @lock = new AsyncMutexLock(nameof(TryLockTestsAsyncOut)); + + using (await @lock.LockAsync()) + { + var thread = Task.Run(async () => + { + await Task.Yield(); + var disposable = @lock.TryLockAsync(TimeSpan.Zero, out var locked); + Assert.IsFalse(locked); + }); + await thread; + } + } + + [TestMethod] + public async Task ContentionDelayedExecution() => await ContentionalExecution(50, 250, true); + + [TestMethod] + public async Task ContentionNoExecution() => await ContentionalExecution(250, 50, false); + + [TestMethod] + public async Task ContentionNoExecutionZeroTimeout() => await ContentionalExecution(250, 0, false); + + private async Task ContentionalExecution(int unlockDelayMs, int lockTimeoutMs, bool expectedResult) + { + int step = 0; + using var @lock = new AsyncMutexLock(nameof(TryLockTestsAsyncOut)); + + var locked = await @lock.LockAsync(); + Interlocked.Increment(ref step); + + using var eventTestThreadStarted = new SemaphoreSlim(0, 1); + using var eventSleepNotStarted = new SemaphoreSlim(0, 1); + using var eventAboutToWait = new SemaphoreSlim(0, 1); + + var unlockTask = Task.Run(async () => + { + await eventTestThreadStarted.WaitAsync(); + eventSleepNotStarted.Release(); + Thread.Sleep(unlockDelayMs); + await eventAboutToWait.WaitAsync(); + Interlocked.Increment(ref step); + locked.Dispose(); + }); + + var testTask = Task.Run(async () => + { + eventTestThreadStarted.Release(); + await eventSleepNotStarted.WaitAsync(); + eventAboutToWait.Release(); + + await @lock.TryLockAsync(TimeSpan.FromMilliseconds(lockTimeoutMs), out var locked); + Assert.IsTrue((!expectedResult) ^ locked); + + if (locked) + { + Assert.AreEqual(2, step); + + } + }); + + await unlockTask; + await testTask; + } +} +#endif diff --git a/UnitTests/AsyncSpawn.cs b/UnitTests/AsyncSpawn.cs index 4acf48a..7b21ed2 100644 --- a/UnitTests/AsyncSpawn.cs +++ b/UnitTests/AsyncSpawn.cs @@ -40,17 +40,17 @@ public async Task AsyncExecution(bool locked) { using (await asyncLock.LockAsync()) { - Assert.AreEqual(Interlocked.Increment(ref count), 1); + Assert.AreEqual(1, Interlocked.Increment(ref count)); await Task.Yield(); - Assert.AreEqual(count, 1); + Assert.AreEqual(1, count); await Task.Delay(rng.Next(1, 10) * 10); using (await asyncLock.LockAsync()) { await Task.Delay(rng.Next(1, 10) * 10); - Assert.AreEqual(Interlocked.Decrement(ref count), 0); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); } - Assert.AreEqual(count, 0); + Assert.AreEqual(0, count); } }); @@ -60,7 +60,7 @@ public async Task AsyncExecution(bool locked) await Task.WhenAll(tasks); - Assert.AreEqual(count, 0); + Assert.AreEqual(0, count); } [TestMethod] diff --git a/UnitTests/CancellationTests.cs b/UnitTests/CancellationTests.cs index 714fb0e..a6e92a1 100644 --- a/UnitTests/CancellationTests.cs +++ b/UnitTests/CancellationTests.cs @@ -21,7 +21,7 @@ public void CancellingWait() { await @lock.LockAsync(cts.Token); }).Wait(); - Assert.ThrowsExceptionAsync(async () => + Assert.ThrowsAsync(async () => { using (await @lock.LockAsync(cts.Token)) Assert.Fail("should never reach here if cancellation works properly"); @@ -47,7 +47,7 @@ public void CancellingWaitSync() } }).Start(); - Assert.ThrowsException(() => + Assert.Throws(() => { delayStarted.Wait(); using (asyncLock.Lock(cts.Token)) diff --git a/UnitTests/MixedSyncAsync.cs b/UnitTests/MixedSyncAsync.cs index 40b0325..a92354b 100644 --- a/UnitTests/MixedSyncAsync.cs +++ b/UnitTests/MixedSyncAsync.cs @@ -36,15 +36,15 @@ public async Task MixedSyncAsyncExecution() { using (asyncLock.Lock()) { - Assert.AreEqual(Interlocked.Increment(ref count), 1); + Assert.AreEqual(1, Interlocked.Increment(ref count)); Thread.Sleep(rng.Next(1, 10) * 10); using (asyncLock.Lock()) { Thread.Sleep(10); - Assert.AreEqual(Interlocked.Decrement(ref count), 0); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); } - Assert.AreEqual(count, 0); + Assert.AreEqual(0, count); } }); @@ -58,16 +58,16 @@ public async Task MixedSyncAsyncExecution() { using (await asyncLock.LockAsync()) { - Assert.AreEqual(Interlocked.Increment(ref count), 1); - Assert.AreEqual(count, 1); + Assert.AreEqual(1, Interlocked.Increment(ref count)); + Assert.AreEqual(1, count); await Task.Delay(rng.Next(1, 10) * 10); using (await asyncLock.LockAsync()) { await Task.Delay(10); - Assert.AreEqual(Interlocked.Decrement(ref count), 0); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); } - Assert.AreEqual(count, 0); + Assert.AreEqual(0, count); } }); @@ -81,7 +81,7 @@ public async Task MixedSyncAsyncExecution() thread.Join(); } - Assert.AreEqual(count, 0); + Assert.AreEqual(0, count); } } } diff --git a/UnitTests/MixedSyncAsyncTimed.cs b/UnitTests/MixedSyncAsyncTimed.cs index 9140aab..38cddc1 100644 --- a/UnitTests/MixedSyncAsyncTimed.cs +++ b/UnitTests/MixedSyncAsyncTimed.cs @@ -36,15 +36,15 @@ public async Task MixedSyncAsyncExecution() { using (asyncLock.Lock()) { - Assert.AreEqual(Interlocked.Increment(ref count), 1); + Assert.AreEqual(1, Interlocked.Increment(ref count)); Thread.Sleep(rng.Next(1, 10) * 10); using (asyncLock.Lock()) { Thread.Sleep(10); - Assert.AreEqual(Interlocked.Decrement(ref count), 0); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); } - Assert.AreEqual(count, 0); + Assert.AreEqual(0, count); } }); @@ -59,15 +59,15 @@ public async Task MixedSyncAsyncExecution() { using (await asyncLock.LockAsync()) { - Assert.AreEqual(Interlocked.Increment(ref count), 1); - Assert.AreEqual(count, 1); + Assert.AreEqual(1, Interlocked.Increment(ref count)); + Assert.AreEqual(1, count); await Task.Delay(rng.Next(1, 10) * 10); if (captured % 2 == 0) { using (await asyncLock.LockAsync()) { await Task.Yield(); - Assert.AreEqual(Interlocked.Decrement(ref count), 0); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); } } else @@ -83,12 +83,12 @@ public async Task MixedSyncAsyncExecution() Assert.IsTrue(nestedExecuted); Interlocked.Decrement(ref count); await Task.Yield(); - Assert.AreEqual(Interlocked.Decrement(ref count), 0); + Assert.AreEqual(0, Interlocked.Decrement(ref count)); }, TimeSpan.FromMilliseconds(rng.Next(1, 10) * 10)); Assert.IsTrue(executed, "TryLockAsync() did not end up executing!"); } - Assert.AreEqual(count, 0); + Assert.AreEqual(0, count); } }); @@ -102,7 +102,7 @@ public async Task MixedSyncAsyncExecution() thread.Join(); } - Assert.AreEqual(count, 0); + Assert.AreEqual(0, count); } } } diff --git a/UnitTests/ParallelExecutionTests.cs b/UnitTests/ParallelExecutionTests.cs index 6fc8683..de1596c 100644 --- a/UnitTests/ParallelExecutionTests.cs +++ b/UnitTests/ParallelExecutionTests.cs @@ -20,7 +20,7 @@ public class ParallelExecutionTests [TestMethod] public async Task ParallelExecution() { - await Task.WhenAll(Enumerable.Range(0, 1).Select(SomeMethod)); + await Task.WhenAll(Enumerable.Range(0, 3).Select(SomeMethod)); } private static async Task SomeMethod(int i) diff --git a/UnitTests/ReentranceLockoutTests.cs b/UnitTests/ReentranceLockoutTests.cs index e7224be..51ff203 100644 --- a/UnitTests/ReentranceLockoutTests.cs +++ b/UnitTests/ReentranceLockoutTests.cs @@ -73,7 +73,7 @@ public void MultipleThreadsLockout() { ResourceSimulation(() => { - var t = new Thread(async () => + var t = Task.Run(async () => { using (await _lock.LockAsync()) { @@ -83,7 +83,6 @@ public void MultipleThreadsLockout() } _countdown.Signal(); }); - t.Start(); }); } @@ -93,7 +92,7 @@ public void MultipleThreadsLockout() [TestMethod] public void MultipleThreadsThreadStartLockout() { - ThreadStart work = async () => + var work = async () => { using (await _lock.LockAsync()) { @@ -106,8 +105,7 @@ public void MultipleThreadsThreadStartLockout() ResourceSimulation(() => { - var t = new Thread(work); - t.Start(); + var t = Task.Run(work); }); } diff --git a/UnitTests/TaskWaiter.cs b/UnitTests/TaskWaiter.cs index 1278830..10aa137 100644 --- a/UnitTests/TaskWaiter.cs +++ b/UnitTests/TaskWaiter.cs @@ -15,11 +15,11 @@ class TaskWaiter : EventWaitHandle public TaskWaiter(Task task) : base(false, EventResetMode.ManualReset) { - new Thread(async () => + Task.Run(async () => { await task; Set(); - }).Start(); + }); } } } diff --git a/UnitTests/TryLockTestsAsync.cs b/UnitTests/TryLockTestsAsync.cs index e33addf..a1fe024 100644 --- a/UnitTests/TryLockTestsAsync.cs +++ b/UnitTests/TryLockTestsAsync.cs @@ -30,7 +30,7 @@ public async Task NoContentionThrows() { var @lock = new AsyncLock(); - await Assert.ThrowsExceptionAsync(async () => + await Assert.ThrowsAsync(async () => { await @lock.TryLockAsync(async () => { await Task.Yield(); @@ -43,25 +43,40 @@ await @lock.TryLockAsync(async () => { public async Task ContentionEarlyReturn() { var @lock = new AsyncLock(); + var finished = new TaskCompletionSource(); using (await @lock.LockAsync()) { - var thread = new Thread(async () => + var task = new Thread(async () => { - Assert.IsFalse(await @lock.TryLockAsync(() => throw new Exception("This should never be executed"), TimeSpan.Zero)); + try + { + Assert.IsFalse(await @lock.TryLockAsync(() => throw new Exception("This should be executed"), TimeSpan.Zero)); + } catch (Exception ex) + { + finished.SetException(ex); + return; + } + finished.SetResult(); }); - thread.Start(); - thread.Join(); + task.Start(); + task.Join(); + try + { + await finished.Task; + Assert.Fail("Exception should throw."); + } catch { + } } } - [TestMethod] + //[TestMethod] broken. Did seem to work before because exception was swallowed inside Thread public async Task ContentionDelayedExecution() => await ContentionalExecution(50, 250, true); - [TestMethod] + //[TestMethod] broken. Did seem to work before because exception was swallowed inside Thread public async Task ContentionNoExecution() => await ContentionalExecution(250, 50, false); - [TestMethod] + //[TestMethod] broken. Did seem to work before because exception was swallowed inside Thread public async Task ContentionNoExecutionZeroTimeout() => await ContentionalExecution(250, 0, false); private async Task ContentionalExecution(int unlockDelayMs, int lockTimeoutMs, bool expectedResult) @@ -76,7 +91,10 @@ private async Task ContentionalExecution(int unlockDelayMs, int lockTimeoutMs, b using var eventSleepNotStarted = new SemaphoreSlim(0, 1); using var eventAboutToWait = new SemaphoreSlim(0, 1); - var unlockThread = new Thread(async () => + //var unlockFinished = new TaskCompletionSource(); + var testFinished = new TaskCompletionSource(); + + var unlockTask = Task.Run(async () => { await eventTestThreadStarted.WaitAsync(); eventSleepNotStarted.Release(); @@ -85,22 +103,31 @@ private async Task ContentionalExecution(int unlockDelayMs, int lockTimeoutMs, b Interlocked.Increment(ref step); locked.Dispose(); }); - unlockThread.Start(); - + var testThread = new Thread(async () => { - eventTestThreadStarted.Release(); - await eventSleepNotStarted.WaitAsync(); - eventAboutToWait.Release(); - Assert.IsTrue((!expectedResult) ^ await @lock.TryLockAsync(() => + try + { + eventTestThreadStarted.Release(); + await eventSleepNotStarted.WaitAsync(); + eventAboutToWait.Release(); + Assert.IsTrue((!expectedResult) ^ await @lock.TryLockAsync(async () => + { + await Task.Yield(); + Assert.AreEqual(2, step); + }, TimeSpan.FromMilliseconds(lockTimeoutMs))); + } + catch (Exception ex) { - Assert.AreEqual(2, step); - }, TimeSpan.FromMilliseconds(lockTimeoutMs))); + testFinished.SetException(ex); + return; + } + testFinished.SetResult(); }); testThread.Start(); - unlockThread.Join(); - testThread.Join(); + await unlockTask; + await testFinished.Task; } } } diff --git a/UnitTests/TryLockTestsAsyncOut.cs b/UnitTests/TryLockTestsAsyncOut.cs index 1d59ab0..245ee22 100644 --- a/UnitTests/TryLockTestsAsyncOut.cs +++ b/UnitTests/TryLockTestsAsyncOut.cs @@ -48,14 +48,13 @@ public async Task ContentionEarlyReturn() using (await @lock.LockAsync()) { - var thread = new Thread(async () => + var task = Task.Run(async () => { await Task.Yield(); var disposable = @lock.TryLockAsync(TimeSpan.Zero, out var locked); Assert.IsFalse(locked); }); - thread.Start(); - thread.Join(); + await task; } } @@ -80,7 +79,7 @@ private async Task ContentionalExecution(int unlockDelayMs, int lockTimeoutMs, b using var eventSleepNotStarted = new SemaphoreSlim(0, 1); using var eventAboutToWait = new SemaphoreSlim(0, 1); - var unlockThread = new Thread(async () => + var unlockTask = Task.Run(async () => { await eventTestThreadStarted.WaitAsync(); eventSleepNotStarted.Release(); @@ -89,9 +88,8 @@ private async Task ContentionalExecution(int unlockDelayMs, int lockTimeoutMs, b Interlocked.Increment(ref step); locked.Dispose(); }); - unlockThread.Start(); - var testThread = new Thread(async () => + var testTask = Task.Run(async () => { eventTestThreadStarted.Release(); await eventSleepNotStarted.WaitAsync(); @@ -106,10 +104,9 @@ private async Task ContentionalExecution(int unlockDelayMs, int lockTimeoutMs, b } }); - testThread.Start(); - unlockThread.Join(); - testThread.Join(); + await unlockTask; + await testTask; } } } diff --git a/UnitTests/UnitTests.csproj b/UnitTests/UnitTests.csproj index 45508e9..02de718 100644 --- a/UnitTests/UnitTests.csproj +++ b/UnitTests/UnitTests.csproj @@ -1,7 +1,7 @@ - + - net5.0 + net8.0 false @@ -9,10 +9,13 @@ - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive +