C# / Advanced: Lifetimes, Concurrency, and Runtime Design / Lesson 15
Concurrency and Synchronization
Protect complete shared-state invariants and distinguish thread-safe operations from transactional application behavior.
On this page
Lesson goals
- Identify conflicting shared accesses.
- Use a stable lock or atomic operation for its intended scope.
- Bound asynchronous access with ownership and completion.
Lock the complete compound update
The first program uses one private gate for every counter increment. Both workers are started and awaited, and the final result is read under the same rule. Locking only the assignment after reading an old value outside the lock would still lose updates. The fixed workload fits int and completes without relying on timing.
Use a dedicated stable lock object rather than this, a public object, or an interned string that unrelated code might also lock. In the modern baseline, System.Threading.Lock is another explicit facility, but object-based lock remains a useful understood form. Do not await inside a lock statement; use an async-compatible design where necessary.
using System;
using System.Threading.Tasks;
object gate = new object();
int count = 0;
Task first = Task.Run(Increment);
Task second = Task.Run(Increment);
await Task.WhenAll(first, second);
lock (gate) Console.WriteLine($"count={count}");
void Increment()
{
for (int i = 0; i < 1000; i++)
{
lock (gate) count++;
}
}
count=2000
Every increment holds the same gate, preserving all two thousand updates.
Choose one atomic read-modify-write
Interlocked.Increment performs an atomic increment on the specified variable. Reading a value and then using a separate assignment is not the same operation. Atomics suit small well-defined state transitions; several atomic fields do not automatically provide one coherent multi-field snapshot or transaction. Use a lock when that makes the invariant clear.
The second program uses Interlocked for updates and Volatile.Read for the final value. Await still owns completion. volatile alone is not an atomic increment and does not turn a collection into a thread-safe one. Avoid teaching memory-ordering tricks without a complete argument about all accesses and object publication.
using System;
using System.Threading;
using System.Threading.Tasks;
int count = 0;
Task first = Task.Run(Increment);
Task second = Task.Run(Increment);
await Task.WhenAll(first, second);
Console.WriteLine($"count={Volatile.Read(ref count)}");
void Increment()
{
for (int i = 0; i < 1000; i++) Interlocked.Increment(ref count);
}
count=2000
Each Interlocked operation contributes once, and both workers finish before reporting.
Thread-safe methods do not make a workflow atomic
ConcurrentDictionary provides safe individual operations under its documented rules, but a ContainsKey followed by an indexer update is still a compound race at the application level. GetOrAdd and AddOrUpdate delegates can run more than once and outside internal locks, so value factories should not perform non-idempotent side effects that assume exactly one execution.
SemaphoreSlim.WaitAsync can bound asynchronous concurrency; release only after a successful acquisition, normally in finally. A semaphore limits active work, not an unbounded pending queue. Keep the semaphore alive until all borrowers finish. Define admission cancellation and overload behavior rather than hiding unlimited waiting behind an async API.
Review order, lifetime, and progress together
Multiple locks need a consistent acquisition order. Do not hold locks while invoking unknown callbacks or blocking on asynchronous work unless the contract has a careful deadlock argument. Reduce shared mutation by using immutable messages or exclusive ownership where it simplifies the design. Thread safety should be documented per operation, not inferred from a namespace name.
Tests should coordinate starts and completion explicitly, stress a bounded synthetic workload, and verify the invariant after all workers finish. Passing repeated runs is evidence but not a universal proof. Use debugger stacks and runtime diagnostics for hangs, and preserve a regression for the first broken access policy rather than only the visible symptom.
Common errors and fixes
A counter loses updates despite a final WhenAll.
- Why it happens
- Workers raced before the final join.
- How to fix it
- Synchronize the complete update with a lock or Interlocked.
- Prevent it next time
- Review accesses during execution, not only after completion.
A ConcurrentDictionary factory performs an effect twice.
- Why it happens
- Exactly-once execution was assumed for a delegate that may repeat.
- How to fix it
- Keep factories side-effect-safe or move effects under a separate protocol.
- Prevent it next time
- Test contention and document factory semantics.
A semaphore over-releases after cancellation.
- Why it happens
- Release runs even when WaitAsync never acquired a permit.
- How to fix it
- Release only after successful acquisition.
- Prevent it next time
- Test already-cancelled and queued cancellation cases.
Check your understanding
1. A balance and transaction count are updated under separate atomic operations. Why may a reader see a pair that never represented a complete transaction?
Show worked solution
Another update can occur between the two reads or writes. Atomicity of each field does not make the pair transactional. If the public contract requires a coherent pair, guard both fields and their updates under one lock or use a deliberately validated immutable snapshot protocol. Document the snapshot boundary and test readers as well as writers.
2. What must a cancellation-safe SemaphoreSlim pattern do when cancellation occurs before acquisition?
Show worked solution
It must not release a permit it never acquired. Await WaitAsync first, then enter a try/finally whose finally releases after successful acquisition. Keep cancellation classification separate from operation failures, and wait for all users before disposing the semaphore. Bound queued demand independently from the permit count.
Key takeaways
- Protect whole invariants, not just individual assignments.
- Completion, atomicity, mutual exclusion, and admission control solve different problems.
- Factories, callbacks, locks, and disposal need compatible lifetime rules.