15 minlesson

lock and SemaphoreSlim

lock and SemaphoreSlim

Synchronization primitives protect shared resources from concurrent access. lock provides mutual exclusion, while SemaphoreSlim limits the number of concurrent accesses.

The lock Statement

lock ensures only one thread can execute a code block at a time:

csharp
1private readonly object _lockObj = new();
2private int _counter = 0;
3
4void IncrementSafely()
5{
6 lock (_lockObj)
7 {
8 _counter++; // Only one thread at a time
9 }
10}

How lock Works

1Thread 1 Thread 2
2-------- --------
3Acquire lock
4Enter critical section
5 Try to acquire lock
6 BLOCKED (waiting)
7_counter++
8Exit critical section
9Release lock
10 Acquire lock
11 Enter critical section
12 _counter++
13 Exit critical section
14 Release lock

Lock Object Best Practices

csharp
1// GOOD: Private, readonly, dedicated lock object
2private readonly object _lock = new();
3
4// BAD: Locking on 'this'
5lock (this) // External code could lock on same object!
6
7// BAD: Locking on a Type
8lock (typeof(MyClass)) // Type objects are shared!
9
10// BAD: Locking on strings
11lock ("my lock") // String interning makes this shared!
12
13// BAD: Locking on value types
14lock (myInt) // Boxing creates new object each time - no protection!

Complete Example

csharp
1class ThreadSafeCounter
2{
3 private readonly object _lock = new();
4 private int _count = 0;
5
6 public int Count
7 {
8 get { lock (_lock) { return _count; } }
9 }
10
11 public void Increment()
12 {
13 lock (_lock)
14 {
15 _count++;
16 }
17 }
18
19 public void Decrement()
20 {
21 lock (_lock)
22 {
23 _count--;
24 }
25 }
26}

SemaphoreSlim

A semaphore allows a limited number of concurrent accesses:

csharp
1// Allow max 3 concurrent operations
2private readonly SemaphoreSlim _semaphore = new(3);
3
4async Task DoWorkAsync()
5{
6 await _semaphore.WaitAsync(); // Wait for slot
7 try
8 {
9 await PerformOperationAsync();
10 }
11 finally
12 {
13 _semaphore.Release(); // Free the slot
14 }
15}

SemaphoreSlim for Async

Unlike lock, SemaphoreSlim works with async code:

csharp
1private readonly SemaphoreSlim _asyncLock = new(1, 1); // Like a lock
2
3async Task SafeOperationAsync()
4{
5 await _asyncLock.WaitAsync();
6 try
7 {
8 await SomeAsyncWork();
9 }
10 finally
11 {
12 _asyncLock.Release();
13 }
14}

Rate Limiting with SemaphoreSlim

csharp
1class RateLimitedClient
2{
3 private readonly SemaphoreSlim _throttle = new(5); // Max 5 concurrent
4 private readonly HttpClient _client = new();
5
6 public async Task<string> GetAsync(string url)
7 {
8 await _throttle.WaitAsync();
9 try
10 {
11 return await _client.GetStringAsync(url);
12 }
13 finally
14 {
15 _throttle.Release();
16 }
17 }
18}
19
20// Usage - only 5 requests at a time
21var client = new RateLimitedClient();
22var tasks = urls.Select(url => client.GetAsync(url));
23await Task.WhenAll(tasks); // Throttled to 5 concurrent

SemaphoreSlim with Timeout

csharp
1var semaphore = new SemaphoreSlim(1);
2
3bool acquired = await semaphore.WaitAsync(TimeSpan.FromSeconds(5));
4if (!acquired)
5{
6 throw new TimeoutException("Could not acquire semaphore");
7}
8
9try
10{
11 await DoWork();
12}
13finally
14{
15 semaphore.Release();
16}

SemaphoreSlim with Cancellation

csharp
1await semaphore.WaitAsync(cancellationToken);

lock vs SemaphoreSlim

FeaturelockSemaphoreSlim
Async supportNoYes
Multiple permitsNo (1 only)Yes
TimeoutNoYes
CancellationNoYes
PerformanceFasterSlightly slower

Choosing the Right Primitive

csharp
1// Use lock for:
2// - Simple, quick synchronous operations
3// - When you need the fastest option
4lock (_lock)
5{
6 _counter++;
7}
8
9// Use SemaphoreSlim(1, 1) for:
10// - Async operations
11// - When you need timeout/cancellation
12await _semaphore.WaitAsync();
13
14// Use SemaphoreSlim(n) for:
15// - Limiting concurrent operations
16// - Rate limiting
17await _throttle.WaitAsync(); // n slots available

Common Patterns

Read-Modify-Write

csharp
1lock (_lock)
2{
3 var current = _value;
4 var newValue = Transform(current);
5 _value = newValue;
6}

Check-Then-Act

csharp
1lock (_lock)
2{
3 if (!_dictionary.ContainsKey(key))
4 {
5 _dictionary[key] = CreateValue();
6 }
7}

Double-Check Locking (Lazy Init)

csharp
1private volatile ExpensiveObject? _instance;
2private readonly object _lock = new();
3
4public ExpensiveObject Instance
5{
6 get
7 {
8 if (_instance == null) // First check (no lock)
9 {
10 lock (_lock)
11 {
12 if (_instance == null) // Second check (with lock)
13 {
14 _instance = new ExpensiveObject();
15 }
16 }
17 }
18 return _instance;
19 }
20}
21
22// Better: Use Lazy<T>
23private readonly Lazy<ExpensiveObject> _lazy = new(() => new ExpensiveObject());
24public ExpensiveObject Instance => _lazy.Value;

Deadlock Warning

Be careful not to create deadlocks:

csharp
1// DEADLOCK RISK!
2lock (_lockA)
3{
4 lock (_lockB) // Thread 1 holds A, wants B
5 {
6 // ...
7 }
8}
9
10// Another thread:
11lock (_lockB)
12{
13 lock (_lockA) // Thread 2 holds B, wants A - DEADLOCK!
14 {
15 // ...
16 }
17}

Prevention: Always acquire locks in the same order.

Key Takeaways

  • lock provides mutual exclusion (one thread at a time)
  • SemaphoreSlim allows limited concurrent access
  • Use lock for quick sync operations
  • Use SemaphoreSlim for async, timeouts, rate limiting
  • Always use private, readonly lock objects
  • Dispose SemaphoreSlim when done
  • Watch out for deadlocks - acquire locks in consistent order