10
\$\begingroup\$

This class is an Async/Await wrapped Dictionary. Of course it doesn't technically implement IDictionary, but the functionality is basically the same as an IDictionary. It achieves similar functionality to ConcurrentDictionary but with async/await, and is non-blocking.

Note: please pay attention to the challenge. The challenge is to see if thread safety can be broken. There may not be a strong justification for the class's existence, but this is not the question. The question is: does it stand up to testing?

Code Is Here

public class AsyncDictionary<TKey, TValue> : IAsyncDictionary<TKey, TValue>, IDisposable
{
    #region Fields
    private readonly IDictionary<TKey, TValue> _dictionary;
    private readonly SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(1, 1);
    private bool disposedValue = false;
    #endregion

    #region Func
    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>> ContainsKeyFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>>((dictionary, keyValuePair) =>
    {
        return Task.FromResult(dictionary.ContainsKey(keyValuePair.Key));
    });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>> ClearFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>>((dictionary, keyValuePair) =>
   {
       dictionary.Clear();
       return Task.FromResult(true);
   });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<int>> GetCountFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<int>>((dictionary, keyValuePair) =>
   {
       return Task.FromResult(dictionary.Count);
   });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<ICollection<TValue>>> GetValuesFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<ICollection<TValue>>>((dictionary, keyValuePair) =>
   {
       return Task.FromResult(dictionary.Values);
   });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<ICollection<TKey>>> GetKeysFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<ICollection<TKey>>>((dictionary, keyValuePair) =>
   {
       return Task.FromResult(dictionary.Keys);
   });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>> AddFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>>((dictionary, keyValuePair) =>
   {
       dictionary.Add(keyValuePair);
       return Task.FromResult(true);
   });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>> AddOrReplaceFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>>((dictionary, keyValuePair) =>
   {
       if (dictionary.ContainsKey(keyValuePair.Key))
       {
           dictionary[keyValuePair.Key] = keyValuePair.Value;
       }
       else
       {
           dictionary.Add(keyValuePair.Key, keyValuePair.Value);
       }

       return Task.FromResult(true);
   });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>> ContainsItemFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>>((dictionary, keyValuePair) =>
   {
       return Task.FromResult(dictionary.Contains(keyValuePair));
   });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>> RemoveFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>>((dictionary, keyValuePair) =>
   {
       return Task.FromResult(dictionary.Remove(keyValuePair));
   });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>> RemoveByKeyFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<bool>>((dictionary, keyValuePair) =>
   {
       return Task.FromResult(dictionary.Remove(keyValuePair.Key));
   });

    private static readonly Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<TValue>> GetValueFunc = new Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<TValue>>((dictionary, keyValuePair) =>
   {
       return Task.FromResult(dictionary[keyValuePair.Key]);
   });
    #endregion

    #region Constructor
    public AsyncDictionary()
    {
        //Note: the constructor overload to allow passing in a different Dictionary type has been removed to disallow unsynchronized access. It can be added if you're careful.
        _dictionary = new Dictionary<TKey, TValue>();
    }

    /// <summary>
    /// This overload is used in cases where a standard Dictionary isn't the right choice. Warning: accessing the Dictionary outside this class will break synchronization
    /// </summary>
    //public AsyncDictionary(IDictionary<TKey, TValue> dictionary)
    //{
    //    _dictionary = dictionary;
    //}
    #endregion

    #region Implementation
    //Only when C# 8 comes!
    //TODO: IEnumerator<KeyValuePair<T1, T2>> GetEnumerator()
    //TODO: IEnumerator IEnumerable.GetEnumerator()

    public Task<ICollection<TKey>> GetKeysAsync()
    {
        return CallSynchronizedAsync(GetKeysFunc, default);
    }

    public Task<ICollection<TValue>> GetValuesAsync()
    {
        return CallSynchronizedAsync(GetValuesFunc, default);
    }

    public Task<int> GetCountAsync()
    {
        return CallSynchronizedAsync(GetCountFunc, default);
    }

    public Task AddAsync(TKey key, TValue value)
    {
        return CallSynchronizedAsync(AddFunc, new KeyValuePair<TKey, TValue>(key, value));
    }

    public Task AddAsync(KeyValuePair<TKey, TValue> item)
    {
        return CallSynchronizedAsync(AddFunc, item);
    }

    public Task AddOrReplaceAsync(TKey key, TValue value)
    {
        return CallSynchronizedAsync(AddOrReplaceFunc, new KeyValuePair<TKey, TValue>(key, value));
    }

    public Task ClearAsync()
    {
        return CallSynchronizedAsync(ClearFunc, default);
    }

    public Task<bool> GetContainsAsync(KeyValuePair<TKey, TValue> item)
    {
        return CallSynchronizedAsync(ContainsItemFunc, item);
    }

    public Task<bool> GetContainsKeyAsync(TKey key)
    {
        return CallSynchronizedAsync(ContainsKeyFunc, new KeyValuePair<TKey, TValue>(key, default));
    }

    public Task<bool> RemoveAsync(TKey key)
    {
        return CallSynchronizedAsync(RemoveByKeyFunc, new KeyValuePair<TKey, TValue>(key, default));
    }

    public Task<bool> RemoveAsync(KeyValuePair<TKey, TValue> item)
    {
        return CallSynchronizedAsync(RemoveFunc, item);
    }

    public Task<TValue> GetValueAsync(TKey key)
    {
        return CallSynchronizedAsync(GetValueFunc, new KeyValuePair<TKey, TValue>(key, default));
    }
    #endregion

    #region Private Methods
    private async Task<TReturn> CallSynchronizedAsync<TReturn>(Func<IDictionary<TKey, TValue>, KeyValuePair<TKey, TValue>, Task<TReturn>> func, KeyValuePair<TKey, TValue> keyValuePair)
    {
        try
        {
            await _semaphoreSlim.WaitAsync();

            return await Task.Run(async () =>
            {
                return await func(_dictionary, keyValuePair);
            });
        }
        finally
        {
            _semaphoreSlim.Release();
        }
    }
    #endregion

    #region IDisposable Support
    protected virtual void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                _semaphoreSlim.Dispose();
            }

            disposedValue = true;
        }
    }

    public void Dispose()
    {
        Dispose(true);
    }
    #endregion
}

I set up some some unit tests here, but I'd like to see if anyone can break the thread safety of this.

Can you add to the unit tests? Can you make the dictionary return the wrong results? Can you cause an exception that shouldn't come up from normal use of this class? Can you detect any other concurrency issues? Can find any other bugs

Note: PRs are more than welcome, and the more unit tests, the better!

Code Is Here

public class AsyncDictionaryTests
{
    #region Fields
    private const int max = 800;
    #endregion

    #region Tests
    [Test]
    public async Task TestAddAndRetrieveKeys()
    {
        var asyncDictionary = new AsyncDictionary<int, string>();
        const int key = 1;
        await asyncDictionary.AddAsync(key, key.ToString());
        var keys = (await asyncDictionary.GetKeysAsync()).ToList();
        Assert.AreEqual(key, keys[0]);
    }

    [Test]
    public async Task TestAddAndRetrieveValues()
    {
        var asyncDictionary = new AsyncDictionary<int, string>();
        const int key = 1;
        var value = key.ToString();
        await asyncDictionary.AddAsync(key, value);
        var values = (await asyncDictionary.GetValuesAsync()).ToList();
        Assert.AreEqual(value, values[0].ToString());
    }

    [Test]
    public async Task TestContainsKey()
    {
        var asyncDictionary = new AsyncDictionary<int, string>();
        const int key = 1;
        await asyncDictionary.AddAsync(key, key.ToString());
        var contains = await asyncDictionary.GetContainsKeyAsync(key);
        Assert.True(contains);
    }


    [Test]
    public async Task TestContains()
    {
        var asyncDictionary = new AsyncDictionary<int, string>();
        const int key = 1;
        var value = key.ToString();
        var kvp = new KeyValuePair<int, string>(key, value);
        await asyncDictionary.AddAsync(kvp);
        var contains = await asyncDictionary.GetContainsAsync(kvp);
        Assert.True(contains);
    }

    [Test]
    public async Task TestRemoveByKey()
    {
        var asyncDictionary = new AsyncDictionary<int, string>();
        const int key = 1;
        await asyncDictionary.AddAsync(key, key.ToString());
        var contains = await asyncDictionary.GetContainsKeyAsync(key);
        Assert.True(contains);
        await asyncDictionary.RemoveAsync(key);
        contains = await asyncDictionary.GetContainsKeyAsync(key);
        Assert.False(contains);
    }

    [Test]
    public async Task TestRemove()
    {
        var asyncDictionary = new AsyncDictionary<int, string>();
        const int key = 1;
        var kvp = new KeyValuePair<int, string>(key, key.ToString());
        await asyncDictionary.AddAsync(kvp);
        var contains = await asyncDictionary.GetContainsKeyAsync(key);
        Assert.True(contains);
        await asyncDictionary.RemoveAsync(kvp);
        contains = await asyncDictionary.GetContainsKeyAsync(key);
        Assert.False(contains);
    }

    [Test]
    public async Task TestGetValue()
    {
        var asyncDictionary = new AsyncDictionary<int, string>();
        const int key = 1;
        await asyncDictionary.AddAsync(key, key.ToString());
        var value = await asyncDictionary.GetValueAsync(key);
        Assert.AreEqual(key.ToString(), value);
    }

    [Test]
    public async Task TestClear()
    {
        var asyncDictionary = new AsyncDictionary<int, string>();
        const int key = 1;
        var value = key.ToString();
        await asyncDictionary.AddAsync(key, value);
        await asyncDictionary.ClearAsync();
        var values = (await asyncDictionary.GetValuesAsync()).ToList();
        Assert.IsEmpty(values);
    }

    [Test]
    public async Task TestAnotherType()
    {
        var asyncDictionary = new AsyncDictionary<string, Thing>();
        var thing = new Thing { Name="test", Size=100 };
        await asyncDictionary.AddAsync(thing.Name, thing);
        var newthing = await asyncDictionary.GetValueAsync(thing.Name);
        Assert.True(ReferenceEquals(thing, newthing));
    }

    [Test]
    public async Task TestThreadSafety()
    {
        var asyncDictionary = new AsyncDictionary<int, string>();

        var tasks = new List<Task> { AddKeyValuePairsAsync(asyncDictionary), asyncDictionary.ClearAsync(), AddKeyValuePairsAsync(asyncDictionary) };

        await Task.WhenAll(tasks);

        tasks = new List<Task> { AddKeyValuePairsAsync(asyncDictionary), AddKeyValuePairsAsync(asyncDictionary), AddKeyValuePairsAsync(asyncDictionary) };

        await Task.WhenAll(tasks);

        tasks = new List<Task> { DoTestEquality(asyncDictionary), DoTestEquality(asyncDictionary), DoTestEquality(asyncDictionary), DoTestEquality(asyncDictionary), AddKeyValuePairsAsync(asyncDictionary) };

        await Task.WhenAll(tasks);
    }
    #endregion

    #region Helpers
    private static async Task DoTestEquality(AsyncDictionary<int, string> asyncDictionary)
    {
        var tasks = new List<Task>();
        for (var i = 0; i < max; i++)
        {
            tasks.Add(TestEquality(asyncDictionary, i));
        }

        await Task.WhenAll(tasks);
    }

    private static async Task TestEquality(AsyncDictionary<int, string> asyncDictionary, int i)
    {
        var expected = i.ToString();
        var actual = await asyncDictionary.GetValueAsync(i);

        Console.WriteLine($"Test Equality Expected: {expected} Actual: {actual}");

        Assert.AreEqual(expected, actual);
    }

    private static async Task AddKeyValuePairsAsync(AsyncDictionary<int, string> asyncDictionary)
    {
        var tasks = AddSome(asyncDictionary);
        await Task.WhenAll(tasks);
    }

    private static List<Task> AddSome(AsyncDictionary<int, string> asyncDictionary)
    {
        var tasks = new List<Task>();
        for (var i = 0; i < max; i++)
        {
            tasks.Add(AddByNumber(asyncDictionary, i));
        }

        return tasks;
    }

    private static Task AddByNumber(AsyncDictionary<int, string> asyncDictionary, int i)
    {
        return asyncDictionary.AddOrReplaceAsync(i, i.ToString());
    }
    #endregion
}

To see a UWP sample application, please clone the repo and run the sample there.

Notes: this class is designed for: maintainability first, concurrency, and flexibility. It is modeled after IDictionary but embraces the async-await paradigm. It comes after years of frustration in trying to synchronise cache in async-await C# apps while trying to avoid blocking calls.

It is heavily based on SemaphoreSlim with a maximum request concurrency of 1. Experience seems to indicate that this class behaves in a FIFO manner. However, the notes on SemaphoreSlim are a little worrying:

If multiple threads are blocked, there is no guaranteed order, such as FIFO or LIFO, that controls when threads enter the semaphore.

Is this an Achilles heal? The SemaphoreSlim code can be found here.

Can you create a scenario where the FIFO is not honored in a way that breaks the functionality of the class?

Conclusion: the marked answer exploits a mistake in the original code to break thread safety. However, the exercise was informative, as the larger question arose: what would the point of this class be? From my naieve perspective, it's designed in such a way that beginner programmers could use it, and likely achieve success with thread safety which is a little less complex than using ConcurrentDictionary, and uses the async-await pattern. Would this approach be recommended? Certainly not for performance reasons. But, the question would need to be asked in a different way to determine whether this class is useful or not.

\$\endgroup\$
3
  • 4
    \$\begingroup\$ Please do not update the code in your question to incorporate feedback from answers, doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. \$\endgroup\$
    – Mast
    Commented Aug 12, 2019 at 10:02
  • 5
    \$\begingroup\$ Revision 9 of this question is under discussion on meta \$\endgroup\$
    – Vogel612
    Commented Aug 13, 2019 at 11:28
  • \$\begingroup\$ Additionally, comments about the question have been moved to chat \$\endgroup\$
    – Vogel612
    Commented Aug 13, 2019 at 11:34

4 Answers 4

9
\$\begingroup\$

If you modify the AsyncDictionary while enumerating its keys/values it throws InvalidOperationException (if the backing dictionary is a Dictionary).

var numbers = new AsyncDictionary<int, int>();

foreach(var number in Enumerable.Range(1, 1000))
{
    await numbers.AddAsync(number, number);
}

foreach(var number in await numbers.GetKeysAsync())
{
    await numbers.RemoveAsync(number);
}

A ConcurrentDictionary handles this scenario just fine.

\$\endgroup\$
4
  • \$\begingroup\$ Oooh. Nice one. \$\endgroup\$ Commented Aug 12, 2019 at 9:55
  • \$\begingroup\$ I fixed the code with a ToList() . I also added your code @johnbot to the unit tests \$\endgroup\$ Commented Aug 12, 2019 at 10:00
  • \$\begingroup\$ No, adding ToList() at calling point won't make it thread-safe. Side question: while all those static methods are...fields?! \$\endgroup\$ Commented Aug 12, 2019 at 14:09
  • \$\begingroup\$ @Johnbot you found a glitch in the code and broke the thread safety. I believe you succeeded in the challenge. I was hoping to see if there were more fundamental issues that could break the thread safety, but you definitely achieved the aim. \$\endgroup\$ Commented Aug 12, 2019 at 21:57
24
\$\begingroup\$

The reason why there is no async API for a dictionary is, that all operations on a dictionary are so fast, that there is no need for asynchronicity.

For concurrent scenarios there is the thread safe variant - the ConcurrentDictionary.

Adding an async API to these dictionaries has absolutely zero value. Rather it increases complexity and reduces performance.

\$\endgroup\$
7
  • 2
    \$\begingroup\$ I'm starting to wonder if the above is true or not. \$\endgroup\$ Commented Aug 11, 2019 at 8:56
  • 5
    \$\begingroup\$ No developer is immune to getting lost in technical solutions that have no resistance at the end of the day. I think it is not even a question of experience / professionalism but rather a question of sharing ideas with colleagues / others. And even that may produce questionable solutions ;) \$\endgroup\$
    – JanDotNet
    Commented Aug 11, 2019 at 9:18
  • 1
    \$\begingroup\$ I think that what you have said, along with another answer are strong points that need to be taken in to consideration. I think to some extent, the question in my mind: "Why didn't the C# team create this?" has been answered to a large extent. But this wasn't the original challenge. The original challenge was to break the functionality of the class. The question of whether this class is useful or not is a separate question. I'm leaning toward thinking that it is not. However, IF it is not, I will explore that as a separate question. \$\endgroup\$ Commented Aug 11, 2019 at 22:59
  • 1
    \$\begingroup\$ @MelbourneDeveloper: If you could batch up a bundle of work (like multiple key/value pairs to add), getting that all done while holding the lock once could be good for throughput vs. using atomic RMW operations for each one. (And bad for latency and maybe fairness if you aren't careful.) Or if you're getting false sharing with a concurrent hash table (threads on different cores modifying nearby entries), that could be worth trying to do something about. But inter-thread communication with callbacks is probably more expensive than inter-core latency for one cache miss. \$\endgroup\$ Commented Aug 12, 2019 at 6:45
  • 1
    \$\begingroup\$ @MelbourneDeveloper - It is not efficient. The point of async is to wait on IO operations, because they are really, really slow (compared to the CPU anyway) and do not involve your thread. The idea is that during the IO operation your thread can do something else more worthwhile. Reading/writing to a dictionary however is pure memory shuffling, no IO whatsoever. And it all involves your thread. There's no moment when you could "wait" for something to happen in the background. Queueing some work to run in a background thread (Task.Run()) is purely artificial and unnecessary. \$\endgroup\$
    – Vilx-
    Commented Aug 12, 2019 at 16:14
13
\$\begingroup\$

Threading Design

Your implementation has a very intrusive lock for all read and write operations, using the SemaphoreSlim with max concurrency 1.

try
{
    await _semaphoreSlim.WaitAsync()// <- both read/write operations acquire single mutex

    return await Task.Run(async () =>
    {
        return await func(_dictionary, keyValuePair);
    });
}
finally
{
    _semaphoreSlim.Release();
}

CallSynchronizedAsync is hence thread-safe to the extend you introduce possible fairness issues. In .NET, multiple threads awaiting a lock to get signaled are not notified in a fair way, meaning thread B may get the lock before A even if A asked to acquire the lock before B.

ConcurrentDictionary mitigates the risk on unfair thread signaling by using the following behavior:

Perhaps you should wrap a ConcurrentDictionary with async/await Task.Run or Task.FromResult functionality to take advantage of the built-in locking mechanism.


API Design

After discussing a bit with you in the comments, it became apparent you would like normal dictionary method signatures. This should not be a problem. Let's say you have a private field of type concurrent dictionary _dictionary. One option is to call the code synchronously and return Task.CompletedTask to make it fit the async/await pattern.

public async Task AddAsync(TKey key, TValue value)
{
    _dictionary.TryAdd(key, value);
    return Task.CompletedTask;
}

You could decide to also provide async/await-aware methods with concurrent dictionary signatures:

public async Task<bool> TryAddAsync(TKey key, TValue value)
{
    return Task.FromResult(_dictionary.TryAdd(key, value));
}
\$\endgroup\$
13
  • 1
    \$\begingroup\$ This post explains thread fairness: stackoverflow.com/questions/4228864/…. \$\endgroup\$
    – dfhwze
    Commented Aug 11, 2019 at 6:56
  • 2
    \$\begingroup\$ sorry, it looks like I was wrong. ConcurrentDictionary does implement IDictionary so I'm rethinking some of my assumptions. Have a look at the diff in this branch. The unit test still passes BTW: github.com/MelbourneDeveloper/AsyncDictionary/compare/… \$\endgroup\$ Commented Aug 11, 2019 at 8:56
  • 2
    \$\begingroup\$ There's literally (yes I did look up the meaning I'm a dictionary once ;)) no reason to ever wrap a short-lived synchronous API call such as adding to a (concurrent) dictionary in Task.Run. That's simply bad design. On a slightly related note: Most people use ConcurrentDictionary in circumstances where a simple dictionary with lock would be faster - just goes to show how complicated performance matters can be. \$\endgroup\$
    – Voo
    Commented Aug 11, 2019 at 21:55
  • 1
    \$\begingroup\$ @Voo my example wraps in Task.FromResult, not Task.Run. Would that be good practice? \$\endgroup\$
    – dfhwze
    Commented Aug 12, 2019 at 4:21
  • 1
    \$\begingroup\$ @dfhwze Task.FromResult is there to allow synchronous implementations to implement asynchronous interfaces easily and with minimal overhead. Given that you control both implementation and interface there's no need for it either. \$\endgroup\$
    – Voo
    Commented Aug 12, 2019 at 6:03
12
\$\begingroup\$

It's pretty hard to break something that uses a global lock around everything. So this seems pretty thread-safe. But that doesn't answer the question of why you'd want to use this.

Asynchronous calls are useful for things that take a long time, particularly if you can delegate the "waiting" to some low-level event based solution (HTTP requests for example).

For anything else, they not only make for a very awkward use of the API, they also are rather slow and involve a lot of compiler generated machinery (which if you're using it for a long running task won't matter much, but for something as simple as adding to a dictionary is plain awful).

When optimising, it's always important to first figure out what you want to optimise (what scenarios are you particularly worried about? how many readers to writers? how much 'global' concurrency on the dictionary? how much races on existing keys?) and then to measure.

Just as a baseline here is the overhead your solution has when simply adding elements sequentially compared to a concurrent dictionary, the standard dictionary and the standard dictionary with a simple lock around it:

|            Method |       N |        Mean |      Error |     StdDev | Ratio |
|------------------ |-------- |------------:|-----------:|-----------:|------:|
|         AddSimple | 1000000 |    34.94 ms |  0.4827 ms |  0.4515 ms |  1.00 |
|                   |         |             |            |            |       |
| AddSimpleWithLock | 1000000 |    52.14 ms |  0.7828 ms |  0.7323 ms |  1.00 |
|                   |         |             |            |            |       |
|     ConcurrentDic | 1000000 |   241.46 ms |  1.2975 ms |  1.2137 ms |  1.00 |
|                   |         |             |            |            |       |
|          AddAsync | 1000000 | 3,214.79 ms | 30.9326 ms | 28.9344 ms |  1.00 |

The following code was used to generate the results. If you haven't used BenchmarkDotNet before it's recommended to read up on it first to avoid getting incorrect results, but you can easily extend it for your other more interesting scenarios. In any case, the results are even worse than I imagined them to be (15 times slower than the concurrent dictionary is impressive even with all the boxing and async overhead - I guessed it'd be about 10 times at the start). (What shouldn't come as a surprise is how cheap uncontended locks are these days, everyone always running to ConcurrentDictionary and co might want to rethink that).

[ClrJob(baseline: true)]
public class DictionaryComparison
{
    private Random _rand;

    private Dictionary<int, int> _simpleDic;

    private Dictionary<int, int> _simpleDicWithLock;

    private readonly object _lock = new object();

    private ConcurrentDictionary<int, int> _concurrentDic;

    private AsyncDictionary<int, int> _asyncDictionary;

    [Params( 1_000_000)]
    public int N;

    [IterationSetup]
    public void IterationSetup()
    {
        _rand = new Random(0xdead);
        _simpleDic = new Dictionary<int, int>();
        _simpleDicWithLock = new Dictionary<int, int>();
        _concurrentDic = new ConcurrentDictionary<int, int>();
        _asyncDictionary = new AsyncDictionary<int, int>();
    }

    [Benchmark]
    public void AddSimple()
    {
        for (int i = 0; i < N; i++)
        {
            _simpleDic[i] = i;
        }
    }

    [Benchmark]
    public void AddSimpleWithLock()
    {
        for (int i = 0; i < N; i++)
        {
            lock (_lock)
            {
                _simpleDicWithLock[i] = i;
            }
        }
    }

    [Benchmark]
    public void ConcurrentDic()
    {
        for (int i = 0; i < N; i++)
        {
            _concurrentDic[i] = i;
        }
    }

    [Benchmark]
    public async Task AddAsync()
    {
        for (int i = 0; i < N; i++)
        {
            await _asyncDictionary.AddAsync(i, i);
        }
    }

}


public sealed class Program
{
    private static async Task Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<DictionaryComparison>();
    }
}

PS: The work is so little even when adding a million elements, that the results are a bit dubious for the two AddSimple variants.

\$\endgroup\$
6
  • \$\begingroup\$ This is very valuable information. I thank you for your time and rigour. I wasn't naive enough to think that this class would outperform ConcurrentDictionary. You've also introduced me to a useful technology BenchmarkDotNet and have made me question some of my original assumptions. \$\endgroup\$ Commented Aug 11, 2019 at 22:50
  • \$\begingroup\$ What about performance of the async wrapper of ConcurrentDic I presented? \$\endgroup\$
    – dfhwze
    Commented Aug 12, 2019 at 3:56
  • \$\begingroup\$ @dfhwze The takeaway here should be to not introduce asynchronous wrapper APIs on top of synchronous APIs. Asynchronous code is mostly useful if you don't have to block a thread during the waiting process (http calls or timers are two classic examples). There simply is no benefit to be gained from wrapping here. \$\endgroup\$
    – Voo
    Commented Aug 12, 2019 at 4:32
  • \$\begingroup\$ I thought the whole point of this question was to have an async signature for dictionary operations :-( \$\endgroup\$
    – dfhwze
    Commented Aug 12, 2019 at 4:35
  • 4
    \$\begingroup\$ @dfhwze "They were so preoccupied with whether or not they could, they didn’t stop to think if they should." ;) It looks like a classical XY problem. \$\endgroup\$
    – Voo
    Commented Aug 12, 2019 at 6:05

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.