Creating Distributed Lock With Redis In .NET Core

Introduction

In this article, we will discuss how to create a distributed lock with Redis in .NET Core.

When we build distributed systems, we will face multiple processes that handle a shared resource together. It will cause some unexpected problems due to the fact that only one of them can utilize the shared resource at a time!

We can use a distributed lock to solve this problem.

Why Distributed Lock?

As usual, we will use a lock to handle this problem.

The following shows some sample codes demonstrating the use of a lock.

public void SomeMethod()   
{   
    // Do something...   
    lock (obj)   
    {   
        // Do ....   
    }   
    // Do something...   
}

However, this type of lock cannot help us to work the problem well! This is an in-process lock that can only solve one process with shared resources.

This is also the main reason why we need a distributed lock!

I will use Redis to create a simple distributed lock here.

And why do I use Redis to do this job? Because of Redis's single-threaded nature and its ability to perform atomic operations.

How To Create A Lock?

I will create a .NET Core Console application to show you.

Before the next step, we should run up the Redis server!

.NET Core

StackExchange.Redis is the most popular Reids client on .NET, and there is no doubt that we will use it to do the following jobs.

Creating the connection with Redis at first.

/// <summary>  
/// The lazy connection.  
/// </summary>  
private static Lazy<ConnectionMultiplexer> lazyConnection = new Lazy<ConnectionMultiplexer>(() =>  
{  
    ConfigurationOptions configuration = new ConfigurationOptions  
    {  
        AbortOnConnectFail = false,  
        ConnectTimeout = 5000,  
    };  

    configuration.EndPoints.Add("localhost", 6379);  

    return ConnectionMultiplexer.Connect(configuration.ToString());  
});  

/// <summary>  
/// Gets the connection.  
/// </summary>  
/// <value>The connection.</value>  
public static ConnectionMultiplexer Connection => lazyConnection.Value;

In order to request a lock on a shared resource, we do the following.

SET resource_name unique_value NX PX duration

The resource_name is a value that all instances of your application would share.

The unique_value is something that must be unique to each instance of your application. And the purpose of this unique value is to remove the lock (unlock).

Finally, we also provide a duration (in milliseconds), after which the lock will be automatically removed by Redis.

Here is the implementation in C# code.

/// <summary>  
/// Acquires the lock.  
/// </summary>  
/// <returns><c>true</c>, if lock was acquired, <c>false</c> otherwise.</returns>  
/// <param name="key">Key.</param>  
/// <param name="value">Value.</param>  
/// <param name="expiration">Expiration.</param>  
static bool AcquireLock(string key, string value, TimeSpan expiration)  
{  
    bool flag = false;  

    try  
    {  
        flag = Connection.GetDatabase().StringSet(key, value, expiration, When.NotExists);  
    }  
    catch (Exception ex)  
    {  
        Console.WriteLine($"Acquire lock fail...{ex.Message}");  
        flag = true;  
    }  

    return flag;  
}  

Here is the code to test the acquire lock.

static void Main(string[] args)  
{  
    string lockKey = "lock:eat";  
    TimeSpan expiration = TimeSpan.FromSeconds(5);  

    // 5 person eat something...  
    Parallel.For(0, 5, x =>  
    {  
        string person = $"person:{x}";  
        bool isLocked = AcquireLock(lockKey, person, expiration);  

        if (isLocked)  
        {  
            Console.WriteLine($"{person} begin eat food (with lock) at {DateTimeOffset.Now.ToUnixTimeMilliseconds()}.");  
        }  
        else  
        {  
            Console.WriteLine($"{person} cannot eat food due to not getting the lock.");  
        }  
    });  

    Console.WriteLine("end");  
    Console.Read();  
}  

After running the code, we may get the following result.

Result

Only one person can get the lock! Others are waiting.

Although the lock will be automatically removed by Redis, it also does not make good use of the shared resource!

Because when a process finishes its job, it should let others use the resource, not wait endlessly!

So we also need to release the lock as well.

How To Release A Lock?

To release the lock, we just removed the item in Redis!

As for what we take in creating a lock, we need to match the unique value of the resource. This will make it safer to release the right lock.

When matching, we will delete the lock, which means that the unlock was successful. Otherwise, the unlock was unsuccessful.

We need to execute the get and del commands at a time, so we will use a Lua script to do this!

/// <summary>  
/// Releases the lock.  
/// </summary>  
/// <returns><c>true</c>, if lock was released, <c>false</c> otherwise.</returns>  
/// <param name="key">Key.</param>  
/// <param name="value">Value.</param>  
static bool ReleaseLock(string key, string value)  
{  
    string lua_script = @"  
    if (redis.call('GET', KEYS[1]) == ARGV[1]) then  
        redis.call('DEL', KEYS[1])  
        return true  
    else  
        return false  
    end  
    ";  

    try  
    {  
        var res = Connection.GetDatabase().ScriptEvaluate(lua_script,  
            new RedisKey[] { key },  
            new RedisValue[] { value });  
        return (bool)res;  
    }  
    catch (Exception ex)  
    {  
        Console.WriteLine($"ReleaseLock lock fail...{ex.Message}");  
        return false;  
    }  
}  

We should call this method when a process has finished.

When a process gets the lock and does not release the lock for some reason, other processes cannot wait until it is released. At this time, other processes should go ahead.

Here is a sample to deal with this scene.

Parallel.For(0, 5, x =>  
{  
    string person = $"person:{x}";  
    var val = 0;  
    bool isLocked = AcquireLock(lockKey, person, expiration);  

    while (!isLocked && val <= 5000)  
    {  
        val += 250;  
        System.Threading.Thread.Sleep(250);  
        isLocked = AcquireLock(lockKey, person, expiration);  
    }  

    if (isLocked)  
    {  
        Console.WriteLine($"{person} begin eat food (with lock) at {DateTimeOffset.Now.ToUnixTimeMilliseconds()}.");  
        if (new Random().NextDouble() < 0.6)  
        {  
            Console.WriteLine($"{person} release lock {ReleaseLock(lockKey, person)} {DateTimeOffset.Now.ToUnixTimeMilliseconds()}");  
        }  
        else  
        {  
            Console.WriteLine($"{person} do not release lock ....");  
        }  
    }  
    else  
    {  
        Console.WriteLine($"{person} begin eat food (without lock) at {DateTimeOffset.Now.ToUnixTimeMilliseconds()}.");  
    }  
});  

After running the sample, you may get the following result.

Running the sample

As you can see, persons 3 and 4 will go ahead without a lock.

Here is the source code you can find on my GitHub page.

Summary

This article introduced how to create distributed locks with Redis in .NET Core. And it's a basic version, you can improve based on your business.

I hope this helps you.