Concurrency describes program structure: several operations can be in progress during the same period. Parallelism describes execution: several operations run at the same instant. A single core can interleave concurrent work but cannot execute it in parallel.

That distinction drives the .NET choices in this folder. Asynchronous composition keeps I/O waits from occupying threads. Controlled parallelism gives CPU work access to multiple cores. Mixing the two models usually adds threads without making the workload finish sooner.

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. The thread still executed one continuation at a time because the overlap came from the operating system and network.

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.

programming concurrency and parallelism

Diagram caveat

The “not concurrent, parallel” quadrant does not fit these definitions. Simultaneous execution is necessarily concurrent. The visual is useful only for contrasting interleaving with simultaneous execution.

Choosing the Execution Model

Mental Model

  • I/O-bound work usually needs asynchronous APIs rather than more worker threads.
  • CPU-bound work may benefit from partitioning across a measured degree of parallelism.
  • Cancellation belongs to the operation’s full ownership chain.
  • Shared mutable state needs an ownership rule before it needs a faster lock.

Choosing Options for the Same Requirement

Start with the workload and failure boundary. The primitive comes after that.

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, Parallel.For / Parallel.ForEach, Parallel.ForEachAsync, PLINQParallel.For / Parallel.ForEach for synchronous CPU work. Parallel.ForEachAsync only when each body is asynchronous. PLINQ for declarative batch transformsRunning heavy CPU loops directly in a 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 buffering or backpressure is also requiredMixing 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), isolated worker with a durable brokerIn-process bounded queue when admission control is enough and shared process capacity is acceptable. Isolated worker when request-serving capacity needs protectionFire-and-forget Task.Run. Treating an in-process queue as durable or resource-isolated

Coordination Patterns

A coordination mechanism must make ownership and failure visible. Merely removing the immediate race is not enough.

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 running continuations inline on the completing threadThe producer chooses exactly one terminal result. Later TrySet* calls lose the race
`lock` / `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` fits a concurrency limit rather than exclusive ownership. Mutex pays for an operating-system handle when ownership must cross a process boundary. Neither provides durable queueing or removes deadlock risk from a multi-lock design.

Decision Walkthroughs

Fan out 500 HTTP calls

The dependency’s capacity sets the useful fan-out, not the size of the input collection. Start with a conservative cap and tune it from latency and rejection data. Preserve each input index when output order matters because completion order will vary.

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);
}

Improve throughput of CPU transforms

Parallel.For or Parallel.ForEach fits synchronous CPU work. Parallel.ForEachAsync earns its async machinery only when each body awaits. PLINQ can keep a pure batch transform readable.

In a server, an in-process bounded queue limits admission but still consumes the same CPU and memory as request handling. Sustained CPU work needs an isolated worker process or service when request-serving capacity must be protected.

Protect shared state in an async flow

A short synchronous critical section belongs behind a lock. If ownership must span an await, SemaphoreSlim.WaitAsync can represent the gate, though holding any gate across I/O widens the contention window. A single-consumer channel is often clearer when ordered mutation and buffering are both part of the requirement.

Questions

References

10 items under this folder.