Locks
Locks ensure a resource is only accessed by one consumer at any given time. Foundatio provides distributed locking implementations through the ILockProvider interface.
The ILockProvider Interface
public interface ILockProvider
{
Task<ILock> AcquireAsync(string resource, TimeSpan? timeUntilExpires = null,
bool releaseOnDispose = true,
CancellationToken cancellationToken = default);
Task<bool> IsLockedAsync(string resource);
Task ReleaseAsync(string resource, string lockId);
Task ReleaseAsync(string resource);
Task RenewAsync(string resource, string lockId, TimeSpan? timeUntilExpires = null);
}
public interface ILock : IAsyncDisposable
{
Task RenewAsync(TimeSpan? timeUntilExpires = null);
Task ReleaseAsync();
string LockId { get; }
string Resource { get; }
DateTime AcquiredTimeUtc { get; }
TimeSpan TimeWaitedForLock { get; }
int RenewalCount { get; }
}Implementations
CacheLockProvider
Uses a cache client and message bus for distributed locking:
using Foundatio.Lock;
using Foundatio.Caching;
using Foundatio.Messaging;
var cache = new InMemoryCacheClient();
var messageBus = new InMemoryMessageBus();
var locker = new CacheLockProvider(cache, messageBus);
await using var @lock = await locker.AcquireAsync("my-resource");
if (@lock != null)
{
// Exclusive access to resource
await DoExclusiveWorkAsync();
}With Redis for production:
var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
var cache = new RedisCacheClient(o => o.ConnectionMultiplexer = redis);
var messageBus = new RedisMessageBus(o => o.Subscriber = redis.GetSubscriber());
var locker = new CacheLockProvider(cache, messageBus);ThrottlingLockProvider
Limits the number of operations within a time period:
using Foundatio.Lock;
var throttledLocker = new ThrottlingLockProvider(
cache,
maxHits: 10, // Maximum locks allowed
period: TimeSpan.FromMinutes(1) // Per time period
);
// Only allows 10 operations per minute across all instances
var @lock = await throttledLocker.AcquireAsync("api-rate-limit");
if (@lock != null)
{
await CallExternalApiAsync();
await @lock.ReleaseAsync();
}
else
{
// Rate limited
throw new TooManyRequestsException();
}ScopedLockProvider
Prefixes all lock keys with a scope:
using Foundatio.Lock;
var baseLock = new CacheLockProvider(cache, messageBus);
var tenantLock = new ScopedLockProvider(baseLock, "tenant:abc");
// Lock key becomes: "tenant:abc:resource-1"
await using var @lock = await tenantLock.AcquireAsync("resource-1");Basic Usage
Acquire and Release
var locker = new CacheLockProvider(cache, messageBus);
// Acquire lock
var @lock = await locker.AcquireAsync("my-resource");
if (@lock != null)
{
try
{
// Do exclusive work
await ProcessAsync();
}
finally
{
// Always release
await @lock.ReleaseAsync();
}
}Using Dispose Pattern
The recommended pattern uses await using for automatic release:
await using var @lock = await locker.AcquireAsync("my-resource");
if (@lock != null)
{
// Lock is automatically released when scope ends
await DoExclusiveWorkAsync();
}Non-Blocking Acquire
Check if lock was acquired:
await using var @lock = await locker.AcquireAsync("my-resource");
if (@lock == null)
{
// Resource is locked by another process
return;
}
// Got the lock
await DoWorkAsync();Blocking Acquire with Timeout
Wait for lock with cancellation:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
await using var @lock = await locker.AcquireAsync(
"my-resource",
cancellationToken: cts.Token
);
if (@lock != null)
{
await DoWorkAsync();
}
}
catch (OperationCanceledException)
{
// Timeout waiting for lock
_logger.LogWarning("Timed out waiting for lock");
}Lock Expiration
Setting Expiration
Locks expire automatically to prevent deadlocks:
// Lock expires after 5 minutes
await using var @lock = await locker.AcquireAsync(
"my-resource",
timeUntilExpires: TimeSpan.FromMinutes(5)
);Renewing Locks
For long-running operations, renew the lock:
await using var @lock = await locker.AcquireAsync(
"my-resource",
timeUntilExpires: TimeSpan.FromMinutes(1)
);
if (@lock != null)
{
// Do some work
await DoPartOneAsync();
// Renew lock for more time
await @lock.RenewAsync(TimeSpan.FromMinutes(1));
// Continue work
await DoPartTwoAsync();
}Automatic Renewal
For very long operations, set up automatic renewal:
await using var @lock = await locker.AcquireAsync("my-resource");
if (@lock == null) return;
using var cts = new CancellationTokenSource();
// Start renewal task
var renewTask = Task.Run(async () =>
{
while (!cts.Token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(30), cts.Token);
await @lock.RenewAsync(TimeSpan.FromMinutes(1));
}
});
try
{
await VeryLongRunningOperationAsync();
}
finally
{
cts.Cancel();
}Common Patterns
Singleton Processing
Ensure only one instance processes a resource:
public async Task ProcessOrderAsync(int orderId)
{
await using var @lock = await _locker.AcquireAsync($"order:{orderId}");
if (@lock == null)
{
_logger.LogInformation("Order {OrderId} is being processed elsewhere", orderId);
return;
}
// Only one instance processes this order
await DoProcessingAsync(orderId);
}Leader Election
Elect a single instance for a job:
public async Task RunAsLeaderAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await using var @lock = await _locker.AcquireAsync("leader:job-runner");
if (@lock != null)
{
_logger.LogInformation("This instance is now the leader");
// Keep renewing while leading
while (!ct.IsCancellationRequested)
{
await DoLeaderWorkAsync();
await @lock.RenewAsync();
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
else
{
// Not leader, wait and try again
await Task.Delay(TimeSpan.FromSeconds(30), ct);
}
}
}Preventing Duplicate Operations
public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
{
// Prevent duplicate orders for same customer
await using var @lock = await _locker.AcquireAsync(
$"create-order:{request.CustomerId}",
timeUntilExpires: TimeSpan.FromSeconds(30)
);
if (@lock == null)
{
throw new ConcurrencyException("Another order is being created");
}
// Check for recent duplicates
var recentOrder = await _db.GetRecentOrderAsync(request.CustomerId);
if (recentOrder != null && recentOrder.IsSimilar(request))
{
throw new DuplicateOrderException();
}
return await _db.CreateOrderAsync(request);
}Using TryUsingAsync Extension
Simplified pattern for lock-protected operations:
var success = await locker.TryUsingAsync(
"my-resource",
async ct =>
{
await DoExclusiveWorkAsync(ct);
},
timeUntilExpires: TimeSpan.FromMinutes(5),
cancellationToken
);
if (!success)
{
_logger.LogWarning("Could not acquire lock");
}Rate Limiting with ThrottlingLockProvider
API Rate Limiting
public class RateLimitedApiClient
{
private readonly ThrottlingLockProvider _throttler;
private readonly HttpClient _client;
public RateLimitedApiClient(ICacheClient cache, HttpClient client)
{
_throttler = new ThrottlingLockProvider(
cache,
maxHits: 100, // 100 requests
period: TimeSpan.FromMinutes(1) // per minute
);
_client = client;
}
public async Task<T> GetAsync<T>(string endpoint)
{
await using var @lock = await _throttler.AcquireAsync("external-api");
if (@lock == null)
{
throw new RateLimitExceededException();
}
var response = await _client.GetAsync(endpoint);
return await response.Content.ReadFromJsonAsync<T>();
}
}Per-User Rate Limiting
public async Task<IActionResult> ProcessRequest(string userId)
{
// 10 requests per minute per user
await using var @lock = await _throttler.AcquireAsync($"user:{userId}:api");
if (@lock == null)
{
return StatusCode(429, "Too many requests");
}
return Ok(await ProcessAsync());
}Dependency Injection
Basic Registration
services.AddSingleton<ICacheClient, InMemoryCacheClient>();
services.AddSingleton<IMessageBus, InMemoryMessageBus>();
services.AddSingleton<ILockProvider>(sp =>
new CacheLockProvider(
sp.GetRequiredService<ICacheClient>(),
sp.GetRequiredService<IMessageBus>()
)
);With Redis
services.AddSingleton<IConnectionMultiplexer>(
await ConnectionMultiplexer.ConnectAsync("localhost:6379")
);
services.AddSingleton<ICacheClient>(sp =>
new RedisCacheClient(o =>
o.ConnectionMultiplexer = sp.GetRequiredService<IConnectionMultiplexer>()
)
);
services.AddSingleton<IMessageBus>(sp =>
new RedisMessageBus(o =>
o.Subscriber = sp.GetRequiredService<IConnectionMultiplexer>().GetSubscriber()
)
);
services.AddSingleton<ILockProvider>(sp =>
new CacheLockProvider(
sp.GetRequiredService<ICacheClient>(),
sp.GetRequiredService<IMessageBus>()
)
);Multiple Lock Providers
// General-purpose locking
services.AddKeyedSingleton<ILockProvider>("general", (sp, _) =>
new CacheLockProvider(
sp.GetRequiredService<ICacheClient>(),
sp.GetRequiredService<IMessageBus>()
)
);
// Rate limiting
services.AddKeyedSingleton<ILockProvider>("throttle", (sp, _) =>
new ThrottlingLockProvider(
sp.GetRequiredService<ICacheClient>(),
maxHits: 100,
period: TimeSpan.FromMinutes(1)
)
);Best Practices
1. Always Handle Null Lock
// ✅ Good: Check for null
await using var @lock = await locker.AcquireAsync("resource");
if (@lock == null)
{
return; // or throw, or retry
}
// ❌ Bad: Assume lock acquired
await using var @lock = await locker.AcquireAsync("resource");
await DoWork(); // May not have the lock!2. Use Meaningful Lock Names
// ✅ Good: Descriptive, hierarchical
await locker.AcquireAsync($"order:process:{orderId}");
await locker.AcquireAsync($"user:{userId}:balance:update");
// ❌ Bad: Generic, ambiguous
await locker.AcquireAsync("lock1");
await locker.AcquireAsync("resource");3. Set Appropriate Expiration
// Match expiration to expected operation duration
await locker.AcquireAsync("quick-op", TimeSpan.FromSeconds(10));
await locker.AcquireAsync("long-op", TimeSpan.FromMinutes(5));4. Prefer await using Pattern
// ✅ Good: Automatic release
await using var @lock = await locker.AcquireAsync("resource");
// ⚠️ Be careful: Manual release required
var @lock = await locker.AcquireAsync("resource", releaseOnDispose: false);
try
{
await DoWork();
}
finally
{
await @lock.ReleaseAsync();
}5. Use Scoped Locks for Multi-Tenant
var tenantLock = new ScopedLockProvider(baseLock, $"tenant:{tenantId}");
// All locks are isolated per tenantLock Information
Access lock metadata:
await using var @lock = await locker.AcquireAsync("resource");
if (@lock != null)
{
Console.WriteLine($"Lock ID: {@lock.LockId}");
Console.WriteLine($"Resource: {@lock.Resource}");
Console.WriteLine($"Acquired: {@lock.AcquiredTimeUtc}");
Console.WriteLine($"Wait time: {@lock.TimeWaitedForLock}");
Console.WriteLine($"Renewals: {@lock.RenewalCount}");
}Next Steps
- Caching - Cache implementations used by locks
- Messaging - Message bus used for cache invalidation
- Resilience - Retry policies for lock acquisition