George Kosmidis

Microsoft MVP | Speaks of Azure, AI & .NET | Founder of Munich .NET
Building tomorrow @
slalom
slalom

Thread synchronisation with SemaphoreSlim and async delegate calls

by George Kosmidis / Published 7 years ago, modified 3 years ago

Although running a parallel foreach that makes async calls is a pretty usual task, I found my self in a complicated situation, when async delegate calls got in the way!

The situation arose while I was trying to write a MemoryCache manager for my application (the manager was actually a wrapper of MemoryCache Class).

The following gist file, is a basic structure of what I was trying to do: Ask MemoryCacheManager for an item with AddOrGetExisting by passing a key to find the object and a generic delegate Func task that actually returns the object.

public class SomeClass{
   
    private readonly ICacheManager _cacheManager;
    
    public void DoSomething(){
        Parallel.ForEach(list, async item =>{
            //...
            var item = await _cacheManager.AddOrGetExisting(item.Id, GetAllData);
            //...
        });
    }
    
    public async List<object> GetAllData(){
        //... 
        return await GetDataFromDb();
    }
}

public class MemoryCacheManager : ICacheManager {
    //...
    public async Task AddOrGetExisting(string key, Func task)
    {
        item = _cache.Get(key);
        if (item == null)
        {
            item = await task();
            _cache.Set(key, item, DateTime.Now.AddSeconds(SomeExpiration));
        }
        return (T)item;
    }
}

Since this is not thread safe, my first approach was to use the lock statement, and as a countermeasure for not being able to have awaitable calls inside a lock, I played around with Task.GetAwaiter().GetResult(). Ugly :/
Sychronous block on an asycrhonous task (that could be intensive) is never a good idea.
As Stephen Cleary states in his blog:

The code … will synchronously block until the task completes. As such, it is subject to the same old deadlock problems as Wait and Result…

“GetResult” actually means “check the task for errors”…

Finally, I found a better approach after reading a few things about thread sycrhonization. I end up with the following solution that uses a semaphore slim:

public class SomeClass{
    ...
    private readonly ICacheManager _cacheManager;
    ...
    public void DoSomething(){
        Parallel.ForEach(list, async item =>{
            ...
            var cachedItem = await _cacheManager.AddOrGetExisting(item.Id, () =>{return Task.FromResult(item)});
            ...
        });
    }    
}

public class MemoryCacheManager : ICacheManager {
    
    //...
    private static SemaphoreSlim slim = new SemaphoreSlim(1, 1);
    //...

    public async Task AddOrGetExisting(string key, Func task)
    {
        var item = _cache.Get(key);
        if (item == null)
        {
            await slim.WaitAsync(); 
            try
            {
                item = _cache.Get(key);
                if (item == null)
                {
                    item = await task();
                    _cache.Set(key, item, DateTime.Now.AddSeconds(SomeExpiration));
                }
            }
            finally
            {
                slim.Release();
            }
        }
        return (T)item;
    }
}

* As you can see, there are two _cache.Get(key), a pattern that is called double-check locking. In short, locks are expensive and there is no reason to get one, if the requested object exists in the cache. Read more about that here and here

This page is open source. Noticed a typo? Or something unclear?
Edit Page Create Issue Discuss
Microsoft MVP - George Kosmidis
Azure Architecture Icons - SVGs, PNGs and draw.io libraries