Concurrency is a property of program structure — composing a program out of independently executing tasks that can be dealt with in overlapping time periods. Parallelism is a property of execution — actually doing several of them in the same instant, which requires multiple cores. A single-core machine runs concurrent programs perfectly well by interleaving them, with zero parallelism; concurrent design enables parallelism without requiring it. The practical consequence is the split this hub is organized around: concurrency is what keeps I/O-bound work from blocking threads, and parallelism is what makes CPU-bound work finish faster on multiple cores.

Composition versus simultaneous execution

A single thread can compose overlapping I/O without running two instructions at once:

  1. At 0 ms, request A sends an HTTP call and registers its continuation with await; the thread returns to the scheduler.
  2. At 1 ms, the same thread starts request B and yields at its await.
  3. At 40 ms, B’s socket completion makes its continuation runnable; the thread processes it.
  4. At 52 ms, A becomes runnable and the thread resumes it.

Both requests were in flight together, but the thread executed only one continuation at a time. The overlap came from the operating system and network, not another CPU core.

CPU work crosses a different boundary. This loop partitions the pixels and schedules workers through the ThreadPool; multiple workers can execute Sharpen simultaneously on different cores:

Parallel.For(
    fromInclusive: 0,
    toExclusive: pixels.Length,
    new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
    i => pixels[i] = Sharpen(pixels[i]));

That is useful only when Sharpen does enough computation to repay partitioning and scheduling overhead. For socket waits, adding worker threads consumes resources without making the remote service respond sooner.

Non-normative source visual

The “not concurrent, parallel” quadrant is invalid under these definitions: work executing simultaneously on multiple cores is necessarily concurrent. Use the visual only to contrast interleaved composition with simultaneous execution, not as a four-state taxonomy.

Deeper Explanation

Mental model

  • If work waits on I/O, prefer async (Task, await) to avoid blocking threads.
  • If work burns CPU, use controlled parallelism (Parallel.ForEachAsync, PLINQ, partitioning).
  • If work can be canceled, thread cancellation through the full call chain.
  • If shared state exists, design locking strategy first, then optimize.

Choosing options for the same requirement

Use requirement-first decisions instead of primitive-first decisions.

RequirementViable optionsPreferAvoid
Many independent external I/O calls with low latency targetSequential await, Task.WhenAll, bounded fan-out (SemaphoreSlim + WhenAll)Task.WhenAll for moderate fan-out; bounded fan-out when dependency limits or connection pools can saturateUnbounded WhenAll over large sets; Parallel.ForEachAsync for pure I/O without explicit limit rationale
CPU-heavy per-item processing on large datasetsSequential loop, Task.Run partitioning, Parallel.ForEachAsync, PLINQParallel.ForEachAsync when bounded workers and cancellation are needed; PLINQ for declarative batch transformsRunning heavy CPU loops directly in hot request path without limits
Serialize access to shared mutable statelock, SemaphoreSlim, Channel<T> single-consumer pipeline, immutable snapshotslock for short synchronous sections; SemaphoreSlim for async call chains; Channel<T> when you also need buffering/backpressureMixing lock with async waiting patterns; coarse global locks around I/O
Stop work on timeout or caller disconnectCaller token only, CancelAfter, linked tokensCaller token by default; linked token when combining caller cancellation and local SLA timeoutCreating nested linked token sources inside tight loops
Run work beyond request lifetimeTask.Run, in-process queue (Channel<T> + BackgroundService), external broker queueIn-process queue for moderate reliability needs; external broker for durability/retries/scale-outFire-and-forget Task.Run where failure/ordering/retry guarantees matter

Coordination Patterns

The mechanism should expose the workload’s ownership and failure boundary, not just make a race disappear.

MechanismWorkloadBackpressureOwnershipCancellationStarvationFailure behavior
`Channel<T>`Async producer-consumer handoffA bounded channel waits or drops by policyWriters submit; readers drain; a single reader can own mutable stateEach wait accepts a token; Complete ends the streamFIFO items do not imply fair writers or readersComplete(error) exposes a terminal error; an uncaught item failure can stop the consumer pump
ThreadPool / `Task`Scheduled work and async composition; use parallelism for CPU partitioningNone: callers must bound fan-out or queueingThe pool owns worker threads; the caller owns task observationCooperative through a token passed into the operationBlocking pool workers can starve unrelated continuationsExceptions are captured by Task and surface when observed or awaited
`TaskCompletionSource<T>`Adapt one callback, event, or external completion into a taskNone: it represents one completion, not a work queueThe adapter owns TrySetResult, TrySetException, and TrySetCanceledThe adapter must register cancellation explicitlyNo contender-fairness guarantee; RunContinuationsAsynchronously avoids inline continuation captureThe producer chooses exactly one terminal result; later TrySet* calls lose the race
`Monitor`Short synchronous access to shared stateMonitor.Wait can gate a condition, but it does not bound incoming workThe entering thread owns the monitor and must exit itlock has no token; use a timed Monitor.TryEnter when waiting must be boundedNo strict acquisition fairness; long holders can starve contenders and form deadlocksExit occurs during stack unwinding, but partial state mutations are not rolled back
BarrierFixed participants meeting at phase boundariesNone: every participant waits for the phaseEach registered participant must signal exactly once per phaseSignalAndWait accepts a token, but cancellation does not complete work for other participantsOne delayed or missing participant stalls the phasePost-phase callback failures surface as BarrierPostPhaseException
ReaderWriterLockSlimRead-heavy synchronous state with rare writesNone: queued callers only wait for ownershipThe entering thread owns its read, upgradeable-read, or write lockNo token; TryEnter*Lock can impose a timeoutWriters are favored over new readers, but strict fairness is not promisedRecursion and ownership errors throw; failed mutations still require application-level recovery

`SemaphoreSlim` belongs beside this table when the requirement is a concurrency limit rather than exclusive ownership. Mutex pays an operating-system handle cost when ownership must cross a process boundary. Neither adds queue durability or makes a multi-lock design safe from deadlock.

Decision walkthroughs

Same requirement: “fan out 500 HTTP calls quickly”

  • If dependency and infrastructure allow high concurrency, use bounded Task.WhenAll with an explicit limit.
  • If each call is tiny and independent, start with a conservative cap (for example 16-64), then tune with telemetry.
  • If strict ordering is needed, preserve original index and reorder results after completion.
public async Task<IReadOnlyList<UserDto>> LoadUsersBoundedAsync(
    IReadOnlyList<int> ids,
    int maxConcurrency,
    CancellationToken cancellationToken)
{
    using var gate = new SemaphoreSlim(maxConcurrency);
 
    var tasks = ids.Select(async id =>
    {
        await gate.WaitAsync(cancellationToken);
        try
        {
            return await _client.GetUserAsync(id, cancellationToken);
        }
        finally
        {
            gate.Release();
        }
    });
 
    return await Task.WhenAll(tasks);
}

Same requirement: “improve throughput of CPU transforms”

  • Use Parallel.ForEachAsync when you need bounded workers and cancellation with straightforward code.
  • Use PLINQ for pure data transforms where query readability is better than imperative loops.
  • If CPU work competes with request handling, move it to background workers with queue-based backpressure.

Same requirement: “protect state and stay async”

  • For tiny in-memory critical sections, a lock is simplest.
  • For async sections that must await, prefer SemaphoreSlim.WaitAsync.
  • If contention is high and order matters, move state mutation behind a single-consumer channel.

Questions

References

10 items under this folder.