Every way to run work concurrently
async and await, Task.WhenAll, Task.Run, Parallel loops, PLINQ, locks, concurrent collections and channels. Waiting on several things at once versus doing several things at once.
Ask what the work spends its time doing.
- Waiting (network, disk, database, a timer) →
async/await. Start the independent calls, thenawait Task.WhenAll(...), so three one-second calls take one second, not three. No extra threads are needed. For many items with a limit on how many run at once,Parallel.ForEachAsync. - One CPU-heavy job that would freeze a UI →
await Task.Run(...). It frees the UI thread but doesn't make the job any faster. - Many independent, CPU-heavy items →
Parallel.For,Parallel.ForEachor PLINQ, each item working on its own data. - One part produces work and another consumes it (downloads feeding a resizer) → a channel between them.
- Several threads must share one piece of state → first try not
to share it. If you must:
Interlockedfor one counter,lockfor a few fields, a concurrent collection for a shared dictionary or queue,SemaphoreSlimwhen the code inside awaits. - Small or quick work → a plain loop. Threads cost more than they save.
Every option needs a way to stop: pass a CancellationToken down.
Measure before and after in a Release build. async is not the same
as parallel, and parallel is not automatically faster.
Waiting on several things at once
Formal. async/await makes waiting non-blocking; it does not by
itself create threads or run code on several cores. I/O-bound work
(network, disk, timers) benefits from async, and independent waits
overlap when their tasks are started before being awaited (Task.WhenAll).
CPU-bound work only finishes sooner when it runs on several cores at
once, with Parallel.For/ForEach, PLINQ or Task.Run, and only when
each piece of work outweighs the cost of scheduling it and there is no
contention on shared state.
In plain English. There are two different kinds of slow. Waiting slow is waiting for a download or a database. Async helps there, because you can start several waits and let them overlap. Working slow is the processor crunching numbers. Only splitting the crunching across several processor cores helps there, and only if each piece is big enough to be worth handing out.
Example. Three downloads of 1 s each take about 3 s awaited one by
one, but about 1 s started together and awaited with WhenAll. Summing
a million numbers with Parallel.For and a lock around each addition is
slower than a plain loop.
Why this rule exists. Using the wrong tool costs you: parallel loops around I/O waste threads, and async around CPU work saves nothing.
async overlaps waiting; parallel spreads computing across cores. Different problems, different tools.
Section
Task and await
An async method returns a Task, and await frees the thread while it waits.
Start them all, then wait for all of them, the first of them, or each as it finishes.
Section
Cancellation tokens
How the caller asks the work to stop, and how your code checks.
Using more cores
Where the threads come from, and moving CPU-bound work off the thread you're on.
Split a collection's items across cores, or run async work with a limit on how many at once.
Sharing safely
Formal. When two or more threads access the same variable and at
least one writes to it, the accesses must be synchronized (a lock,
Interlocked, a concurrent collection, or a channel), or the result
depends on timing. Even count++ is a read-modify-write of three steps,
so unsynchronized increments from several threads lose updates. Every
thread that touches the state must use the same synchronization; a lock
that only some writers take protects nothing.
In plain English. If several threads change the same value, they can
trip over each other. Two threads both read 5, both add 1, both write 6,
and one of the additions is lost. You need a rule that makes them take
turns, such as a lock or Interlocked.Increment, and every thread has
to follow it.
Example. Eight tasks each running count++ 100,000 times usually
finish with far fewer than 800,000. With Interlocked.Increment(ref count) the total is 800,000 every time.
Why this rule exists. Without synchronization, the processor and compiler are free to interleave and reorder the steps, and the bug shows up only sometimes, which makes it very hard to find.
Two threads writing the same variable without synchronization lose updates.
A lock for a few fields, Interlocked for one number, and SemaphoreSlim when the protected code awaits.
ConcurrentDictionary for shared lookups, and a Channel
Choosing
The table that matches the kind of work to the tool, and how to time it honestly.