Skip to main content

.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.

csharp
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.

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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

  1. Keep synchronization blocks as short as possible to minimize contention between threads.
  2. Be mindful of deadlocks—always acquire locks in the same order across your application.
  3. Use the right tool for the job:
    • lock for simple scenarios
    • Monitor when you need more control or waiting conditions
    • Interlocked for simple atomic operations
    • ReaderWriterLockSlim when you have many readers and few writers
    • SemaphoreSlim for limiting concurrent access
  4. Consider thread-local storage for data that doesn't need to be shared.
  5. Consider using higher-level abstractions like ConcurrentDictionary and other collections from System.Collections.Concurrent.
  6. 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:

csharp
// 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 and Monitor for exclusive access to resources
  • Mutex for cross-process synchronization
  • Semaphore and SemaphoreSlim for limiting concurrent access
  • ReaderWriterLockSlim for scenarios with many readers and few writers
  • Interlocked 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

  1. Implement a thread-safe counter that can be incremented, decremented, and reset atomically.
  2. Create a thread-safe producer-consumer queue with a maximum capacity.
  3. Modify the CachingService example to use ReaderWriterLockSlim instead of SemaphoreSlim and compare the performance.
  4. Implement a simple thread pool that limits the number of concurrent worker threads.
  5. Create a class that represents a shared resource with methods that simulate different operations, and use appropriate synchronization to ensure thread safety.

Additional Resources

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! :)