Threading and Concurrency
Two things at once means two things can go wrong at once
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
Why can count++ from two threads lose updates?
Real-world analogy
What is it?
Concurrency means several pieces of work make progress at the same time. It is safe when they do not share changing data, and risky when they do. Shared mutable state needs protection: a lock, an atomic operation, a concurrent collection or, best of all, no sharing.
Real-world relevance
A batch job processes 10,000 invoices with Parallel.ForEachAsync and adds each result to a shared Dictionary. It passes every test on a laptop, then in production it throws or silently loses entries. Switching to ConcurrentDictionary, or to returning results and combining them once, fixes it.
Key points
- Thread, Task, thread pool — A thread is an OS worker, which is expensive to create. A Task is a unit of work, and the thread pool lends reusable threads to run tasks.
- Race conditions — count++ is three steps: read, add, write. Two threads can interleave them, so one increment disappears. The result changes from run to run.
- lock protects a section — lock (gate) { ... } lets one thread in at a time. It is simple and fast, but it blocks threads and you cannot await inside it.
- SemaphoreSlim for async — SemaphoreSlim works with await semaphore.WaitAsync(). Use it for async locking, or to limit how many calls run in parallel.
- Interlocked for single numbers — Interlocked.Increment(ref count) does the whole read-add-write as one step the CPU cannot split. It is the cheapest fix for a shared counter.
- Concurrent collections — ConcurrentDictionary is safe for many writers. A normal Dictionary written by parallel tasks can corrupt itself or throw.
- Parallel.ForEachAsync — Use it to fan out async work with a limit, MaxDegreeOfParallelism. It suits bounded I/O, such as calling an API for 1,000 ids, ten at a time.
- Deadlock and choosing the tool — A deadlock happens when thread A holds lock 1 and wants lock 2, while B holds 2 and wants 1. Always take locks in the same order. I/O-bound work wants async, CPU-bound work wants Parallel.For.
Quick check
Answer from memory — scrolling up first makes it stick less.
Why can you not use await inside a lock block?
What causes a deadlock?
Code example
int unsafeCount = 0;
int safeCount = 0;
int lockedCount = 0;
var gate = new object();
Parallel.For(0, 100_000, _ =>
{
// Read, add, write: threads can overwrite each other
unsafeCount++;
// One atomic step: always correct and cheap
Interlocked.Increment(ref safeCount);
// Only one thread inside at a time
lock (gate)
{
lockedCount++;
}
});
Console.WriteLine($"Unsafe: {unsafeCount}"); // usually less than 100000
Console.WriteLine($"Interlocked: {safeCount}"); // always 100000
Console.WriteLine($"Locked: {lockedCount}"); // always 100000Line-by-line walkthrough
- 1. Three counters start at zero. gate is an object used only as the lock.
- 2. Parallel.For runs the body 100,000 times on many threads at once.
- 3. unsafeCount++ is read, add, write. Two threads can read the same value, so increments are lost.
- 4. Interlocked.Increment does the whole update as one indivisible step.
- 5. The lock block lets one thread in at a time, so lockedCount is also correct. It costs more than Interlocked.
- 6. The prints show the unsafe total below 100,000 most runs, while the other two are always exact.
Spot the bug
var cache = new Dictionary<int, string>();
await Parallel.ForEachAsync(ids, async (id, ct) =>
{
var name = await LoadNameAsync(id, ct);
cache[id] = name;
});Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Managed threading basics (Microsoft Learn)
- How to write a simple Parallel.ForEach loop (Microsoft Learn)
Lock it in