This page shows how to take advantage of Redis's fast atomic server operations to enable high-performance distributed locks that can span across multiple app servers.
Achieving High Performance, Distributed Locking with Redis​
When you have a high-performance, scalable network data structure server like Redis accessible to your back end systems, a whole range of technical possibilities open up that were previously difficult to achieve. Something like multi-server-wide application-level locks were previously only achievable using dedicated, centralized infrastructure and the crafting of some carefully custom concurrent programming logic.
With Redis this becomes a trivial task as you get simplified access to rich atomic server operations that complete within a fraction of a millisecond. So the same normally CPU-intensive load generated by distributed locking when using a remote filesystem or RDBMS is barely noticeable on a Redis server.
ServiceStack's C# Redis Client takes advantage of the convenience and safety offered by .NET's IDisposable interface and Redis's SETNX operation to provide a simple API to implement your own custom distributed locks, ensuring at all times that only 1 client at a time can execute the protected logic. While one of the Redis clients obtains the lock, the other clients enter into an 'exponential retry back-off multiplier state' continually retrying to obtain the lock at random intervals until they are finally successful.
Simple API Usage​
The locking functionality is available on the IRedisClient and IRedisTypedClient interfaces, the relevant portion of which is displayed below.
public interface IRedisClient
{
...
IDisposable AcquireLock(string key);
IDisposable AcquireLock(string key, TimeSpan timeOut);
}
TIP
The above implementation does not include the ability to auto-recover from a crashed client, power or network failure, so under rare conditions it is possible for all clients to be deadlocked indefinitely waiting on a lock that is never released. In these cases it's wise to supply a TimeOut
or manually recover from 'zombie locks' by clearing them all on server restarts, etc.
To reiterate: if you specify a TimeOut
, the client will treat the lock as invalid once the timeout expires, and thus grab the lock.
Below are a couple examples showing how to use the API in some typical usage scenarios. The full runnable source code of the following examples are available here.
Example: Multiple clients acquiring the same lock​
The example below shows the behaviour of running 5 concurrent clients trying to acquire the same lock at the same time. An artificial delay is added inside the lock to simulate a cpu-intensive workload.
//The number of concurrent clients to run
const int noOfClients = 5;
var asyncResults = new List<IAsyncResult>(noOfClients);
for (var i = 1; i <= noOfClients; i++)
{
var clientNo = i;
var actionFn = (Action)delegate
{
var redisClient = new RedisClient(TestConfig.SingleHost);
using (redisClient.AcquireLock("testlock"))
{
Console.WriteLine("client {0} acquired lock", clientNo);
var counter = redisClient.Get<int>("atomic-counter");
//Add an artificial delay to demonstrate locking behaviour
Thread.Sleep(100);
redisClient.Set("atomic-counter", counter + 1);
Console.WriteLine("client {0} released lock", clientNo);
}
};
//Asynchronously invoke the above delegate in a background thread
asyncResults.Add(actionFn.BeginInvoke(null, null));
}
//Wait at most 1 second for all the threads to complete
asyncResults.WaitAll(TimeSpan.FromSeconds(1));
//Print out the 'atomic-counter' result
using (var redisClient = new RedisClient(TestConfig.SingleHost))
{
var counter = redisClient.Get<int>("atomic-counter");
Console.WriteLine("atomic-counter after 1sec: {0}", counter);
}
/*Output:
client 1 acquired lock
client 1 released lock
client 3 acquired lock
client 3 released lock
client 4 acquired lock
client 4 released lock
client 5 acquired lock
client 5 released lock
client 2 acquired lock
client 2 released lock
atomic-counter after 1sec: 5
*/
When you acquire a lock without specifying a TimeOut
as seen in the above example, each waiting client goes into an indefinite loop retrying to acquire the lock until its successful.
If by some chance you had some rogue code not following convention and implementing logic within the disposable scope or worse short circuiting execution using Thread.Abort
,
you could potentially run into a deadlock situation. This is why it is generally a good idea to specify a TimeOut
whenever you make a blocking call. So like all good blocking API's
the client lets you specify an optional TimeOut
parameter as seen in the following example:
Example: Acquiring a lock with Time Out​
var redisClient = new RedisClient(TestConfig.SingleHost);
//Initialize and set counter to '1'
redisClient.Increment("atomic-counter");
//Acquire lock and never release it
redisClient.AcquireLock("testlock");
var waitFor = TimeSpan.FromSeconds(2);
var now = DateTime.Now;
try
{
using (var newClient = new RedisClient(TestConfig.SingleHost))
{
//Attempt to acquire a lock with a 2 second timeout
using (newClient.AcquireLock("testlock", waitFor))
{
//If lock was acquired this would be incremented to '2'
redisClient.Increment("atomic-counter");
}
}
}
catch (TimeoutException tex)
{
var timeTaken = DateTime.Now - now;
Console.WriteLine("After '{0}', Received TimeoutException: '{1}'", timeTaken, tex.Message);
var counter = redisClient.Get<int>("atomic-counter");
Console.WriteLine("atomic-counter remains at '{0}'", counter);
}
/*Output:
After '00:00:02.3321334', Received TimeoutException: 'Exceeded timeout of 00:00:02'
atomic-counter remains at '1'
*/