Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ DistributedLock contains implementations based on various technologies; you can
- **[DistributedLock.MongoDB](docs/DistributedLock.MongoDB.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.MongoDB.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.MongoDB/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.MongoDB.html) (uses MongoDB)
- **[DistributedLock.ZooKeeper](docs/DistributedLock.ZooKeeper.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.ZooKeeper.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.ZooKeeper/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.ZooKeeper.html) (uses Apache ZooKeeper)
- **[DistributedLock.FileSystem](docs/DistributedLock.FileSystem.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.FileSystem.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.FileSystem/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.FileSystem.html) (uses lock files)
- **[DistributedLock.WaitHandles](docs/DistributedLock.WaitHandles.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.WaitHandles.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.WaitHandles/) [![Static Badge](https://img.shields.io/badge/API%20Docs-DNDocs-190088?logo=readme&logoColor=white)](https://dndocs.com/d/distributedlock/api/Medallion.Threading.WaitHandles.html) (*Windows only*: uses operating system global `WaitHandle`s)
(*Windows only*: uses operating system global `WaitHandle`s)
- **[DistributedLock.InProcess](docs/DistributedLock.InProcess.md)** [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.InProcess.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.InProcess/) (*non-distributed*: uses in-process primitives such as `SemaphoreSlim`; useful for single-instance apps and testing)

**Click on the name** of any of the above packages to see the documentation specific to that implementation, or read on for general documentation that applies to all implementations.

Expand Down
40 changes: 40 additions & 0 deletions docs/DistributedLock.InProcess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# DistributedLock.InProcess

[Download the NuGet package](https://www.nuget.org/packages/DistributedLock.InProcess) [![NuGet Status](http://img.shields.io/nuget/v/DistributedLock.InProcess.svg?style=flat)](https://www.nuget.org/packages/DistributedLock.InProcess/)

The DistributedLock.InProcess package offers **non-distributed** implementations of the DistributedLock abstractions based on in-process primitives such as `SemaphoreSlim`. This is useful for single-instance deployments, local development, and tests, where you want to program against `IDistributedLockProvider` & co. without a backing service. For example:

```C#
var provider = new InProcessDistributedSynchronizationProvider();
var @lock = provider.CreateLock("MyLockName");
await using (await @lock.AcquireAsync())
{
// I have the lock!
}
```

## APIs

- The `InProcessDistributedLock` class implements the `IDistributedLock` interface.
- The `InProcessDistributedSemaphore` class implements the `IDistributedSemaphore` interface.
- The `InProcessDistributedReaderWriterLock` class implements the `IDistributedUpgradeableReaderWriterLock` interface.
- The `InProcessDistributedSynchronizationProvider` class implements the `IDistributedLockProvider`, `IDistributedSemaphoreProvider` and `IDistributedUpgradeableReaderWriterLockProvider` interfaces.

Primitives are created through the provider; there are no public constructors.

## Implementation notes

- **These classes only coordinate within a single process.** They provide no protection across processes or machines.
- **Scopes:** primitives are cached process-wide by (scope, name). All providers constructed with the same `scope` (the default is a shared, process-wide scope) hand out coordinating primitives for the same name, even across provider instances. Providers with different scopes are fully isolated:

```C#
var tenantA = new InProcessDistributedSynchronizationProvider(scope: "tenant-a");
var tenantB = new InProcessDistributedSynchronizationProvider(scope: "tenant-b");
// tenantA.CreateLock("x") and tenantB.CreateLock("x") do not block each other
```

- **Primitive types are independent:** locks, semaphores and reader-writer locks are cached separately, so a lock and a reader-writer lock (or semaphore) with the same name do not coordinate with one another. This mirrors most other providers.
- **Semaphore max count:** the max count of a named semaphore is fixed by whichever caller first creates it while it is alive.
- **Abandonment:** a handle that is never disposed is released only once it has been garbage collected and processed by a background finalizer, which runs roughly every 30 seconds. Until then the primitive remains held. For reader-writer locks, an abandoned read handle blocks writers, and a waiting writer in turn blocks new readers. Always dispose handles.
- **Reader-writer lock fairness:** the reader-writer lock is writer-preferring. Once a writer is waiting, new readers queue behind it.
- `HandleLostToken` is always `CancellationToken.None`, since an in-process handle cannot be lost.
1 change: 1 addition & 0 deletions src/DistributedLock.Core/AssemblyAttributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@
[assembly: InternalsVisibleTo("DistributedLock.MySql, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")]
[assembly: InternalsVisibleTo("DistributedLock.Oracle, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")]
[assembly: InternalsVisibleTo("DistributedLock.MongoDB, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")]
[assembly: InternalsVisibleTo("DistributedLock.InProcess, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd3af56ccc8ed94fffe25bfd651e6a5674f8f20a76d37de800dd0f7380e04f0fde2da6fa200380b14fe398605b6f470c87e5e0a0bf39ae871f07536a4994aa7a0057c4d3bcedc8fef3eecb0c88c2024a1b3289305c2393acd9fb9f9a42d0bd7826738ce864d507575ea3a1fe1746ab19823303269f79379d767949807f494be8")]
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -149,20 +149,6 @@ private static async ValueTask DisposeHandlesAsync(IReadOnlyList<IDistributedSyn
throw new AggregateException(exceptions);
}
}

private readonly struct TimeoutTracker(TimeoutValue timeout)
{
private readonly System.Diagnostics.Stopwatch? _stopwatch = timeout.IsInfinite
? null
: System.Diagnostics.Stopwatch.StartNew();

public TimeSpan Remaining =>
this._stopwatch is { Elapsed: var elapsed }
? elapsed >= timeout.TimeSpan
? TimeSpan.Zero
: timeout.TimeSpan - elapsed
: Timeout.InfiniteTimeSpan;
}
}

internal static class CompositeDistributedLockHandleExtensions
Expand Down
21 changes: 21 additions & 0 deletions src/DistributedLock.Core/Internal/SemaphoreSlimExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Medallion.Threading.Internal;

#if DEBUG
public
#else
internal
#endif
static class SemaphoreSlimExtensions
{
/// <summary>
/// A <see cref="SyncViaAsync"/>-compatible implementation of
/// <see cref="SemaphoreSlim.WaitAsync(TimeSpan, CancellationToken)"/>.
/// </summary>
public static ValueTask<bool> InternalWaitAsync(
this SemaphoreSlim semaphore,
TimeSpan timeout,
CancellationToken cancellationToken) =>
SyncViaAsync.IsSynchronous
? semaphore.Wait(timeout, cancellationToken).AsValueTask()
: semaphore.WaitAsync(timeout, cancellationToken).AsValueTask();
}
20 changes: 20 additions & 0 deletions src/DistributedLock.Core/Internal/TimeoutTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Medallion.Threading.Internal;

#if DEBUG
public
#else
internal
#endif
readonly struct TimeoutTracker(TimeoutValue timeout)
{
private readonly System.Diagnostics.Stopwatch? _stopwatch = timeout.IsInfinite
? null
: System.Diagnostics.Stopwatch.StartNew();

public TimeSpan Remaining =>
this._stopwatch is { Elapsed: var elapsed }
? elapsed >= timeout.TimeSpan
? TimeSpan.Zero
: timeout.TimeSpan - elapsed
: Timeout.InfiniteTimeSpan;
}
174 changes: 174 additions & 0 deletions src/DistributedLock.InProcess/AsyncReaderWriterLockState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
using Medallion.Threading.Internal;

namespace Medallion.Threading.InProcess;

internal class AsyncReaderWriterLockState
{
/// <summary>
/// Held by writers for the duration of the lock; blocks incoming readers.
/// </summary>
private readonly SemaphoreSlim _entryGate = new(1, 1);
/// <summary>
/// Held by a writer, or collectively by the current set of readers (first in/last out).
/// </summary>
private readonly SemaphoreSlim _writeSemaphore = new(1, 1);
/// <summary>
/// Held by the upgradeable reader and by writers. This makes upgradeable acquisitions mutually
/// exclusive with each other and with writers, while still allowing plain readers.
/// </summary>
private readonly SemaphoreSlim _upgradeSemaphore = new(1, 1);
/// <summary>
/// Guards <see cref="_readerCount"/>. This is a plain monitor rather than a <see cref="SemaphoreSlim"/> because
/// the release path must not block or go async: it runs from Dispose/DisposeAsync and from the
/// <see cref="ManagedFinalizerQueue"/> thread. Every critical section it guards is non-blocking.
/// </summary>
private readonly object _readerCountLock = new();
private int _readerCount = 0;
/// <summary>
/// The number of writers (including upgrading readers) that are waiting for or holding the write claim.
/// Guarded by <see cref="_readerCountLock"/>. While this is zero, readers may bypass <see cref="_entryGate"/>.
/// </summary>
private int _writerCount = 0;

internal ValueTask<bool> TryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken, bool isWrite) =>
this.TryAcquireAsync(timeout, cancellationToken, isWrite ? InProcessReaderWriterLockType.Write : InProcessReaderWriterLockType.Read);

internal async ValueTask<bool> TryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken, InProcessReaderWriterLockType type)
{
var tracker = new TimeoutTracker(timeout);

switch (type)
{
case InProcessReaderWriterLockType.Upgrade:
// an upgradeable reader only excludes other upgradeable readers and writers
return await this._upgradeSemaphore.InternalWaitAsync(tracker.Remaining, cancellationToken).ConfigureAwait(false);

case InProcessReaderWriterLockType.Write:
if (!await this._upgradeSemaphore.InternalWaitAsync(tracker.Remaining, cancellationToken).ConfigureAwait(false))
{
return false;
}
var acquiredWrite = false;
try { return acquiredWrite = await this.TryAcquireWriteCoreAsync(tracker.Remaining, cancellationToken).ConfigureAwait(false); }
finally { if (!acquiredWrite) { this._upgradeSemaphore.Release(); } }

default:
return await this.TryAcquireReadCoreAsync(tracker, cancellationToken).ConfigureAwait(false);
}
}

/// <summary>
/// Acquires the write claim (<see cref="_entryGate"/> + <see cref="_writeSemaphore"/>). Assumes
/// <see cref="_upgradeSemaphore"/> is already held by the caller. The inverse is <see cref="ReleaseWriteCore"/>.
/// </summary>
internal async ValueTask<bool> TryAcquireWriteCoreAsync(TimeoutValue timeout, CancellationToken cancellationToken)
{
var tracker = new TimeoutTracker(timeout);

// announce ourselves before touching the write semaphore so that readers stop taking the fast path
lock (this._readerCountLock) { ++this._writerCount; }

var acquired = false;
try
{
// hold the entry gate for the duration of the write lock so that new readers queue up behind us
if (!await this._entryGate.InternalWaitAsync(tracker.Remaining, cancellationToken).ConfigureAwait(false))
{
return false;
}

try { return acquired = await this._writeSemaphore.InternalWaitAsync(tracker.Remaining, cancellationToken).ConfigureAwait(false); }
finally { if (!acquired) { this._entryGate.Release(); } }
}
finally
{
if (!acquired) { lock (this._readerCountLock) { --this._writerCount; } }
}
}

private async ValueTask<bool> TryAcquireReadCoreAsync(TimeoutTracker tracker, CancellationToken cancellationToken)
{
// Fast path: if no writer is waiting or holding, we can join the readers without going through the entry
// gate. Every writer increments _writerCount under _readerCountLock before it tries to take _writeSemaphore,
// so while _writerCount == 0 the write semaphore is either free or held by the current readers. This avoids
// spurious zero-timeout failures caused by other readers briefly holding the entry gate.
lock (this._readerCountLock)
{
if (this._writerCount == 0)
{
if (this._readerCount == 0)
{
var acquiredWriteSemaphore = this._writeSemaphore.Wait(TimeSpan.Zero);
Invariant.Require(acquiredWriteSemaphore, "the write semaphore must be free when there are no readers or writers");
}
++this._readerCount;
return true;
}
}

// Slow path: a writer is pending. Wait
if (!await this._entryGate.InternalWaitAsync(tracker.Remaining, cancellationToken).ConfigureAwait(false))
{
return false;
}

try
{
// We hold the entry gate, so no writer can hold _writeSemaphore right now. It is therefore either
// free (_readerCount == 0) or held by the existing readers (_readerCount > 0). In the former case
// the Wait below is guaranteed to succeed immediately, so this critical section never blocks.
lock (this._readerCountLock)
{
if (this._readerCount == 0)
{
var acquiredWriteSemaphore = this._writeSemaphore.Wait(TimeSpan.Zero);
Invariant.Require(acquiredWriteSemaphore, "the write semaphore must be free while the entry gate is held and there are no readers");
}
++this._readerCount;
return true;
}
}
finally { this._entryGate.Release(); }
}

internal void Release(bool isWrite) =>
this.Release(isWrite ? InProcessReaderWriterLockType.Write : InProcessReaderWriterLockType.Read, upgraded: false);

internal void Release(InProcessReaderWriterLockType type, bool upgraded)
{
switch (type)
{
case InProcessReaderWriterLockType.Write:
this.ReleaseWriteCore();
this._upgradeSemaphore.Release();
break;

case InProcessReaderWriterLockType.Upgrade:
if (upgraded) { this.ReleaseWriteCore(); }
this._upgradeSemaphore.Release();
break;

default:
// Non-blocking: see the remarks on _readerCountLock. This matters because release runs from
// Dispose(), DisposeAsync() and the ManagedFinalizerQueue thread, none of which may block.
lock (this._readerCountLock)
{
if (--this._readerCount == 0) { this._writeSemaphore.Release(); }
}
break;
}
}

/// <summary>
/// Releases the write claim (<see cref="_writeSemaphore"/> + <see cref="_entryGate"/>) WITHOUT releasing
/// <see cref="_upgradeSemaphore"/>. This is the inverse of <see cref="TryAcquireWriteCoreAsync"/>. It is used
/// both by normal write release and to compensate for an upgrade that raced with handle disposal.
/// </summary>
internal void ReleaseWriteCore()
{
this._writeSemaphore.Release();
this._entryGate.Release();
// decrement last so that fast-path readers never observe _writerCount == 0 while we still hold _writeSemaphore
lock (this._readerCountLock) { --this._writerCount; }
}
}
Loading