.NET Thread Synchronization
Introduction
When working with multiple threads in .NET applications, ensuring they work together harmoniously is crucial for maintaining data integrity and preventing unexpected behaviors. Thread synchronization refers to the coordination of threads to prevent them from accessing shared resources simultaneously, which could lead to race conditions, data corruption, or other concurrency issues.
In this tutorial, you'll learn about:
- Why thread synchronization is necessary
- Common synchronization mechanisms in .NET
- How to implement these mechanisms in your code
- Best practices for thread synchronization
Why Synchronization Matters
Consider this simple scenario: Two threads try to increment the same counter variable at the same time.
public class UnsafeCounter
{
private int _count = 0;
public void Increment()
{
_count++; // This is not atomic!
}
public int GetCount()
{
return _count;
}
}
If two threads call Increment()
simultaneously, the expected result would be that _count
increases by 2. However, the increment operation (_count++
) is not atomic—it involves reading the current value, adding 1, and storing the result back. If thread A and thread B both read _count
as 0, they'll both write back 1, resulting in a final value of 1 instead of 2.
This is called a race condition, and it's just one example of why thread synchronization is necessary.
Common Synchronization Mechanisms in .NET
1. The lock
Statement
The lock
statement is the most basic synchronization mechanism in C#. It ensures that only one thread can execute a block of code at a time.
public class SafeCounter
{
private int _count = 0;
private readonly object _lockObject = new object();
public void Increment()
{
lock (_lockObject)
{
_count++;
}
}
public int GetCount()
{
lock (_lockObject)
{
return _count;
}
}
}
Here's how to use it:
static void Main()
{
SafeCounter counter = new SafeCounter();
// Create and start 10 threads that increment the counter
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.Length; i++)
{
threads[i] = new Thread(() =>
{
for (int j = 0; j < 1000; j++)
{
counter.Increment();
}
});
threads[i].Start();
}
// Wait for all threads to complete
foreach (var thread in threads)
{
thread.Join();
}
// Output: 10000 (always correct)
Console.WriteLine($"Final count: {counter.GetCount()}");
}
2. Monitor Class
The lock
statement is actually a syntactic sugar for the Monitor
class, which provides more control:
public void ComplexOperation()
{
bool lockTaken = false;
try
{
Monitor.Enter(_lockObject, ref lockTaken);
// Critical section of code
}
finally
{
if (lockTaken)
{
Monitor.Exit(_lockObject);
}
}
}
The Monitor
class also provides additional methods for thread coordination:
public class BufferWithMonitor
{
private readonly Queue<int> _buffer = new Queue<int>();
private readonly object _lockObject = new object();
private const int MaxSize = 5;
public void Produce(int item)
{
lock (_lockObject)
{
while (_buffer.Count >= MaxSize)
{
// Buffer is full, wait for consumer
Monitor.Wait(_lockObject);
}
_buffer.Enqueue(item);
Console.WriteLine($"Produced: {item}, Buffer size: {_buffer.Count}");
// Signal that an item is available
Monitor.Pulse(_lockObject);
}
}
public int Consume()
{
lock (_lockObject)
{
while (_buffer.Count == 0)
{
// Buffer is empty, wait for producer
Monitor.Wait(_lockObject);
}
int item = _buffer.Dequeue();
Console.WriteLine($"Consumed: {item}, Buffer size: {_buffer.Count}");
// Signal that there's space in the buffer
Monitor.Pulse(_lockObject);
return item;
}
}
}
3. Mutex
A Mutex
is similar to a lock but can be used across processes:
public class SingleInstanceApp
{
private static Mutex _mutex;
public static bool IsFirstInstance()
{
bool createdNew;
_mutex = new Mutex(true, "MyUniqueAppName", out createdNew);
return createdNew;
}
public static void ReleaseMutex()
{
_mutex?.ReleaseMutex();
}
}
// Usage
static void Main()
{
if (!SingleInstanceApp.IsFirstInstance())
{
Console.WriteLine("Another instance is already running. Exiting...");
return;
}
Console.WriteLine("Application running...");
Console.ReadLine();
SingleInstanceApp.ReleaseMutex();
}
4. Semaphore and SemaphoreSlim
Semaphores allow a specified number of threads to access a resource:
public class ConnectionPool
{
private readonly SemaphoreSlim _semaphore;
public ConnectionPool(int maxConnections)
{
_semaphore = new SemaphoreSlim(maxConnections, maxConnections);
}
public async Task<Connection> GetConnectionAsync()
{
await _semaphore.WaitAsync();
return new Connection(() => _semaphore.Release());
}
public class Connection : IDisposable
{
private readonly Action _releaseAction;
public Connection(Action releaseAction)
{
_releaseAction = releaseAction;
Console.WriteLine("Connection opened");
}
public void Dispose()
{
Console.WriteLine("Connection closed");
_releaseAction();
}
}
}
// Usage
static async Task Main()
{
var pool = new ConnectionPool(3); // Maximum of 3 concurrent connections
var tasks = new List<Task>();
for (int i = 0; i < 10; i++)
{
int id = i;
tasks.Add(Task.Run(async () =>
{
using (var connection = await pool.GetConnectionAsync())
{
Console.WriteLine($"Task {id} acquired connection");
await Task.Delay(1000); // Simulate work
Console.WriteLine($"Task {id} releasing connection");
}
}));
}
await Task.WhenAll(tasks);
}
5. ReaderWriterLockSlim
This mechanism allows multiple readers but only one writer:
public class ThreadSafeCache<TKey, TValue>
{
private readonly Dictionary<TKey, TValue> _cache = new Dictionary<TKey, TValue>();
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
public TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
{
// First try to read without locking for writes
_lock.EnterReadLock();
try
{
if (_cache.TryGetValue(key, out TValue value))
{
return value;
}
}
finally
{
_lock.ExitReadLock();
}
// Key not found, need to acquire write lock
_lock.EnterWriteLock();
try
{
// Check again in case another thread added it
if (!_cache.TryGetValue(key, out TValue value))
{
value = valueFactory(key);
_cache[key] = value;
}
return value;
}
finally
{
_lock.ExitWriteLock();
}
}
public bool TryGetValue(TKey key, out TValue value)
{
_lock.EnterReadLock();
try
{
return _cache.TryGetValue(key, out value);
}
finally
{
_lock.ExitReadLock();
}
}
}
6. Interlocked Class
For simple atomic operations, the Interlocked
class provides efficient methods:
public class AtomicCounter
{
private int _count = 0;
public void Increment()
{
Interlocked.Increment(ref _count);
}
public int GetCount()
{
return Interlocked.CompareExchange(ref _count, 0, 0);
}
}
Real-World Example: A Thread-Safe Caching Service
Let's implement a more practical example—a thread-safe caching service that might be used in a web application:
public class CachingService<TKey, TValue>
{
private readonly Dictionary<TKey, CacheItem<TValue>> _cache = new Dictionary<TKey, CacheItem<TValue>>();
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
private readonly Timer _cleanupTimer;
public CachingService(TimeSpan cleanupInterval)
{
_cleanupTimer = new Timer(CleanupExpiredItems, null, cleanupInterval, cleanupInterval);
}
public async Task<TValue> GetOrAddAsync(TKey key, Func<TKey, Task<TValue>> valueFactory, TimeSpan expiration)
{
// Try to get value without lock first
if (_cache.TryGetValue(key, out var item) && !item.IsExpired)
{
return item.Value;
}
// Value not found or expired, need to create/refresh it
await _semaphore.WaitAsync();
try
{
// Check again after acquiring lock
if (_cache.TryGetValue(key, out item) && !item.IsExpired)
{
return item.Value;
}
// Create new value
TValue value = await valueFactory(key);
_cache[key] = new CacheItem<TValue>(value, expiration);
return value;
}
finally
{
_semaphore.Release();
}
}
private void CleanupExpiredItems(object state)
{
_semaphore.Wait();
try
{
var keysToRemove = _cache
.Where(pair => pair.Value.IsExpired)
.Select(pair => pair.Key)
.ToList();
foreach (var key in keysToRemove)
{
_cache.Remove(key);
Console.WriteLine($"Removed expired item: {key}");
}
}
finally
{
_semaphore.Release();
}
}
private class CacheItem<T>
{
public T Value { get; }
public DateTime ExpirationTime { get; }
public bool IsExpired => DateTime.Now > ExpirationTime;
public CacheItem(T value, TimeSpan expiration)
{
Value = value;
ExpirationTime = DateTime.Now.Add(expiration);
}
}
}
// Usage example
public class WeatherService
{
private readonly CachingService<string, string> _cache = new CachingService<string, string>(TimeSpan.FromMinutes(5));
public async Task<string> GetWeatherForCityAsync(string city)
{
return await _cache.GetOrAddAsync(
city,
async (cityName) =>
{
// Simulate API call
await Task.Delay(1000);
return $"The weather in {cityName} is sunny, 25°C";
},
TimeSpan.FromHours(1)
);
}
}
Best Practices
- Keep synchronization blocks as short as possible to minimize contention between threads.
- Be mindful of deadlocks—always acquire locks in the same order across your application.
- Use the right tool for the job:
lock
for simple scenariosMonitor
when you need more control or waiting conditionsInterlocked
for simple atomic operationsReaderWriterLockSlim
when you have many readers and few writersSemaphoreSlim
for limiting concurrent access
- Consider thread-local storage for data that doesn't need to be shared.
- Consider using higher-level abstractions like
ConcurrentDictionary
and other collections fromSystem.Collections.Concurrent
. - Prefer async/await over manual thread synchronization when possible.
Common Pitfalls
Deadlocks
Deadlocks occur when two or more threads are waiting for each other to release resources:
// Thread 1
lock (resourceA)
{
// Do some work
lock (resourceB)
{
// Do more work
}
}
// Thread 2
lock (resourceB)
{
// Do some work
lock (resourceA)
{
// Do more work
}
}
To avoid deadlocks, always acquire locks in a consistent order throughout your application.
Priority Inversion
This occurs when a low-priority thread holds a lock needed by a high-priority thread. The solution is often to use priority inheritance, which .NET's synchronization primitives support automatically in most cases.
Summary
Thread synchronization is essential when working with multithreaded applications in .NET. The framework provides various mechanisms to handle different synchronization scenarios:
lock
andMonitor
for exclusive access to resourcesMutex
for cross-process synchronizationSemaphore
andSemaphoreSlim
for limiting concurrent accessReaderWriterLockSlim
for scenarios with many readers and few writersInterlocked
for simple atomic operations
By choosing the right synchronization mechanism and following best practices, you can write robust concurrent applications that avoid race conditions, deadlocks, and other threading issues.
Exercises
- Implement a thread-safe counter that can be incremented, decremented, and reset atomically.
- Create a thread-safe producer-consumer queue with a maximum capacity.
- Modify the
CachingService
example to useReaderWriterLockSlim
instead ofSemaphoreSlim
and compare the performance. - Implement a simple thread pool that limits the number of concurrent worker threads.
- Create a class that represents a shared resource with methods that simulate different operations, and use appropriate synchronization to ensure thread safety.
Additional Resources
- Microsoft Documentation on Threading
- Concurrent Collections in .NET
- Threading in C# by Joseph Albahari
- Parallel Programming in .NET
- Task Parallel Library
Happy coding, and remember: with great concurrency comes great responsibility!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)