Search Tech Journey

Find topics, journeys and posts

back to blog
system designintermediate 32m2026-06-10

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.

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 those incidents become 30-minute fixes instead of all-day mysteries.

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 detect thread-pool starvation in 30 seconds with dotnet-counters, and how Central Package Management defends against dependency-confusion attacks.

Concepts and depth

CLR, GC, value-type optimisation

The CLR (Common Language Runtime, implemented today by CoreCLR) is the conductor: it loads assemblies (.dll files containing IL), verifies them, JIT-compiles methods on first call, 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, mark-and-sweep, and mostly concurrent. 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 are cheap and frequent (sub-millisecond on modern hardware); Gen 2 collections walk the whole reachable graph and are expensive (tens of ms). 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+) holds objects that must not move (interop buffers); separating them keeps the rest of the heap compactable.

Value-type optimisation is why struct exists. Value types live inline (on the stack for locals, in-line in their containing object on the heap) — no GC pressure, no extra indirection. The cost is copying on assignment, which is fine for small types (≤ 16 bytes is the rule of thumb). readonly struct lets the JIT skip defensive copies. 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
  • • Know that allocations dying in Gen 0 are nearly free
  • • Watch Gen 0/1/2 collections with dotnet-counters
  • • Default Server GC for ASP.NET Core (it is on already)
Expert tier
  • • Use ArrayPool<T>.Shared.Rent/Return for hot-path buffers
  • Span<T> and Memory<T> for zero-alloc parsing
  • • Tune <RetainVMGarbageCollection>true</...> for VM-bound workloads
Gen 0
Nursery
Small, collected often, < 1 ms.
Gen 1
Buffer
Short-lived survivors.
Gen 2
Long-lived
Expensive, collected rarely.
LOH
≥ 85,000 bytes
Not compacted by default.
POH
Pinned
Interop buffers; keeps rest compactable.
The .NET heap layout. New allocations land in Gen 0; survivors promote upward.

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 to the first await; if the awaited operation is not yet complete, the state machine registers itself as a continuation and returns to the caller. When the operation completes, the runtime invokes the continuation, calling MoveNext again — which resumes execution past the await.

The state machine is a struct for a reason: if everything completes synchronously (a cache hit, an already-completed task), no allocation happens — the struct lives on the stack and is discarded. If an await actually suspends, the struct gets boxed to the heap so it can outlive its stack frame. That box is the per-async-suspend allocation cost you sometimes hear about. PGO (Profile-Guided Optimisation, on by default in .NET 8+) lets the JIT inline hot synchronous paths and avoid even more.

async is fundamentally about not blocking a thread while waiting for I/O. There is no thread sitting on the awaited operation. The state machine plus the runtime's I/O completion plumbing (epoll on Linux, IOCP on Windows) means a single thread can multiplex thousands of in-flight requests. This is why ASP.NET Core can serve tens of thousands of concurrent requests on a few cores — the threads are never blocked, only continuations are queued.

public async Task&lt;int&gt; GetAsync(CancellationToken ct)
{
    await Task.Delay(100, ct);   // suspension point — state machine boxes here
    return 42;
}

ValueTask — when and why

Task<T> is a class — always heap-allocated. ValueTask<T> is a struct that wraps either a result (no allocation) or a Task<T> (allocation, same as before). Use ValueTask<T> when the result is usually synchronous (cache hits, internal buffers): the synchronous path skips the allocation entirely, and the asynchronous path is no worse than Task<T>.

The gotchas: a ValueTask may only be awaited once. Storing it in a field, awaiting it twice, or passing it to Task.WhenAll is undefined behaviour. If you need any of those, call .AsTask() first to materialise an honest Task. 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, requiring continuations to run on a specific request thread. WPF and WinForms have one that marshals continuations back to the UI thread. ASP.NET Core does not have a synchronisation context — continuations run on whatever thread-pool thread happens to be free.

In library code targeting both contexts, write await someTask.ConfigureAwait(false) to opt out of capturing the context. This avoids forcing continuations onto a specific thread and prevents one specific deadlock: 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 ASP.NET Core application code you do not strictly need ConfigureAwait(false) (no context to capture), but writing it everywhere in libraries protects callers in any context.

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 a kernel timer if CancelAfter was 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&lt;List&lt;User&gt;&gt; GetActiveUsersAsync(CancellationToken ct)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    cts.CancelAfter(TimeSpan.FromSeconds(5));

    var users = await _db.Users
        .Where(u =&gt; u.IsActive)
        .ToListAsync(cts.Token);

    foreach (var u in users)
    {
        ct.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 (hill-climbing algorithm adds threads at ~1 per 500 ms by default), 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(() =&gt; ...) 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. Never wrap async code in Task.Run to "make it async" — it just wastes a thread and gains nothing. Parallel.ForEachAsync(source, options, async (item, ct) =&gt; ...) (.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 apply backpressure (producer awaits when full); unbounded channels accept everything. 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

The dotnet CLI is the entry point for everything: scaffold, build, run, test, publish.

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; conflicts use the "nearest wins" rule. Version pinning (Version="8.0.1") is exact; 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. One upgrade, whole repo updates. Enable CentralPackageTransitivePinningEnabled to also pin transitive dependencies (the indirect ones you did not declare) — critical for reproducible builds and supply-chain hygiene.

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

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 because of "highest version wins" semantics.

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 (8.0.300 → 8.0.301-399), minor (8.0 → 8.x), major (8 → 9+), and the latest* variants always pick the highest installed match. Default is latestPatch. Use latestFeature in app repos to get bug fixes without language version drift; use disable in repos where reproducibility trumps freshness.

Pair with Directory.Build.props at the repo root for project-wide settings: <LangVersion>latest</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 monitor --process-id $(pgrep -f Api.dll) System.Runtime

# Output, refreshed every second:
[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 above 10% is a warning; above 20% is a red flag.
  • ThreadPool Queue Length growing over time = starvation.
  • ThreadPool Thread Count growing rapidly = sync-over-async or blocking I/O.
  • 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&lt;Report&gt; 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) =&gt; await EnrichAsync(item, ct));

        return new Report(id, data);
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        throw;   // caller cancelled — propagate
    }
    catch (OperationCanceledException)
    {
        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 disposes the underlying kernel timer.

3. Producer/consumer with bounded Channel&lt;T&gt;

public async Task ProcessFeedAsync(CancellationToken ct)
{
    var channel = Channel.CreateBounded&lt;Event&gt;(new BoundedChannelOptions(1000)
    {
        SingleReader = false,
        SingleWriter = false,
        FullMode = BoundedChannelFullMode.Wait   // producer awaits when full
    });

    var producer = Task.Run(async () =&gt;
    {
        await foreach (var ev in ReadFromKafkaAsync(ct))
            await channel.Writer.WriteAsync(ev, ct);
        channel.Writer.Complete();
    }, ct);

    var consumers = Enumerable.Range(0, 4).Select(_ =&gt; Task.Run(async () =&gt;
    {
        await foreach (var ev in channel.Reader.ReadAllAsync(ct))
            await HandleAsync(ev, ct);
    }, ct)).ToArray();

    await Task.WhenAll(consumers.Append(producer));
}

What to notice:

  • Bounded channels apply backpressure — if consumers fall behind, the producer awaits.
  • Writer.Complete() signals end-of-stream; consumers exit their await foreach cleanly.
  • SingleReader = false allows multiple consumer tasks.
  • All work honours the same CancellationToken.
  • Replaces BlockingCollection<T> for async scenarios — no thread blocking.

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

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

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.
  • Source mapping forbids MyOrg.* from coming off public nuget.org — defends dependency confusion.
  • &lt;clear /&gt; removes user-machine sources so CI is reproducible.
  • 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, lack of reflection, lack of dynamic codegen.
    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 <>c__DisplayClass, the MoveNext, the state variable.
    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 is CPM, and why is CentralPackageTransitivePinningEnabled important for supply-chain security?
  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?

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 API uses Server GC + Background GC (defaults for ASP.NET Core); 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.

Operationally, the deploy pipeline runs dotnet-counters for 60 s post-deploy against a smoke-test workload and fails the deployment if % Time in GC is above 15% or ThreadPool Queue Length is above 50 — your runtime fluency turning directly into a deployment gate.

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