Skip to content

How to use AsyncKeyedLocker

Mark Cilia Vincenti edited this page Nov 10, 2024 · 5 revisions

You need to start off with creating an instance of AsyncKeyedLocker or AsyncKeyedLocker<T>. The recommended way is to use the latter, which is faster and consumes less memory. The former uses object and can be used to mix different types of objects.

Dependency injection

services.AddSingleton<AsyncKeyedLocker>();

or (recommended):

services.AddSingleton<AsyncKeyedLocker<string>>();

Variable instantiation

var asyncKeyedLocker = new AsyncKeyedLocker();

or (recommended):

var asyncKeyedLocker = new AsyncKeyedLocker<string>();

or if you would like to set the maximum number of requests for the semaphore that can be granted concurrently (set to 1 by default):

// using AsyncKeyedLockOptions
var asyncKeyedLocker1 = new AsyncKeyedLocker<string>(new AsyncKeyedLockOptions(maxCount: 2));

// using Action<AsyncKeyedLockOptions>
var asyncKeyedLocker2 = new AsyncKeyedLocker<string>(o => o.MaxCount = 2);

There are also AsyncKeyedLocker() constructors which accept the parameters of ConcurrentDictionary, namely the concurrency level, the capacity and the IEqualityComparer to use.

Pooling

Whenever a lock needs to be acquired for a key that is not currently being processed, an AsyncKeyedLockReleaser object needs to exist for that key and added to a ConcurrentDictionary. In order to reduce allocations having to create objects only to dispose of them shortly after, AsyncKeyedLock allows for object pooling. Whenever a new key is needed, it is taken from the pool (rather than created from scratch). If the pool is empty, a new object is created. This means that the pool will NOT throttle or limit the number of keys being concurrently processed. Once a key is no longer in use, the AsyncKeyedLockReleaser object is returned back to the pool, unless the pool is already full up.

Usage of the pool can lead to big performance gains, but under some circumstances it can also lead to inferior performance. If the pool is too small, the benefit from using the pool might be outweighed by the extra overhead from the pool itself. If, on the other hand, the pool is too big, then that's a number of objects in memory for nothing, consuming memory.

It is recommended to run benchmarks and tests if you intend on using pooling in an environment of high load in order to make sure that you choose an optimal pool size.

Setting the pool size can be done via the AsyncKeyedLockOptions in one of the overloaded constructors, such as this:

// using AsyncKeyedLockOptions
var asyncKeyedLocker1 = new AsyncKeyedLocker<string>(new AsyncKeyedLockOptions(poolSize: 100));

// using Action<AsyncKeyedLockOptions>
var asyncKeyedLocker2 = new AsyncKeyedLocker<string>(o => o.PoolSize = 100);

You can also set the initial pool fill (by default this is set to the pool size):

// using AsyncKeyedLockOptions
var asyncKeyedLocker = new AsyncKeyedLocker<string>(new AsyncKeyedLockOptions(poolSize: 100, poolInitialFill: 50));

// using Action<AsyncKeyedLockOptions>
var asyncKeyedLocker = new AsyncKeyedLocker<string>(o =>
{
	o.PoolSize = 100;
	o.PoolInitialFill = 50;
});

NOTE: Prior to AsyncKeyedLock 7.0.0, pooling was not enabled by default. Since 7.0.0, the default is a pool size of 20 and an initial fill of 1.

Locking

// without cancellation token
using (await asyncKeyedLocker.LockAsync(myObject))
{
	...
}

// with cancellation token
using (await asyncKeyedLocker.LockAsync(myObject, cancellationToken))
{
	...
}

You can also use timeouts with LockOrNull / LockOrNullAsync methods to set the maximum time to wait, either in milliseconds or as a TimeSpan.

In the case you need to use timeouts to instead give up if unable to obtain a lock by a certain amount of time, you can also use TryLockAsync methods which will call a Func<Task> or Action if the timeout is not expired, whilst returning a boolean representing whether or not it waited successfully.

There are also synchronous Lock and TryLock methods available.

If you would like to see how many concurrent requests there are for a semaphore for a given key:

int myRemainingCount = asyncKeyedLocker.GetRemainingCount(myObject);

If you would like to see the number of remaining threads that can enter the lock for a given key:

int myCurrentCount = asyncKeyedLocker.GetCurrentCount(myObject);

If you would like to check whether any request is using a specific key:

bool isInUse = asyncKeyedLocker.IsInUse(myObject);

Conditional locking

There are also overloaded methods called ConditionalLock and ConditionalLockAsync that accept a boolean parameter which, when set to false, will not do any locking. This can be useful either if you have a setting for enabling/disabling locking, or if you are using recursion given that the locks are not inherently reentrant.

double factorial = Factorial(number);

public static double Factorial(int number, bool isFirst = true)
{
  using (await _asyncKeyedLocker.ConditionalLockAsync("test123", isFirst))
  {
    if (number == 0)
      return 1;
    return number * Factorial(number-1, false);
  }
}