Skip to content

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

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
// 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:

csharp
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:

csharp
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:

csharp
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:

csharp
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

csharp
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:

csharp
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

csharp
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

csharp
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

csharp
services.AddSingleton<ICacheClient, InMemoryCacheClient>();
services.AddSingleton<IMessageBus, InMemoryMessageBus>();

services.AddSingleton<ILockProvider>(sp =>
    new CacheLockProvider(
        sp.GetRequiredService<ICacheClient>(),
        sp.GetRequiredService<IMessageBus>()
    )
);

With Redis

csharp
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

csharp
// 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

csharp
// ✅ 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

csharp
// ✅ 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

csharp
// 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

csharp
// ✅ 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

csharp
var tenantLock = new ScopedLockProvider(baseLock, $"tenant:{tenantId}");
// All locks are isolated per tenant

Lock Information

Access lock metadata:

csharp
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

Released under the Apache 2.0 License.