Sharing data safely in a multi threaded C#

Anton Baksheiev
3 min read6 days ago

--

1. Thread-Safe Data Structures

Concurrent Collections: Use thread-safe collections from System.Collections.Concurrent for scenarios where you need to share data across multiple threads.

  • ConcurrentDictionary<TKey, TValue>
  • ConcurrentQueue<T>
  • ConcurrentStack<T>
  • BlockingCollection<T> (which wraps around other collections and adds thread-safe operations)
using System.Collections.Concurrent;

var concurrentQueue = new ConcurrentQueue<int>();

// Producer thread
Task.Run(() =>
{
concurrentQueue.Enqueue(1);
});

// Consumer thread
Task.Run(() =>
{
if (concurrentQueue.TryDequeue(out int result))
{
Console.WriteLine(result);
}
});

2. Locks

lock Statement: The lock statement (or Monitor) is used to ensure that only one thread can access a critical section of code at a time.

private static readonly object _lock = new object();
private static int sharedValue;

// Thread 1
Task.Run(() =>
{
lock (_lock)
{
sharedValue++;
}
});

// Thread 2
Task.Run(() =>
{
lock (_lock)
{
Console.WriteLine(sharedValue);
}
});

Monitor Class: Provides more advanced control over synchronization compared to lock.

private static readonly object _lock = new object();
private static int sharedValue;

// Thread 1
Task.Run(() =>
{
Monitor.Enter(_lock);
try
{
sharedValue++;
}
finally
{
Monitor.Exit(_lock);
}
});

// Thread 2
Task.Run(() =>
{
Monitor.Enter(_lock);
try
{
Console.WriteLine(sharedValue);
}
finally
{
Monitor.Exit(_lock);
}
});

3. ReaderWriterLockSlim

ReaderWriterLockSlim: Provides a way to handle scenarios where multiple threads read data simultaneously but write data exclusively.

using System.Threading;

private static readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
private static int sharedValue;

// Thread for writing
Task.Run(() =>
{
_lock.EnterWriteLock();
try
{
sharedValue++;
}
finally
{
_lock.ExitWriteLock();
}
});

// Thread for reading
Task.Run(() =>
{
_lock.EnterReadLock();
try
{
Console.WriteLine(sharedValue);
}
finally
{
_lock.ExitReadLock();
}
});

4. Interlocked Class

Interlocked Class: Provides atomic operations for variables shared by multiple threads, useful for simple data types like integers.

using System.Threading;

private static int sharedValue;

// Thread 1
Task.Run(() =>
{
Interlocked.Increment(ref sharedValue);
});

// Thread 2
Task.Run(() =>
{
Console.WriteLine(Interlocked.CompareExchange(ref sharedValue, 0, 0));
});

5. Semaphore and SemaphoreSlim

  • Semaphore: Useful for controlling access to a resource pool with a limited number of slots.
using System.Threading;

private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
private static int sharedValue;

// Thread 1
Task.Run(async () =>
{
await _semaphore.WaitAsync();
try
{
sharedValue++;
}
finally
{
_semaphore.Release();
}
});

// Thread 2
Task.Run(async () =>
{
await _semaphore.WaitAsync();
try
{
Console.WriteLine(sharedValue);
}
finally
{
_semaphore.Release();
}
});

Best Practices

  • Minimize Lock Contention: Try to minimize the time spent within locked sections to avoid performance bottlenecks.
  • Avoid Deadlocks: Ensure that locks are always acquired and released in a consistent order to prevent deadlocks.
  • Prefer High-Level Concurrency Utilities: Use higher-level abstractions like Concurrent collections or Task parallelism where possible to avoid low-level synchronization issues.

By using these techniques and following best practices, you can safely share and manage data across multiple threads in C#.

Thank you for reading! If you have any questions or suggestions, feel free to connect with me on LinkedIn.

--

--

Anton Baksheiev

Software engineer specializing in quality and testing. AWS certified. Expert in .NET, databases, Docker, and TDD. Passionate about innovation and performance.