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.
- • 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)
- • Use
ArrayPool<T>.Shared.Rent/Returnfor hot-path buffers - •
Span<T>andMemory<T>for zero-alloc parsing - • Tune
<RetainVMGarbageCollection>true</...>for VM-bound workloads
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<int> 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<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)
{
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(() => ...) 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) => ...) (.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.
- • Cap parallelism with
Parallel.ForEachAsyncorSemaphoreSlim - • Pass
CancellationTokenthrough every async call - • Use
Channel<T>for producer/consumer
- • Bounded channels for backpressure-shaped systems
- •
IAsyncEnumerable<T>for streaming results - •
PeriodicTimerfor 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.
<!-- 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 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.
- •
global.jsonpinned per repo - •
Directory.Build.propsenforcing NRT + warnings-as-errors - • CPM enabled; one place to upgrade
- • 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 GCabove 10% is a warning; above 20% is a red flag.ThreadPool Queue Lengthgrowing over time = starvation.ThreadPool Thread Countgrowing rapidly = sync-over-async or blocking I/O.Allocation Rateof hundreds of MB/s on a typical API = look for per-request allocations.- Capture during a
wrkork6load 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)
{
throw new TimeoutException($"Report {id} timed out after 30s");
}
}
What to notice:
CreateLinkedTokenSource(ct)composes caller and local cancellation.CancelAfteradds a timeout without writing a separate timer.- Distinguishing caller-cancel vs local-timeout in the catch is important for telemetry.
Parallel.ForEachAsynchonours the linked token across all workers.usingon the CTS disposes the underlying kernel timer.
3. Producer/consumer with bounded Channel<T>
public async Task ProcessFeedAsync(CancellationToken ct)
{
var channel = Channel.CreateBounded<Event>(new BoundedChannelOptions(1000)
{
SingleReader = false,
SingleWriter = false,
FullMode = BoundedChannelFullMode.Wait // producer awaits when full
});
var producer = Task.Run(async () =>
{
await foreach (var ev in ReadFromKafkaAsync(ct))
await channel.Writer.WriteAsync(ev, ct);
channel.Writer.Complete();
}, ct);
var consumers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
{
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 theirawait foreachcleanly.SingleReader = falseallows 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
<!-- 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>
<!-- nuget.config at repo root -->
<?xml version="1.0" encoding="utf-8"?>
<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.
- Source mapping forbids
MyOrg.*from coming off public nuget.org — defends dependency confusion. <clear />removes user-machine sources so CI is reproducible.- Commit
nuget.configto the repo; never put credentials in it (use environment variables or credential provider).
Hands-on exercises
-
dotnet-countersduring load. Goal: feel GC and thread pool under pressure.- Install
dotnet-countersand a load tool (wrkork6). - Run a small ASP.NET Core API; hit it with 100 RPS for 60 s.
- Capture the counter output; identify Gen 0/1/2 rates and thread-pool growth.
- Add
Thread.Sleep(100)to a handler and re-run; observe queue length growing. - Done when: you can read the counters and explain what each row means.
- Install
-
Migrate to CPM. Goal: one place for every NuGet version.
- Pick a multi-project repo with duplicated
Version="..."attributes. - Create
Directory.Packages.propswithManagePackageVersionsCentrallyand all versions. - Strip
Versionfrom every<PackageReference>in child csproj files. - Enable transitive pinning; restore + build must succeed.
- Done when: changing a version in one file updates every project.
- Pick a multi-project repo with duplicated
-
Task vs ValueTask benchmark. Goal: quantify the allocation difference.
- Use BenchmarkDotNet to compare
Task<int>andValueTask<int>for a method that returns 99% sync, 1% async. - Read the
Allocatedcolumn. - Compute total bytes saved at 10k ops.
- Write a 2-line summary of when ValueTask is worth it.
- Done when: you can defend the choice with numbers in a code review.
- Use BenchmarkDotNet to compare
-
Native AOT a CLI tool. Goal: feel the trade-offs.
- Take a small
dotnet new consoleapp; add<PublishAot>true</PublishAot>. dotnet publish -c Release -r linux-x64.- Measure startup time vs the default JIT publish.
- Note binary size, lack of reflection, lack of dynamic codegen.
- Done when: you can explain when AOT is appropriate (CLI, serverless) and when not (plugin-heavy app code).
- Take a small
-
Composed cancellation pipeline. Goal: 1000-item async pipeline with concurrency cap + timeout + Ctrl+C.
- Build a console app that processes 1000 items via
Parallel.ForEachAsync. - Cap concurrency at 8; wire
Console.CancelKeyPressto a CTS. - Add a per-operation timeout via
CreateLinkedTokenSource+CancelAfter. - Confirm Ctrl+C aborts in-flight work cleanly.
- Done when: the app honours both ambient cancellation and per-op timeouts.
- Build a console app that processes 1000 items via
-
State-machine inspection in SharpLab. Goal: see what the compiler does.
- Paste an async method into sharplab.io.
- Switch the right pane to "C# (Decompiled)".
- Identify the generated
<>c__DisplayClass, theMoveNext, the state variable. - Add a second
awaitand watch the state machine grow. - Done when: you can read the generated code and explain the resumption logic.
-
dotnet watchhot reload. Goal: tight inner-loop feedback.- Scaffold a minimal API;
dotnet watch run. - Change a returned string in a handler; the request reflects the change without restart.
- Add a new endpoint; confirm hot reload picks it up (or rebuilds if it cannot hot-reload).
- Done when: the inner loop is < 2 s between edit and seeing the result.
- Scaffold a minimal API;
Self-check questions
- What is the generational hypothesis, and why does the .NET GC use it?
- What is the difference between Workstation and Server GC, and which does ASP.NET Core default to?
- What does
ValueTask<T>buy you overTask<T>, and what are the restrictions on using it? - What is tiered compilation, and what does Tier 1 do that Tier 0 does not?
- How does the thread pool decide to grow more threads, and why is the hill-climbing algorithm sometimes too slow?
- What does
CreateLinkedTokenSourcesolve, and when do you reach for it? - What does
ConfigureAwait(false)actually change, and why is it less important in ASP.NET Core than in old ASP.NET? - What is CPM, and why is
CentralPackageTransitivePinningEnabledimportant for supply-chain security? - What does
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>change about your build, and why is it worth the friction? - What is the dependency-confusion attack, and how does NuGet package source mapping defend against it?
- Why is
async voidonly for event handlers? What happens if it throws? - What is the difference between
Task.Run,Parallel.ForEachAsync, and aChannel<T>consumer? When do you reach for each?
High-signal resources
Official docs
- .NET runtime + GC docs — read the whole "fundamentals" section.
- .NET CLI docs — every command, every option.
- Central Package Management — the spec.
global.jsonreference — rollForward semantics.dotnet-countersreference — the diagnostic tool you will reach for most.
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
- Performance Improvements in .NET 8 — Stephen Toub's annual mega-post. Legendary.
- Stephen Cleary — Async/Await intro — canonical "what is actually happening" essay.
- Maoni Stephens' GC blog — the GC lead at Microsoft.
- SharpLab — paste C#, see the IL + JIT assembly.
Weekly milestones
- Day 1 — dotnet CLI + project structure +
global.json+Directory.Build.props. Scaffold a small repo with all of them. - Day 2 — CPM migration on an existing repo (exercise 2). Add package source mapping. Read the CPM doc.
- Day 3 — GC fundamentals +
dotnet-counters(exercise 1). Read the GC fundamentals docs. - 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.
- 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