Dinesh/ Blog
application engineering

MS Stack Ch 4 — .NET runtime and async

The CLR, garbage collector, Task vs ValueTask, the async state machine, cancellation, the dotnet CLI, NuGet, global.json, central package management — the runtime your C# code actually lives in.

27 min readChecking device speech…
Lesson preparation & details

Level: intermediate

By the end, you should be able to

  • A producer throws while four consumers await ReadAllAsync. Why can Task.WhenAll never finish?
  • Explain the version and execution boundaries before applying the examples

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 33 sections

Review and execution boundary

C# 12/.NET 8; illustrative counters are synthetic, not benchmark results. This is a historical target, not a recommendation to copy these package versions into a new deployment. The 9 October 2026 body review checked documentation, source/configuration invariants and MDX syntax; no .NET runtime, NuGet restore, diagnostic attachment or load test was executed.

Reviewed on 7 October 2026 against the official source snapshots linked below. The review is bounded editorial correction, not certification of every dependency, security property or cloud deployment. Historical setup commands and optional exercises were not executed. No cloud resources, third-party packages or external side effects were created. Old screenshots and unavailable private assets remain in the private recovery archive, not prerequisites for this lesson.

Chapter 4 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

C# is the language; .NET is the runtime, the standard library, the tooling, and the package ecosystem your C# code lives in. Most senior debugging sessions are not about C# syntax — they are about the runtime: garbage collection pauses spiking p99 latency, thread-pool starvation under load, an async continuation running on the wrong context, a NuGet conflict that breaks restore in CI but not locally. This chapter builds the runtime mental model so investigations start from evidence rather than guesses.

The previous chapter covered what C# can express. This chapter covers how it executes: how IL becomes machine code via the JIT, how the garbage collector manages the heap across generations, how async/await is rewritten into a state machine that integrates with the thread pool, how cancellation tokens compose, and how the dotnet CLI plus NuGet plus global.json plus Directory.Packages.props form a reproducible build environment. None of this is glamorous, and all of it shows up in production.

Shipping-tier means you can ship features without runtime mysteries: GC, thread pool, and async work in your head; you use dotnet-counters when something feels slow; you have CPM enabled and global.json pinned per repo. Expert-tier means you can analyse a dotnet-trace/PerfView capture, tune Server vs Workstation GC, ship a Native AOT binary for a CLI tool, use ArrayPool<T> and Span<T> in hot paths, and inspect RyuJIT codegen via SharpLab to confirm an optimisation actually happened.

You finish this chapter when you can explain to a colleague what happens on the first call to an async method, why ValueTask exists, how to investigate suspected thread-pool starvation with counters and blocked stacks, and how package source mapping restricts dependency origins independently of Central Package Management.

CLR + JIT — Tiered compilation, ReadyToRun, Native AOT — and when each pays off.

GC fundamentals — Generations, LOH, POH, Server vs Workstation, Background GC.

Task vs ValueTask — Where async allocates and how to skip it on the hot path.

Cancellation — Tokens, linked tokens, RequestAborted, ThrowIfCancellationRequested.

dotnet CLI + global.json — Reproducible SDK pinning per repo with rollForward.

NuGet + CPM — Central Package Management, transitive pinning, source mapping.

Concepts and depth

CLR, GC, value-type optimisation

The CLR (Common Language Runtime, implemented today by CoreCLR) is the conductor: it loads managed assemblies (.dll files containing IL and metadata), enforces runtime type-safety rules, and normally JIT-compiles methods as needed, manages the heap, runs the thread pool, and handles exceptions. Your C# source compiles to IL — a stack-based bytecode — packaged in a .dll with metadata about every type, method, and field. When the process starts, the CLR loads your main assembly, walks dependencies, and starts executing Main (or the top-level statements compiled into a synthetic Main).

The garbage collector is generational and tracing, with compaction and background collection depending on heap and configuration. The generational hypothesis says most objects die young — so the GC partitions the heap into Gen 0 (nursery, allocated into directly), Gen 1 (short-lived survivors), and Gen 2 (long-lived). Gen 0 collections generally examine less data than full Gen 2 collections; pause duration depends on live data, allocation rate, hardware and GC configuration, not fixed millisecond thresholds. Surviving objects get promoted upward. The Large Object Heap (LOH) holds objects ≥ 85,000 bytes (default threshold) and is not compacted by default — fragmentation can build up if you allocate and free large arrays in different sizes. The Pinned Object Heap (POH) (.NET 5+) supports explicitly pinned allocations such as pinned arrays. Pinning an existing object does not relocate it into the POH; it can still hinder compaction of its original heap.

Compilation choices. With tiered compilation, Tier 0 favors quick startup (quick JIT or precompiled ReadyToRun code); Tier 1 generates optimized code in the background for methods selected for recompilation. ReadyToRun can reduce startup JIT work but does not remove the runtime JIT. Native AOT instead publishes native code ahead of time and restricts runtime code generation and dynamic loading. Compare cold startup, steady-state throughput and artifact size separately; a faster cold start does not prove a faster hot loop.

GC modes. Workstation GC targets client-style workloads; Server GC normally uses multiple heaps and dedicated collection threads for throughput. Background GC allows managed work during parts of a Gen 2 collection, not pause-free collection. ASP.NET Core normally requests Server GC, but effective mode depends on runtime configuration and available processors; a single-logical-CPU environment uses Workstation GC. Check GCSettings.IsServerGC rather than inferring the active mode from the project type.

Value-type optimisation is why struct exists. Value types copy their contents and can be stored inline, but boxing, captured locals or reference-valued fields can still contribute to allocation and GC pressure. The cost is copying on assignment, which is fine for small types (≤ 16 bytes is the rule of thumb). readonly struct can avoid defensive copies at readonly call sites; measure instead of assuming all copies disappear. ref struct (like Span<T>) can never escape to the heap — it cannot be a field of a class, cannot be boxed, cannot be captured in an async method — which lets the JIT prove zero-allocation slicing of arrays and strings. Hot-path code in modern .NET (System.Text.Json, Kestrel) is full of Span<T> precisely because it eliminates allocation.

Good enough to ship

• Measure allocation rate and collection cost; short-lived allocation is not free • Watch Gen 0/1/2 collections with dotnet-counters • ASP.NET Core normally requests Server GC; verify the effective mode and processor constraints

Expert tier

• Use ArrayPool<T>.Shared.Rent/Return for hot-path buffers • Span<T> and Memory<T> for zero-alloc parsing • Tune GC configuration only against measured memory limits and pauses; do not copy undocumented project properties

Read the heap cards as two different classifications. Gen 0, Gen 1, and Gen 2 describe the survival history of ordinary small objects; LOH and POH describe special allocation destinations, not the next two promotion steps. A short-lived request object usually starts in Gen 0, while a sufficiently large array goes directly to the LOH. When investigating pauses, first ask which allocation pattern your workload creates rather than assuming every object visits all five boxes.

Gen 0 Nursery Small, collected frequently; pause time is workload-dependent.

Gen 1 Buffer Short-lived survivors.

Gen 2 Long-lived Full collections can be costly; frequency depends on memory pressure and workload.

LOH ≥ 85,000 bytes Not compacted by default.

POH Pinned Explicitly pinned allocations; pinning an existing object does not move it here.

Generations and special-purpose heaps: ordinary small allocations start in Gen 0; LOH and POH are not later promotion stages.

Task and Task<T>, async/await state machine

Task is the "future" type — a placeholder for a value (or void) that will be available later. Task<T> carries a result of type T; Task (non-generic) carries only completion. When you write async Task<int> M(), the compiler rewrites the method into a state machine struct: each await becomes a state transition. The struct implements IAsyncStateMachine.MoveNext, which contains the original body sliced into resumption blocks. On the first call, MoveNext runs synchronously through already-completed awaits. At an incomplete await, it registers a continuation and returns control to the caller. When the operation completes, the runtime invokes the continuation, calling MoveNext again — which resumes execution past the await.

Release builds commonly use a struct state machine, but its exact representation is a compiler/runtime detail. Suspension needs state that survives the call; modern builders use heap-backed state-machine storage. Synchronous completion can avoid that storage but may still allocate a Task result, a closure or application data. Measure allocations with the chosen runtime and result values rather than promising zero allocation.

For I/O-bound work, async enables not blocking a thread while waiting for I/O, provided the underlying API is genuinely asynchronous. The keyword does not make arbitrary work nonblocking: a synchronous database call or Thread.Sleep before an await still blocks. Runtime I/O completion plumbing can resume many outstanding operations on a smaller pool of threads; actual request capacity depends on CPU work, memory, connections and downstream limits, not a promised requests-per-core figure.

public async Task<int> GetAsync(CancellationToken ct)
{
    await Task.Delay(100, ct);   // potential suspension point; exact allocation is runtime-dependent
    return 42;
}

ValueTask — when and why

Task<T> is a reference type, but a call may reuse a cached completed task. ValueTask<T> can wrap a result, a Task or an IValueTaskSource with a token. It may save allocations for frequent synchronous results or pooled operations, but its larger value and more complex consumption can add overhead. Benchmark both paths; the asynchronous path is not guaranteed to be no worse.

The gotchas: a ValueTask may only be awaited once. Consume it once; do not await it multiple times or call AsTask multiple times. Task.WhenAll accepts Tasks, not ValueTasks. Convert each operation once with AsTask and reuse that Task when multiple awaits or composition are required. Merely storing a ValueTask is not the violation; unsafe reuse is. Also: do not return ValueTask from public APIs by default. The allocation savings rarely matter outside hot paths, and the single-await restriction trips callers up. The .NET team's rule: use Task for public APIs, use ValueTask for private hot paths after you have measured.

IAsyncEnumerable<T> is the async sibling of IEnumerable<T> — await foreach over a stream of values that arrive asynchronously (database cursors, paginated APIs, server-sent events). Each MoveNextAsync returns a ValueTask<bool>; the same allocation-savings story applies.

ConfigureAwait(false) and the synchronisation context

A synchronisation context is a hook that lets the framework decide where a continuation runs after await. Old ASP.NET (Framework, not Core) had one that captured HttpContext, serializing access within a request context, not requiring one fixed physical thread. WPF and WinForms have one that marshals continuations back to the UI thread. ASP.NET Core does not install a custom synchronisation context by default. An incomplete await commonly resumes via the thread pool; a completed await can continue inline, and application code or libraries can introduce a custom context or scheduler.

In library code targeting both contexts, write await someTask.ConfigureAwait(false) to opt out of capturing the context. This removes that await's requirement to marshal back to the captured context; it does not force a new thread or guarantee that an entire sync-over-async call chain is deadlock-free. The failure it helps avoid is: a UI/old-ASP.NET caller blocks on .Result; the awaited task tries to resume on the captured context; the context is blocked; the task never completes; deadlock. In ordinary ASP.NET Core application code there is no framework context to capture. General-purpose libraries can use ConfigureAwait(false) consistently where they do not need caller context; callers should still await rather than block. It does not suppress ExecutionContext/AsyncLocal flow.

title: Never block on async code

task.Result and task.Wait() synchronously block the calling thread. This can deadlock with a captured synchronization context and can contribute to thread-pool starvation under load in ASP.NET Core. Async all the way up. Never call .Result or .Wait() in async code. Prefer an async factory or explicit asynchronous initialization instead of blocking in a constructor. A top-level synchronous bridge still needs a context-aware design; its position alone does not make it safe.

Cooperative cancellation

.NET cancellation is cooperative — there is no "kill this task" primitive. Instead, every cancellable operation accepts a CancellationToken. A CancellationTokenSource creates and owns the token; calling cts.Cancel() flips the token's IsCancellationRequested to true and triggers any registered callbacks. Operations honour cancellation by either checking token.ThrowIfCancellationRequested() in loops or passing the token to underlying I/O calls that internally throw OperationCanceledException when triggered.

Linked tokens compose multiple cancellation sources: var linked = CancellationTokenSource.CreateLinkedTokenSource(callerToken); linked.CancelAfter(TimeSpan.FromSeconds(5)); produces a token that fires when the caller cancels or after 5 seconds, whichever comes first. This is the standard pattern for "respect the caller's cancellation, plus my local timeout". Dispose the CTS when you are done — it owns registrations and timer resources when used.

In ASP.NET Core, HttpContext.RequestAborted is a CancellationToken that fires when the client disconnects. Wire it into every long-running async operation in your handler; the user closing their browser tab should propagate down to "cancel the SQL query, cancel the Kusto call, release the connection". Without it, you keep doing work no one is waiting for.

public async Task<List<User>> GetActiveUsersAsync(CancellationToken ct)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    cts.CancelAfter(TimeSpan.FromSeconds(5));
 
    var users = await _db.Users
        .Where(u => u.IsActive)
        .ToListAsync(cts.Token);
 
    foreach (var u in users)
    {
        cts.Token.ThrowIfCancellationRequested();
        await ProcessAsync(u, cts.Token);
    }
    return users;
}

Common async pitfalls — async void, blocking, fire-and-forget

async void is for event handlers only. Exceptions from async void are raised on the synchronisation context (often crashing the process) instead of being captured in a Task for the caller to observe. If a method does work, it must return Task or Task<T> so the caller can await and observe failures.

Fire-and-forget (_ = SomeAsync(); or Task.Run(...) without await) hides exceptions and runs unsupervised. In ASP.NET Core, prefer IHostedService / BackgroundService for long-running background work, or Channel<T> to hand work to a worker. If you truly need fire-and-forget for a non-critical telemetry call, wrap it in try/catch and log — never let it escape silently.

Blocking on async code (.Result, .Wait(), GetAwaiter().GetResult()) is the classic production incident. Even in ASP.NET Core without the deadlock pathology, it consumes thread-pool threads, which forces the pool to grow (growth behavior depends on runtime version and blocking compensation), which spikes latency. Sync over async is almost always a bug. The exception: a top-level Main in a console app or a one-time bridging point at the top of the call tree.

Threading primitives — Task.Run, Parallel.ForEachAsync, SemaphoreSlim, Channel<T>

Task.Run(() => ...) queues work to the thread pool. Use it to offload CPU-bound work in a UI app or to bridge a synchronous CPU loop from an async context. Do not wrap already-asynchronous I/O in Task.Run just to "make it async": that adds scheduling overhead without making the I/O more asynchronous. It does not dedicate a thread for the whole I/O wait. Deliberately offloading a method's expensive synchronous prefix is a different requirement. Parallel.ForEachAsync(source, options, async (item, ct) => ...) (.NET 6+) is the modern way to run an async operation over a sequence with MaxDegreeOfParallelism and CancellationToken baked in.

SemaphoreSlim is a counting semaphore — call WaitAsync(ct) to acquire, Release() to free. The standard tool for capping concurrency manually (e.g., "at most 8 concurrent outbound HTTP calls"). Always wrap Release in finally to guarantee the slot is freed on exception.

Channel<T> (System.Threading.Channels) is the modern producer/consumer queue. Bounded channels in Wait mode apply backpressure; DropWrite, DropNewest and DropOldest deliberately drop items. Unbounded channels have no capacity limit and can exhaust memory. Pattern: one or more producers await channel.Writer.WriteAsync(item, ct), one or more consumers await foreach (var item in channel.Reader.ReadAllAsync(ct)). Replaces BlockingCollection<T> for async scenarios.

Good enough to ship

• Cap parallelism with Parallel.ForEachAsync or SemaphoreSlim • Pass CancellationToken through every async call • Use Channel<T> for producer/consumer

Expert tier

• Bounded channels for backpressure-shaped systems • IAsyncEnumerable<T> for streaming results • PeriodicTimer for low-allocation periodic background work

.NET CLI — new / restore / build / run / test / publish

So far we have followed work inside a running process: allocating objects, suspending async methods, and bounding concurrent operations. To reproduce that behaviour on another machine, we also need to control how the process is built and which dependencies it loads. The dotnet CLI is the entry point for that lifecycle: scaffold, build, run, test, publish. The following NuGet and SDK-pinning sections make those commands reproducible rather than machine-dependent.

dotnet new webapi -n Api -o src/Api          # scaffold minimal API project
dotnet new gitignore                          # generate .gitignore
dotnet new sln -n MyStack                     # create solution
dotnet sln add src/Api/Api.csproj             # add project to solution
 
dotnet restore                                # download NuGet packages
dotnet build -c Release                       # compile
dotnet run --project src/Api                  # build + run
dotnet watch run --project src/Api            # rebuild + hot-reload on file change
 
dotnet test                                   # run all tests
dotnet add src/Api package Polly              # add NuGet dependency
dotnet add src/Api reference src/Domain       # add project reference
 
dotnet publish -c Release -o ./out            # produce deployable artifact
dotnet pack -c Release -o ./packages          # produce NuGet package

dotnet watch is the development feedback loop — file changes trigger rebuilds or hot reload. Project files (.csproj) and solution files (.sln) describe the dependency graph; modern SDK-style csproj is XML but short (10-20 lines for a typical project). Solutions are for IDE convenience; the CLI works fine without one.

NuGet — package sources, nuget.config, Central Package Management, transitive resolution

NuGet is the .NET package registry. dotnet add package Foo writes a <PackageReference Include="Foo" Version="1.2.3" /> into the csproj. Transitive dependencies are resolved at restore time; resolution uses lowest-applicable-version, floating-version, direct-dependency-wins and cousin-dependency rules. A bare Version="8.0.1" expresses a minimum, not an exact singleton constraint; [8.0.1] is exact. For applications, review and commit packages.lock.json and use locked restore in CI; floating versions (Version="8.*", Version="8.0.*") accept newer matches at restore time — convenient for libraries, dangerous for apps (CI build can differ from local).

Central Package Management (CPM) moves all versions to a single Directory.Packages.props at the repo root; child csproj just declare <PackageReference Include="Foo" /> without a version. Projects that import that file share its versions; a closer nested Directory.Packages.props takes precedence unless it explicitly imports the parent. Enable CentralPackageTransitivePinningEnabled when you intentionally want central entries to promote transitive dependencies (the indirect ones you did not declare). A downgrade below a dependency's requirement fails restore with NU1109; this option alone neither locks the complete graph nor validates a package's origin.

<!-- Directory.Packages.props at repo root -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Polly" Version="8.4.0" />
    <PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
    <PackageVersion Include="xunit" Version="2.9.0" />
  </ItemGroup>
</Project>

nuget.config declares package sources (the public nuget.org and any private feeds). Package source mapping locks specific patterns to specific feeds — MyOrg.* only from the private feed, everything else from nuget.org. This defends against the dependency-confusion attack where an attacker publishes a package named MyOrg.Internal to public nuget.org; without source mapping, your CI might pull the malicious public version when package identity is not restricted to a trusted source.

SDK pinning with global.json and rollForward

// global.json at the repo root
{
  "sdk": {
    "version": "8.0.300",
    "rollForward": "latestFeature"
  }
}

global.json pins the .NET SDK version per repo so different repos can use different SDKs without conflict. rollForward controls what newer SDKs are acceptable if the exact version is not installed: disable (exact only), patch (8.0.300 → 8.0.301+), feature (prefer the requested feature band, then advance to a higher feature band within 8.0 if needed), minor (8.0 → 8.x), major (8 → 9+), and the latest* variants always pick the highest installed match. When a version is specified without rollForward, the documented default is patch. Use latestFeature in app repos to allow newer feature bands within the same major/minor SDK; this is a range policy, not byte-identical build pinning; use disable in repos where reproducibility trumps freshness.

Pair with Directory.Build.props at the repo root for project-wide settings: <LangVersion>12.0</LangVersion>, <Nullable>enable</Nullable>, <ImplicitUsings>enable</ImplicitUsings>, <TreatWarningsAsErrors>true</TreatWarningsAsErrors>, <AnalysisLevel>latest-all</AnalysisLevel>. One file, every project — no drift between sub-projects.

Good enough to ship

• global.json pinned per repo • Directory.Build.props enforcing NRT + warnings-as-errors • CPM enabled; one place to upgrade

Expert tier

• Package source mapping for supply-chain defence • Renovate / Dependabot policies for safe upgrade cadence • Custom MSBuild targets in Directory.Build.targets

Worked examples

1. dotnet-counters snapshot of an ASP.NET Core app under load

# install once
dotnet tool install -g dotnet-counters
 
# run your API, then in another shell:
dotnet-counters ps
# Select exactly one verified PID from the list; replace 12345 below.
dotnet-counters monitor --process-id 12345 --counters System.Runtime
 
# Synthetic legacy EventCounter-style display; labels depend on tool/runtime version:
[System.Runtime]
    % Time in GC since last GC (%)                     2
    Allocation Rate (B / 1 sec)                  4,210,832
    CPU Usage (%)                                     18
    Exception Count (Count / 1 sec)                    0
    GC Heap Size (MB)                                 86
    Gen 0 GC Count (Count / 1 sec)                     4
    Gen 1 GC Count (Count / 1 sec)                     1
    Gen 2 GC Count (Count / 1 sec)                     0
    LOH Size (MB)                                      2
    Monitor Lock Contention Count (Count / 1 sec)      0
    Number of Active Timers                           14
    ThreadPool Completed Work Item Count (Count / 1 sec) 1,820
    ThreadPool Queue Length                            0
    ThreadPool Thread Count                           24
    Working Set (MB)                                 132

What to notice:

  • % Time in GC should be compared with workload-specific latency and throughput; these are not universal alarm thresholds.
  • Growing ThreadPool Queue Length is a symptom to investigate alongside throughput, blocked stacks and scheduling.
  • Growing thread count can indicate blocking; inspect stacks and correlate CPU/queue/throughput before diagnosing the cause.
  • Allocation Rate of hundreds of MB/s on a typical API = look for per-request allocations.
  • Capture during a wrk or k6 load test to see the real picture; the idle process is uninformative.

2. Composed cancellation with caller token + timeout + manual cancel

public async Task<Report> GenerateReportAsync(Guid id, CancellationToken ct)
{
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
    linked.CancelAfter(TimeSpan.FromSeconds(30));
 
    try
    {
        var data = await FetchDataAsync(id, linked.Token);
 
        await Parallel.ForEachAsync(
            data.Items,
            new ParallelOptions
            {
                MaxDegreeOfParallelism = 8,
                CancellationToken      = linked.Token
            },
            async (item, ct) => await EnrichAsync(item, ct));
 
        return new Report(id, data);
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        throw;   // caller cancelled — propagate
    }
    catch (OperationCanceledException) when (linked.IsCancellationRequested)
    {
        throw new TimeoutException($"Report {id} timed out after 30s");
    }
}

What to notice:

  • CreateLinkedTokenSource(ct) composes caller and local cancellation.
  • CancelAfter adds a timeout without writing a separate timer.
  • Distinguishing caller-cancel vs local-timeout in the catch is important for telemetry.
  • Parallel.ForEachAsync honours the linked token across all workers.
  • using on the CTS releases timer and registration resources.

3. Producer/consumer with bounded Channel<T>

A bounded queue needs failure supervision in both directions. This .NET 8 fragment completes the writer if production fails and cancels peer workers if consumption fails. ReadFeedAsync and HandleAsync are application callbacks that must honor cancellation.

public async Task ProcessFeedAsync(CancellationToken caller)
{
    using var stop = CancellationTokenSource.CreateLinkedTokenSource(caller);
    var channel = Channel.CreateBounded<Event>(1000);
    async Task Produce()
    {
        Exception? failure = null;
        try
        {
            await foreach (var item in ReadFeedAsync(stop.Token))
                await channel.Writer.WriteAsync(item, stop.Token);
        }
        catch (Exception ex) { failure = ex; stop.Cancel(); throw; }
        finally { channel.Writer.TryComplete(failure); }
    }
    async Task Consume()
    {
        try
        {
            await foreach (var item in channel.Reader.ReadAllAsync(stop.Token))
                await HandleAsync(item, stop.Token);
        }
        catch { stop.Cancel(); throw; }
    }
    var consumers = Enumerable.Range(0, 4).Select(_ => Consume()).ToArray();
    await Task.WhenAll(consumers.Append(Produce()));
}

The bound is item count, not bytes. Cancellation can stop enqueuing and reading; it cannot undo an already-completed side effect. Include producer-fault, consumer-fault, full-queue cancellation and normal-empty-feed cases in runtime tests.

4. Directory.Packages.props + nuget.config with source mapping

<!-- Directory.Packages.props -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.6" />
    <PackageVersion Include="Polly" Version="8.4.0" />
    <PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
    <PackageVersion Include="MyOrg.Telemetry" Version="2.3.0" />
  </ItemGroup>
</Project>
<?xml version="1.0" encoding="utf-8"?>
<!-- nuget.config at repo root -->
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
    <add key="my-org"    value="https://pkgs.dev.azure.com/myorg/_packaging/feed/nuget/v3/index.json" />
  </packageSources>
  <packageSourceMapping>
    <packageSource key="my-org">
      <package pattern="MyOrg.*" />
    </packageSource>
    <packageSource key="nuget.org">
      <package pattern="*" />
    </packageSource>
  </packageSourceMapping>
</configuration>

What to notice:

  • Versions live in one file; child csproj say <PackageReference Include="Polly" /> with no Version.
  • Transitive pinning catches indirect dependencies in the same place.
  • The more-specific MyOrg.* prefix wins over the public * fallback for packages needing resolution. Packages already present in the global-packages folder bypass source lookups; validate using an isolated trusted cache, not an untrusted shared cache.
  • <clear /> clears inherited entries in this packageSources collection. It does not freeze SDK selection, dependency versions, credentials or all NuGet settings; combine it with the lockfile and a controlled cache.
  • Commit nuget.config to the repo; never put credentials in it (use environment variables or credential provider).

Hands-on exercises

  1. dotnet-counters during load. Goal: feel GC and thread pool under pressure.

    1. Install dotnet-counters and a load tool (wrk or k6).
    2. Run a small ASP.NET Core API; hit it with 100 RPS for 60 s.
    3. Capture the counter output; identify Gen 0/1/2 rates and thread-pool growth.
    4. Add Thread.Sleep(100) to a handler and re-run; observe queue length growing.
    5. Done when: you can read the counters and explain what each row means.
  2. Migrate to CPM. Goal: one place for every NuGet version.

    1. Pick a multi-project repo with duplicated Version="..." attributes.
    2. Create Directory.Packages.props with ManagePackageVersionsCentrally and all versions.
    3. Strip Version from every <PackageReference> in child csproj files.
    4. Enable transitive pinning; restore + build must succeed.
    5. Done when: changing a version in one file updates every project.
  3. Task vs ValueTask benchmark. Goal: quantify the allocation difference.

    1. Use BenchmarkDotNet to compare Task<int> and ValueTask<int> for a method that returns 99% sync, 1% async.
    2. Read the Allocated column.
    3. Compute total bytes saved at 10k ops.
    4. Write a 2-line summary of when ValueTask is worth it.
    5. Done when: you can defend the choice with numbers in a code review.
  4. Native AOT a CLI tool. Goal: feel the trade-offs.

    1. Take a small dotnet new console app; add <PublishAot>true</PublishAot>.
    2. dotnet publish -c Release -r linux-x64.
    3. Measure startup time vs the default JIT publish.
    4. Note binary size, reflection/trimming constraints and lack of runtime dynamic code generation. Reflection is not universally absent; required metadata must be preserved.
    5. Done when: you can explain when AOT is appropriate (CLI, serverless) and when not (plugin-heavy app code).
  5. Composed cancellation pipeline. Goal: 1000-item async pipeline with concurrency cap + timeout + Ctrl+C.

    1. Build a console app that processes 1000 items via Parallel.ForEachAsync.
    2. Cap concurrency at 8; wire Console.CancelKeyPress to a CTS.
    3. Add a per-operation timeout via CreateLinkedTokenSource + CancelAfter.
    4. Confirm Ctrl+C aborts in-flight work cleanly.
    5. Done when: the app honours both ambient cancellation and per-op timeouts.
  6. State-machine inspection in SharpLab. Goal: see what the compiler does.

    1. Paste an async method into sharplab.io.
    2. Switch the right pane to "C# (Decompiled)".
    3. Identify the generated async state-machine type, MoveNext and state field. A DisplayClass is a closure artifact, not the name of every async state machine.
    4. Add a second await and watch the state machine grow.
    5. Done when: you can read the generated code and explain the resumption logic.
  7. dotnet watch hot reload. Goal: tight inner-loop feedback.

    1. Scaffold a minimal API; dotnet watch run.
    2. Change a returned string in a handler; the request reflects the change without restart.
    3. Add a new endpoint; confirm hot reload picks it up (or rebuilds if it cannot hot-reload).
    4. Done when: the inner loop is < 2 s between edit and seeing the result.

Self-check questions

  1. What is the generational hypothesis, and why does the .NET GC use it?
  2. What is the difference between Workstation and Server GC, and which does ASP.NET Core default to?
  3. What does ValueTask<T> buy you over Task<T>, and what are the restrictions on using it?
  4. What is tiered compilation, and what does Tier 1 do that Tier 0 does not?
  5. How does the thread pool decide to grow more threads, and why is the hill-climbing algorithm sometimes too slow?
  6. What does CreateLinkedTokenSource solve, and when do you reach for it?
  7. What does ConfigureAwait(false) actually change, and why is it less important in ASP.NET Core than in old ASP.NET?
  8. What does CPM centralize, what does transitive pinning change, and why is neither a substitute for package source mapping?
  9. What does <TreatWarningsAsErrors>true</TreatWarningsAsErrors> change about your build, and why is it worth the friction?
  10. What is the dependency-confusion attack, and how does NuGet package source mapping defend against it?
  11. Why is async void only for event handlers? What happens if it throws?
  12. What is the difference between Task.Run, Parallel.ForEachAsync, and a Channel<T> consumer? When do you reach for each?

Worked assessment answers

  1. Generations: most objects die young; collecting a smaller young region often costs less than tracing all long-lived data. Promotion is based on survival, not a guarantee of object age or a fixed pause budget.
  2. GC mode: Workstation is client-oriented; Server uses dedicated collection threads and multiple heaps for throughput. ASP.NET Core normally requests Server GC, but a single logical CPU or explicit configuration can change the effective mode. Background GC is a separate concurrency choice.
  3. ValueTask: inline results or pooled sources can avoid a new Task allocation on measured hot paths, at the cost of a larger value and stricter consumption rules. Await or convert once; reuse the converted Task if composition needs multiple observations.
  4. Tiers: Tier 0 favors fast code availability; Tier 1 spends more compilation effort on optimized code. ReadyToRun can supply initial native code without eliminating later JIT work. Neither choice guarantees an improvement for every workload.
  5. Thread pool: the runtime adjusts worker count using throughput feedback and mechanisms for blocking/starvation. Slow adjustment can amplify latency when workers block, but runtime versions differ. Correlate queue growth, CPU, completed work and blocked stacks; adding threads cannot create CPU capacity.
  6. Linked cancellation: combine caller cancellation with a local timeout without losing either signal. Pass the linked token to both I/O and CPU-loop checks, dispose its source, and distinguish caller cancellation from local timeout in telemetry.
  7. ConfigureAwait: false declines the captured continuation context/scheduler for that await. It does not force a thread switch, suppress AsyncLocal flow, or make blocking safe. ASP.NET Core installs no custom context by default.
  8. Packages: CPM shares version declarations; transitive pinning can promote centrally listed indirect dependencies and rejects downgrades. Source mapping controls eligible feeds; a lockfile controls resolved graph changes. An existing global cache still needs trust.
  9. Warnings: TreatWarningsAsErrors makes compiler warnings fail the build, so new diagnostics become an explicit engineering decision. It does not turn a passing build into a correctness or security proof; analyzer and SDK versions affect the warning set.
  10. Dependency confusion: an internal package identity can be supplied by an unintended public feed. The more-specific MyOrg.* mapping restricts that identity to the private feed when source lookup occurs; CPM alone does not.
  11. async void: callers cannot await completion or observe failure through a returned Task; exceptions use the context/runtime handling path. Keep it for required event-handler signatures and handle failures deliberately.
  12. Concurrency tools: Task.Run offloads CPU work; Parallel.ForEachAsync bounds concurrent per-item operations; Channel separates producers and consumers with an explicit buffering/full policy. Channel completion and peer cancellation are separate supervision duties.

High-signal resources

Official docs

Books or courses

  • Pro .NET Memory Management — Konrad Kokosa. The most thorough book on the .NET GC.
  • Concurrency in C# Cookbook (2nd edition) — Stephen Cleary. Patterns for cancellation, parallelism, channels.

Practitioner posts

Weekly milestones

  1. Day 1 — dotnet CLI + project structure + global.json + Directory.Build.props. Scaffold a small repo with all of them.
  2. Day 2 — CPM migration on an existing repo (exercise 2). Add package source mapping. Read the CPM doc.
  3. Day 3 — GC fundamentals + dotnet-counters (exercise 1). Read the GC fundamentals docs.
  4. Day 4-5 — async deep dive: state machine in SharpLab (exercise 6), ValueTask benchmark (exercise 3), cancellation pipeline (exercise 5). Read 3 Stephen Cleary posts.
  5. Day 6-7 — Native AOT trade-offs (exercise 4), dotnet watch (exercise 7). Re-attempt every self-check question without notes.

How it shows up in the capstone

The capstone repo has a single global.json pinning .NET 8 with rollForward: latestFeature, a Directory.Build.props enforcing NRT, warnings-as-errors, AnalysisLevel=latest-all, and Deterministic=true, and a Directory.Packages.props with CPM, transitive pinning, and explicit PackageVersion for every dependency. A nuget.config declares the public feed plus a private Azure Artifacts feed with package source mapping locking MyOrg.* to the private feed.

The reference API requests Server GC + Background GC and checks the effective runtime mode; every controller action accepts CancellationToken and wires HttpContext.RequestAborted through to EF Core and Kusto SDK calls. Long-running endpoints compose with CreateLinkedTokenSource + CancelAfter for per-endpoint timeouts. A BackgroundService warms caches on startup; a PeriodicTimer refreshes them every 5 minutes; a Channel<T> decouples the API request pipeline from a batch enrichment worker.

In the reference capstone design, capture runtime counters during a controlled workload and compare them with a recorded baseline alongside latency/error SLOs. Define a rollback decision from sustained regressions; arbitrary 15% GC or queue-length 50 thresholds are not universal acceptance gates.

Previous chapter ← Ch 3 — C# language Next chapter → Ch 5 — ASP.NET Core

Corrected contracts and failure analysis

Await suspends an async method when its awaitable is incomplete; it does not imply creation of a dedicated thread. I/O-bound operations normally await the provider API directly. Task.Run is useful for offloading CPU work from a UI thread, but wrapping every server request in Task.Run does not add CPU capacity.

Cancellation is cooperative. A 30-second CancelAfter schedules a cancellation request, not a hard deadline that kills ignored work. Dispose linked sources. Channel completion must run even on producer failure, otherwise readers may wait forever; on consumer failure cancel the producer too, or a full queue can deadlock completion. Bounded buffers bound queued items, not item size or downstream fan-out. Counter thresholds require a measured baseline and workload, not a universal 10-percent GC rule.

Boundary exercise with solution

A producer throws while four consumers await ReadAllAsync. Why can Task.WhenAll never finish?

Solution and reasoning

If the writer is not completed, consumers wait for more data indefinitely. Complete with the producer exception in finally, and supervise both directions: failure in any worker cancels the shared linked token. Observe every task; a timeout signal alone is not proof of shutdown.

Source-backed review notes

Solved package and scheduling decisions

Source selection. A restore requires MyOrg.Telemetry and Polly. With the example mapping and an empty isolated package cache, the MyOrg prefix selects the private source while the * fallback selects nuget.org for Polly. CPM decides the requested versions; it does not decide trusted origin. A matching package in a prepopulated global cache changes this experiment because no source lookup may occur. Keep feeds authenticated through a supported credential provider; do not put tokens in source-controlled XML.

Version calculation. The requirement 8.0.1 permits a lowest available compatible version at least 8.0.1; [8.0.1] permits only that version. With SDK 8.0.300 requested and only 8.0.401 installed, disable and patch fail, whereas feature or latestFeature can choose 8.0.401. Record the resolved SDK, lockfile and restore mode as build evidence. The historical package numbers above illustrate syntax, not current security recommendations.

Task consumption. Suppose an API returns one ValueTask. var task = operation.AsTask(); await Task.WhenAll(task, task); consumes the ValueTask once and reuses a Task. Calling AsTask twice on the original ValueTask is not equivalent and violates its general consumption contract.

Supervision exercise. With a capacity-one queue and a consumer that fails after the first item, the next producer write may be waiting for capacity. Cancel peers, complete the writer in finally, and await all worker completions. If HandleAsync ignores cancellation, shutdown can still wait; no queue primitive forcibly rolls back an external write. This is why the example requires cancellation-cooperative callbacks and separate idempotency at the destination.

Additional runtime and package contracts

Primary documentation checked on 9 October 2026: tiered compilation and ReadyToRun, Workstation and Server GC, ConfigureAwait FAQ, CPM import and transitive-pinning rules, and dotnet-counters syntax. These support the stated contracts, not performance measurements or execution of the fragments.

Recovery source checks

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.