AnoiKeyedLock 1.1.0
dotnet add package AnoiKeyedLock --version 1.1.0
NuGet\Install-Package AnoiKeyedLock -Version 1.1.0
<PackageReference Include="AnoiKeyedLock" Version="1.1.0" />
<PackageVersion Include="AnoiKeyedLock" Version="1.1.0" />
<PackageReference Include="AnoiKeyedLock" />
paket add AnoiKeyedLock --version 1.1.0
#r "nuget: AnoiKeyedLock, 1.1.0"
#:package AnoiKeyedLock@1.1.0
#addin nuget:?package=AnoiKeyedLock&version=1.1.0
#tool nuget:?package=AnoiKeyedLock&version=1.1.0
AnoiKeyedLock
A high-performance, low-allocation keyed lock implementation for .NET that ensures exclusive access per string key with automatic resource cleanup.
Features
✅ High Performance: Lock-free reference counting with minimal contention
✅ Low Allocations: Struct-based releaser avoids heap allocations
✅ Automatic Cleanup: Keys and semaphores are automatically removed when no longer needed
✅ Thread-Safe: Fully thread-safe for concurrent access
✅ Flexible API: Support for sync/async, timeouts, and cancellation tokens
✅ String Keys: Optimized for string-based locking with optional case-insensitive comparison
✅ Diagnostics: Built-in IsLocked() and GetActiveKeys() methods for monitoring
✅ IAsyncDisposable: Support for await using syntax on .NET 8+
✅ Multi-targeting: Supports .NET Standard 2.1, .NET 8, .NET 9, and .NET 10
Installation
Simply include the KeyedLock.cs file in your project, or build and reference the assembly.
Quick Start
using AnoiKeyedLock;
var keyedLock = new KeyedLock();
// Basic usage
using (var releaser = keyedLock.Lock("myKey"))
{
// Only one thread can execute this block for "myKey" at a time
DoWork();
}
// Async usage
using (var releaser = await keyedLock.LockAsync("myKey"))
{
await DoWorkAsync();
}
// With timeout
if (keyedLock.TryLock("myKey", TimeSpan.FromSeconds(5), out var releaser))
{
using (releaser)
{
DoWork();
}
}
Dependency Injection
AnoiKeyedLock provides built-in support for dependency injection through the AddKeyedLock() extension method.
Setup
First, add the required NuGet package to your project:
dotnet add package Microsoft.Extensions.DependencyInjection
Then register KeyedLock in your DI container:
using AnoiKeyedLock;
using Microsoft.Extensions.DependencyInjection;
// In your Startup.cs or Program.cs
services.AddKeyedLock();
// Or with custom string comparer (e.g., case-insensitive)
services.AddKeyedLock(StringComparer.OrdinalIgnoreCase);
Usage with DI
Once registered, inject IKeyedLock into your services:
public class MyService
{
private readonly IKeyedLock _keyedLock;
public MyService(IKeyedLock keyedLock)
{
_keyedLock = keyedLock;
}
public async Task ProcessAsync(string id)
{
using (var releaser = await _keyedLock.LockAsync(id))
{
// Your synchronized code here
await DoWorkAsync(id);
}
}
}
ASP.NET Core Example
// Program.cs or Startup.cs
var builder = WebApplication.CreateBuilder(args);
// Register KeyedLock as a singleton
builder.Services.AddKeyedLock();
// Register your services
builder.Services.AddScoped<IUserService, UserService>();
var app = builder.Build();
// Controller usage
[ApiController]
[Route("api/[controller]")]
public class UserController : ControllerBase
{
private readonly IKeyedLock _keyedLock;
public UserController(IKeyedLock keyedLock)
{
_keyedLock = keyedLock;
}
[HttpPost("{userId}/process")]
public async Task<IActionResult> ProcessUser(string userId)
{
using (var releaser = await _keyedLock.LockAsync(userId))
{
// Ensure only one request processes this user at a time
await ProcessUserDataAsync(userId);
return Ok();
}
}
}
Lifetime
The KeyedLock is registered as a singleton by default, which is the recommended approach to ensure all parts of your application share the same lock instance for a given key.
API Reference
Constructor
// Default (ordinal) string comparison
var keyedLock = new KeyedLock();
// Case-insensitive comparison
var keyedLock = new KeyedLock(StringComparer.OrdinalIgnoreCase);
// Custom concurrency settings (for high-throughput scenarios)
var keyedLock = new KeyedLock(
concurrencyLevel: Environment.ProcessorCount * 2,
initialCapacity: 100,
comparer: StringComparer.Ordinal);
Synchronous Methods
Lock(string key)
Acquires a lock for the specified key. Blocks indefinitely until the lock is acquired.
KeyedLockReleaser Lock(string key)
TryLock(string key, TimeSpan timeout, out KeyedLockReleaser releaser)
Tries to acquire a lock within the specified timeout.
bool TryLock(string key, TimeSpan timeout, out KeyedLockReleaser releaser)
bool TryLock(string key, int millisecondsTimeout, out KeyedLockReleaser releaser)
TryLock(string key, CancellationToken cancellationToken, out KeyedLockReleaser releaser)
Tries to acquire a lock with cancellation support.
bool TryLock(string key, CancellationToken cancellationToken, out KeyedLockReleaser releaser)
Asynchronous Methods
LockAsync(string key, CancellationToken cancellationToken = default)
Asynchronously acquires a lock for the specified key.
Task<KeyedLockReleaser> LockAsync(string key, CancellationToken cancellationToken = default)
TryLockAsync(string key, TimeSpan timeout, CancellationToken cancellationToken = default)
Tries to asynchronously acquire a lock within the specified timeout.
Task<(bool success, KeyedLockReleaser releaser)> TryLockAsync(
string key,
TimeSpan timeout,
CancellationToken cancellationToken = default)
Task<(bool success, KeyedLockReleaser releaser)> TryLockAsync(
string key,
int millisecondsTimeout,
CancellationToken cancellationToken = default)
Properties and Diagnostic Methods
Count
Gets the current number of keys being tracked.
int Count { get; }
IsLocked(string key)
Checks if a lock is currently held for the specified key. Useful for diagnostics.
bool IsLocked(string key)
⚠️ Note: This is a point-in-time check. The lock state may change immediately after this method returns. Do not use for synchronization decisions.
GetActiveKeys()
Gets a snapshot of all keys that currently have active locks. Useful for monitoring and debugging.
string[] GetActiveKeys()
IAsyncDisposable Support (.NET 8+)
On .NET 8 and later, KeyedLockReleaser implements IAsyncDisposable, enabling the await using syntax:
await using (var releaser = await keyedLock.LockAsync("myKey"))
{
await DoWorkAsync();
}
How It Works
Architecture
- Concurrent Dictionary: Stores semaphores keyed by string keys
- Reference Counting: Tracks how many threads are waiting for or holding each lock
- Automatic Cleanup: When the reference count reaches zero, the semaphore is disposed and removed
- Struct-based Releaser: The
KeyedLockReleaseris a struct to avoid heap allocations
Thread Safety
- Multiple threads can acquire locks on different keys simultaneously without blocking
- Multiple threads waiting on the same key will queue in FIFO order
- Reference counting uses lock-free atomic operations (Interlocked)
- Dictionary operations use ConcurrentDictionary for thread-safe access
Performance Characteristics
| Operation | Complexity | Allocations |
|---|---|---|
| Lock acquisition | O(1) amortized | ~0 (struct releaser) |
| Lock release | O(1) amortized | 0 |
| Key cleanup | O(1) amortized | 0 |
Use Cases
Prevent Duplicate API Calls
public class ApiClient
{
private readonly KeyedLock _lock = new KeyedLock();
public async Task<User> GetUserAsync(string userId)
{
using (var releaser = await _lock.LockAsync(userId))
{
return await _httpClient.GetFromJsonAsync<User>($"/users/{userId}");
}
}
}
File Processing Coordination
public class FileProcessor
{
private readonly KeyedLock _lock = new KeyedLock();
public async Task ProcessFileAsync(string filePath)
{
using (var releaser = await _lock.LockAsync(filePath))
{
// Ensure only one thread processes this file at a time
await ProcessFileInternalAsync(filePath);
}
}
}
Database Connection Management
public class ConnectionManager
{
private readonly KeyedLock _lock = new KeyedLock();
public async Task<T> ExecuteAsync<T>(string connectionString, Func<DbConnection, Task<T>> operation)
{
using (var releaser = await _lock.LockAsync(connectionString))
{
using (var connection = new SqlConnection(connectionString))
{
await connection.OpenAsync();
return await operation(connection);
}
}
}
}
Rate Limiting
public class RateLimiter
{
private readonly KeyedLock _lock = new KeyedLock();
public async Task<bool> TryExecuteAsync(string clientId, Func<Task> action, TimeSpan minInterval)
{
var result = await _lock.TryLockAsync(clientId, TimeSpan.FromMilliseconds(1));
if (result.success)
{
using (result.releaser)
{
await action();
await Task.Delay(minInterval); // Enforce minimum interval
return true;
}
}
return false;
}
}
Best Practices
- ✅ Always use
usingstatements to ensure locks are released - ✅ Keep critical sections short to minimize contention
- ✅ Use async methods for I/O operations to avoid blocking threads
- ✅ Keys cannot be null or whitespace - validation is performed automatically
- ✅ Handle timeouts gracefully - always have a fallback strategy
- ❌ Avoid nested locks on the same key (not reentrant)
- ❌ Don't hold locks across long-running operations without timeouts
Requirements
- .NET Standard 2.1 or higher, or .NET 8/9/10
- C# 7.3 or higher
Compatible With
- .NET Standard 2.1+
- .NET Core 3.0+
- .NET 5, 6, 7, 8, 9, 10+
- Xamarin (iOS 12.16+, Android 10.0+)
- Unity 2021.2+
Examples
See Examples.cs for comprehensive usage examples including:
- Basic synchronous and asynchronous locking
- Timeout handling
- Cancellation support
- Real-world scenarios (file processing, API calls, etc.)
- Performance testing
License
MIT
Contributing
Contributions are welcome! Please feel free to submit pull requests or open issues.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.1
-
net10.0
-
net8.0
-
net9.0
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
v1.1.0:
- Added multi-targeting support for .NET 8, .NET 9, and .NET 10
- Added IAsyncDisposable support for 'await using' syntax (.NET 8+)
- Added IsLocked() method for diagnostics
- Added GetActiveKeys() method for monitoring
- Added constructor overload with concurrencyLevel and initialCapacity parameters
- Added DI extension method for concurrency settings
- Improved robustness with ObjectDisposedException handling
- Performance improvements with readonly struct fields