Distributed Locking in .NET: Coordinating Work Across Multiple Instances

Distributed Locking in .NET: Coordinating Work Across Multiple Instances

5 min read ·

Diskless 2.0 combines Kafka's Tiered Storage and Diskless Topics into one simpler path with zero-copy migration. Cut costs, reduce complexity, and run faster, cloud-ready Kafka without broker disks for streamlined performance built for the next decade. Read the full blog here.

BoldSign API & SDKs for Developers - Add contract signing to your app in minutes with our .NET SDK. Fully embed sending and signing, control branding and emails, get transparent pricing, and start testing instantly in our free sandbox. Trusted by 40K+ businesses and recognized as the #1 developer-choice e-signature API.

When you build applications that run across multiple servers or processes, you eventually run into the problem of concurrent access. Multiple workers try to update the same resource at the same time, and you end up with race conditions, duplicated work, or corrupted data.

.NET provides excellent concurrency control primitives for single-process scenarios, like lock, SemaphoreSlim, and Mutex. But when your application is scaled out across multiple instances, these primitives don't work anymore.

That's where distributed locking comes in.

Distributed locking provides a solution by ensuring only one node (application instance) can access a critical section at a time, preventing race conditions and maintaining data consistency across your distributed system.

Why and When You Need Distributed Locking

In a single-process app, you can just use lock or the new Lock class in .NET 10. But once you scale out, that's not enough, because each process has its own memory space.

A few common cases where distributed locks are valuable:

  • Background jobs: ensuring only one worker processes a particular job or resource at a time.
  • Leader election: choosing a single process to perform periodic work (like applying async database projections).
  • Avoiding double execution: ensuring scheduled tasks don't run multiple times when deployed to multiple instances.
  • Coordinating shared resources: e.g., only one service instance performing a migration or cleanup at a time.
  • Cache stampede prevention: ensuring only one instance refreshes the cache when a given cache key expires.

The key value: consistency and safety across distributed environments. Without this, you risk duplicate operations, corrupted state, or unnecessary load.

Now you know why distributed locking is important.

Let's look at some implementation options.

DIY Distributed Locking with PostgreSQL Advisory Locks

Let's start simple. PostgreSQL has a feature called advisory locks that's perfect for distributed locking. Unlike table locks, these don't interfere with your data - they're purely for coordination.

Here's an example:

public class NightlyReportService(NpgsqlDataSource dataSource)
{
    public async Task ProcessNightlyReport()
    {
        await using var connection = dataSource.OpenConnection();

        var key = HashKey("nightly-report");

        var acquired = await connection.ExecuteScalarAsync<bool>(
            "SELECT pg_try_advisory_lock(@key)",
            new { key });

        if (!acquired)
        {
            throw new ConflictException("Another instance is already processing the nightly report");
        }

        try
        {
            await DoWork();
        }
        finally
        {
            await connection.ExecuteAsync(
                "SELECT pg_advisory_unlock(@key)",
                new { key });
        }
    }

    private static long HashKey(string key) =>
        BitConverter.ToInt64(SHA256.HashData(Encoding.UTF8.GetBytes(key)), 0);

    private static Task DoWork() => Task.Delay(5000); // Your actual work here
}

Here's what's happening under the hood.

First, we convert our lock name into a number. PostgreSQL advisory locks need numeric keys, so we hash nightly-report into a 64-bit integer. Every node (application instance) must generate the same number for the same string, or this won't work.

Next, pg_try_advisory_lock() attempts to grab an exclusive lock on that number. It returns true if successful, false if another connection already holds it. This call doesn't block - it tells you immediately whether you got the lock.

If we get the lock, we do our work. If not, we return a conflict response and let the other instance handle it.

The finally block ensures we always release the lock, even if something goes wrong. PostgreSQL also automatically releases advisory locks when connections close, which is a nice safety net.

SQL Server has a similar feature with sp_getapplock.

Exploring the DistributedLock Library

While the DIY approach works, production applications need more sophisticated features. The DistributedLock library handles the edge cases and provides multiple backend options (Postgres, Redis, SqlServer, etc.). You know I'm a fan of not reinventing the wheel, so this is a great choice.

Install the package:

Install-Package DistributedLock

I'll use the approach with IDistributedLockProvider which works nicely with DI. You can acquire a lock without having to know anything about the underlying infrastructure. All you have to do is register a lock provider implementation in your DI container.

For example, using Postgres:

// Register the distributed lock provider
builder.Services.AddSingleton<IDistributedLockProvider>(
    (_) =>
    {
        return new PostgresDistributedSynchronizationProvider(
            builder.Configuration.GetConnectionString("distributed-locking")!);
    });

Or if you want to use Redis with the Redlock algorithm:

// Requires StackExchange.Redis
builder.Services.AddSingleton<IConnectionMultiplexer>(
    (_) =>
    {
        return ConnectionMultiplexer.Connect(
            builder.Configuration.GetConnectionString("redis")!);
    });

// Register the distributed lock provider
builder.Services.AddSingleton<IDistributedLockProvider>(
    (sp) =>
    {
        var connectionMultiplexer = sp.GetRequiredService<IConnectionMultiplexer>();

        return new RedisDistributedSynchronizationProvider(connectionMultiplexer.GetDatabase());
    });

The usage is straightforward:

// You can also pass in a timeout, where the provider will keep retrying to acquire the lock
// until the timeout is reached.
IDistributedSynchronizationHandle? distributedLock = distributedLockProvider
    .TryAcquireLock("nightly-report");

// If we didn't get the lock, the object will be null
if (distributedLock is null)
{
    return Results.Conflict();
}

// It's important to wrap the lock in a using statement to ensure it's released properly
using (distributedLock)
{
    await DoWork();
}

The library handles all the tricky parts: timeouts, retries, and ensuring locks are released even in failure scenarios.

It also supports many backends (SQL Server, Azure, ZooKeeper, etc.), making it a solid choice for production workloads.

Wrapping Up

Distributed locking isn't something you need every day. But when you do, it saves you from subtle, painful bugs that only appear under load or in production.

Start simple: if you're already using Postgres, advisory locks are a powerful tool.

For a cleaner developer experience, reach for the DistributedLock library.

Choose the backend that fits your infrastructure (Postgres, Redis, SQL Server, etc.).

The right lock at the right time ensures your system stays consistent, reliable, and resilient, even across multiple processes and servers.


Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 4,200+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,100+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. (NEW) Pragmatic REST APIs: Join 1,200+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

Become a Better .NET Software Engineer

Join 72,000+ engineers who are improving their skills every Saturday morning.