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.
Published Updated
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 packagedotnet 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) 132What to notice:
% Time in GCshould be compared with workload-specific latency and throughput; these are not universal alarm thresholds.- Growing
ThreadPool Queue Lengthis 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 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) when (linked.IsCancellationRequested)
{
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 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.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, reflection/trimming constraints and lack of runtime dynamic code generation. Reflection is not universally absent; required metadata must be preserved.
- 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 async state-machine type, MoveNext and state field. A DisplayClass is a closure artifact, not the name of every async state machine.
- 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 does CPM centralize, what does transitive pinning change, and why is neither a substitute for package source mapping?
- 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?
Worked assessment answers
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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. - 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.
- 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
- .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 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
- Asynchronous programming scenarios - C# | Microsoft Learn — accessed 2026-10-07. Exact supporting passage: “CPU-bound code starts an operation on a background thread with the Task.Run method.”
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
- ValueTask<TResult> Struct (System.Threading.Tasks) | Microsoft Learn: ValueTask must be consumed once, including conversion via AsTask.
- global.json overview - .NET CLI | Microsoft Learn: SDK rollForward feature can move to a higher feature band; disable requires exact match.
- NuGet Package Version Reference | Microsoft Learn: Bare NuGet versions are minimum constraints; bracketed singleton versions are exact.
- NuGet Package Dependency Resolution | Microsoft Learn: NuGet transitive resolution is not a universal nearest/highest-wins rule.
- Package Source Mapping | Microsoft Learn: Global package cache can bypass source mapping lookup.
- Channels - .NET | Microsoft Learn: Bounded channels may wait or drop according to full mode.
- Native AOT deployment overview - .NET | Microsoft Learn: Native AOT restricts dynamic loading and runtime code generation; trimming constrains reflection.
- Fundamentals of garbage collection - .NET | Microsoft Learn: LOH default threshold and compaction policy do not imply fixed GC pause times.
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.