C# concurrent programming

Concurrent programming -Asynchronous vs. Multithreaded Code

Concurrent programming is a broad term and we should start with it by examining the difference between asynchronous methods and actual multithreading.

Although .NET Core uses Task to represent both concepts, there is a core difference in how it handles them internally.

Asynchronous methods run in the background while the calling thread is doing other work. This means that these methods are I/O bound, i.e. they spend most of their time in input and output operations, such as file or network access.

Whenever possible, it makes a lot of sense to use asynchronous I/O methods in favor of synchronous ones. In the meantime, the calling thread can handle user interaction in a desktop application or process other requests in a server application, instead of just idly waiting for the operation to complete.

You can read more about calling asynchronous methods using async and await in my Asynchronous Programming in C# using Async Await – Best Practices article for the September edition of the DNC Magazine.

CPU-bound methods require CPU cycles to do their work and can only run in the background using their own dedicated thread. The number of available CPU cores, limits the number of threads that can run in parallel. The operating system is responsible for switching between the remaining threads, giving them a chance to execute their code.

These methods still run concurrently, but not necessarily in parallel. This means that although the methods do not execute at the same time, one method can still execute in the middle of the other, which is paused during that time.

parallel-vs-concurrent-dotnet-core

 

Task Synchronization

If tasks are completely independent, the methods we just saw for coordinating them will suffice. However, as soon as they need to access shared data concurrently, additional synchronization is required in order to prevent data corruption.

Whenever two or more threads attempt to modify a data structure in parallel, data can quickly become inconsistent. The following snippet of code is one such example:

var counters = new Dictionary< int, int >();
 
if (counters.ContainsKey(key))
{
    counters[key] ++;
}
else
{
    counters[key] = 1;
}

When multiple threads execute the above code in parallel, a specific execution order of instructions in different threads can cause the data to be incorrect, e.g.:

  • Both threads check the condition for the same key value when it is not yet present in the collection.
  • As a result, they both enter the else block and set the value for this key to 1.
  • Final counter value will be 1 instead of 2, which would be the expected result if the threads would execute the same code consecutively.

Such blocks of code, which may only be entered by one thread at a time, are called critical sections. In C#, you can protect them by using the lock statement:

var counters = new Dictionary< int, int >();
 
lock (syncObject)
{
    if (counters.ContainsKey(key))
    {
        counters[key]++;
    }
    else
    {
        counters[key] = 1;
    }
}

For this approach to work, all threads must share the same syncObject as well. As a best practice, syncObjectshould be a private Object instance that is exclusively used for protecting access to a single critical section and cannot be accessed from outside.

The lock statement will allow only one thread to access the block of code inside it. It will block the next thread trying to access it until the previous one exits it. This will ensure that a thread will execute the complete critical section of code without interruptions by another thread. Of course, this will reduce the parallelism and slow down the overall execution of code, therefore you will want to minimize the number of critical sections and to make them as short as possible.

The lock statement is just a shorthand for using the Monitor class:

var lockWasTaken = false;
var temp = syncObject;
try
{
    Monitor.Enter(temp, ref lockWasTaken);
    // lock statement body
}
finally
{
    if (lockWasTaken)
    {
        Monitor.Exit(temp);
    }
}

Although most of the time you will want to use the lock statement, Monitor class can give you additional control when you need it. For example, you can use TryEnter() instead of Enter() and specify a timeout to avoid waiting indefinitely for the lock to release.

Other Synchronization Primitives

Monitor is just one of the many synchronization primitives in .NET Core. Depending on the scenario, others might be more suitable.

Mutex is a more heavyweight version of Monitor that relies on the underlying operating system. This allows it to synchronize access to a resource not only on thread boundaries, but even over process boundaries. Monitor is the recommended alternative over Mutex for synchronization inside a single process.

SemaphoreSlim and Semaphore can limit the number of concurrent consumers of a resource to a configurable maximum number, instead of to only a single one, as Monitor does. SemaphoreSlim is more lightweight than Semaphore, but restricted to only a single process. Whenever possible you should use SemaphoreSlim instead of Semaphore.

ReaderWriterLockSlim can differentiate between two different types of access to a resource. It allows unlimited number of readers to access the resource in parallel, and limits writers to a single access at a time. It is great for protecting resources that are thread safe for reading, but require exclusive access for modifying data.

AutoResetEventManualResetEvent and ManualResetEventSlim will block incoming threads, until they receive a signal (i.e. a call to Set()). Then the waiting threads will continue their execution. AutoResetEvent will only allow one thread to continue, before blocking again until the next call to Set(). ManualResetEvent and ManualResetEventSlimwill not start blocking threads again, until Reset() is called. ManualResetEventSlim is the recommended more lightweight version of the two.

Interlocked provides a selection of atomic operations that are a better alternative to locking and other synchronization primitives, when applicable:

// non-atomic operation with a lock
lock (syncObject)
{
    counter++;
}
// equivalent atomic operation that doesn't require a lock
Interlocked.Increment(ref counter);

Concurrent Collections

When a critical section is required only to ensure atomic access to a data structure, a specialized data structure for concurrent access might be a better and more performant alternative. For example, by using ConcurrentDictionaryinstead of Dictionary, the lock statement example can be simplified:

var counters = new ConcurrentDictionary< int, int >();
 
counters.TryAdd(key, 0);
lock (syncObject)
{
    counters[key]++;
}

Naively, one might even want to use the following:

counters.AddOrUpdate(key, 1, (oldKey, oldValue) => oldValue + 1);

However, the update delegate in the above method is executed outside the critical section. Therefore a second thread could still read the same old value as the first thread, before the first one has updated it, effectively overwriting the first thread’s update with its own value and losing one increment. Even concurrent collections are not immune to multithreading issues when used incorrectly.

Another alternative to concurrent collections, is immutable collections.

Similar to concurrent collections they are also thread safe, but the underlying implementation is different. Any operations that change the data structures do not modify the original instance. Instead, they return a changed copy and leave the original instance unchanged:

var original = new Dictionary< int, int >().ToImmutableDictionary();
var modified = original.Add(key, value);

Because of this, any changes to the collection in one thread are not visible to the other threads, as they still reference the original unmodified collection, which is the very reason why immutable collections are inherently thread safe.

Of course, this makes them useful for a different set of problems. They work best in cases, when multiple threads require the same input collection and then modify it independently, potentially with a final common step that merges the changes from all the threads. With regular collections, this would require creating a copy of the collection for each thread in advance.

posted @ 2018-02-05 07:43  逸朵  阅读(341)  评论(0编辑  收藏  举报