Skip to content
Free public beta until December 4, 2026: every course is open, no card needed. After that, the first chapter of each course stays free and full access is a one-time $39 ($19 for up to 100 founding members chosen for helpful feedback). Your progress is kept. How the beta works

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, then await 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.ForEach or 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: Interlocked for one counter, lock for a few fields, a concurrent collection for a shared dictionary or queue, SemaphoreSlim when 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.

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 between a producer and a consumer.

Choosing

The table that matches the kind of work to the tool, and how to time it honestly.