Deep dive
Decision table
| Job | Pick | Why |
|---|---|---|
| HTTP API | ASP.NET Core minimal APIs | Fast, low ceremony |
| RPC between services | gRPC for .NET | First-class codegen, HTTP/2 |
| Data access | EF Core for CRUD, Dapper/Npgsql for hot paths | Productivity vs control |
| Fast startup / small containers / serverless | NativeAOT publish | No JIT, ms startup, smaller memory |
| Unity games | C# scripts (Mono or IL2CPP backend) | The engine's language |
| Game server for a Unity client | .NET 8+ server sharing a C# model library | One codebase for rules |
| Windows desktop | WPF or WinUI; Avalonia/.NET MAUI cross-platform | Native toolkits |
| High-throughput parsing | Span<T>, System.IO.Pipelines, Utf8JsonReader | Zero-copy, low allocation |
Memory and performance model
- Classes on the GC heap; structs inline (in arrays, fields, stack).
ref struct(e.g.,Span<T>) can never escape to the heap, which makes stack and pooled buffers safe to slice. ArrayPool<T>.SharedandMemoryPool<T>for reusable buffers;System.Text.Jsonsource generators avoid reflection.- JIT: tier-0 quick code, then tier-1 with dynamic PGO (devirtualization, hot/cold splitting), on-stack replacement for long loops. Hardware intrinsics and
Vector<T>/Vector128/256/512for SIMD. - GC modes: Workstation (desktop), Server (per-core heaps, throughput; adapts its heap count automatically since .NET 9).
GCHeapHardLimitor container limits bound memory.
Concurrency model
Task/ValueTaskand async/await: the compiler builds state machines; I/O completes on the thread pool (IOCP on Windows, epoll/kqueue elsewhere).System.Threading.Channelsfor producer/consumer pipelines with backpressure;Parallel.ForEachAsync, PLINQ for CPU fan-out.lock,SemaphoreSlim,Interlocked; the .NET memory model is stronger than ECMA's minimum on x64 but code should use proper primitives.
Code
ASP.NET Core (.NET 8+), minimal API, bounded background queue, span-based parsing. dotnet new web, replace Program.cs:
using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient(); // pooled handlers: no socket exhaustion
builder.Services.AddSingleton(Channel.CreateBounded<string>(
new BoundedChannelOptions(1024) { FullMode = BoundedChannelFullMode.Wait }));
builder.Services.AddHostedService<Worker>();
var app = builder.Build();
app.MapPost("/jobs/{id}", (string id, Channel<string> q) =>
q.Writer.TryWrite(id) ? Results.Accepted() : Results.StatusCode(503)); // shed load when full
app.MapGet("/sum", (string csv) => Results.Ok(Parsing.SumCsv(csv)));
app.Run();
static class Parsing
{
// Walks the input with spans: no Split(), no substring allocations.
public static long SumCsv(ReadOnlySpan<char> s)
{
long total = 0;
while (!s.IsEmpty)
{
int i = s.IndexOf(',');
var part = i < 0 ? s : s[..i];
if (long.TryParse(part, out var v)) total += v;
s = i < 0 ? ReadOnlySpan<char>.Empty : s[(i + 1)..];
}
return total;
}
}
sealed class Worker(Channel<string> queue, ILogger<Worker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var id in queue.Reader.ReadAllAsync(ct))
{
log.LogInformation("processing {Id}", id);
await Task.Delay(50, ct); // real async work here
}
}
}
NativeAOT publish (.csproj):
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
dotnet publish -c Release -r linux-x64 # single native binary in bin/Release/net8.0/linux-x64/publish
For AOT, use WebApplication.CreateSlimBuilder and System.Text.Json source-generated contexts; reflection-heavy libraries may not work.
Efficiency
- Cost: tiered JIT starts with unoptimized tier-0 code and re-JITs hot methods using dynamic PGO, on by default since .NET 8. In one Toub microbenchmark (Intel Xeon Platinum 8370C) a list-counting loop took 15.8 µs on .NET 7 and 6.1 µs on .NET 8 (7.1 µs without PGO). Objects of 85,000 bytes or more go to the large object heap (Microsoft docs). Native AOT apps have faster startup and smaller memory (Microsoft docs). Bound: allocation rate and GC pauses; JIT warmup at startup.
- Levers:
Span<T>,stackalloc,ArrayPool<T>— fewer heap allocations and GCs (.NET performance posts) — parsing and buffer-heavy hot paths- Native AOT publish — faster startup, smaller memory footprint (Microsoft docs) — serverless, small containers, CLIs
- dynamic PGO (default since .NET 8) — devirtualization and hot/cold layout; about 15 % on the microbenchmark above (7.1 to 6.1 µs, Toub) — long-running services, no change needed
- avoid large arrays above 85,000 bytes per request — keeps them off the large object heap (Microsoft docs) — buffer-heavy services; pool them
Vector128/256/512intrinsics — SIMD (gain up to the vector width, order of) — numeric kernels
- Naive → standard → state of the art:
- allocating strings and arrays per request · GC gen-0 churn
- pooled buffers,
Span<T>, source-generated JSON · low-allocation steady state - Native AOT for startup-bound hosts, dynamic PGO for throughput-bound hosts · each picked by workload
Build and deploy
dotnetCLI, NuGet with lock files (RestorePackagesWithLockFile), Central Package Management for many projects.- Containers:
mcr.microsoft.com/dotnet/aspnet(JIT) orruntime-deps/chiseled images for AOT;dotnet publish /t:PublishContainerbuilds images without a Dockerfile. - Diagnostics:
dotnet-counters,dotnet-trace,dotnet-dump, BenchmarkDotNet for microbenchmarks.
Pitfalls
- Sync-over-async (
.Result,.Wait()) causing thread-pool starvation or deadlocks; async all the way. async voidoutside event handlers: exceptions crash the process.new HttpClient()per request: socket exhaustion; useIHttpClientFactoryor one shared instance.- Allocating large arrays repeatedly: LOH fragmentation and gen-2 GCs; pool them.
- Boxing structs via interfaces or
object; LINQ in hot loops. - Captured
DbContextacross threads (not thread-safe); EF Core N+1 queries via lazy loading. - Unity: allocations per frame cause GC spikes; the Unity GC differs from CoreCLR's.
- NativeAOT surprises: trimming removes reflection targets; test the published binary, not
dotnet run.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| .NET runtime + SDK | C# / C++ | dotnet/runtime | brew install dotnet (or the installer from dotnet.microsoft.com) | — | — | — | — | CoreCLR, JIT, GC, Native AOT |
| ASP.NET Core | C# | dotnet/aspnetcore | included in the SDK | — | — | — | — | Kestrel, minimal APIs |
| EF Core | C# | dotnet/efcore | dotnet add package Microsoft.EntityFrameworkCore | — | — | — | — | ORM |
| Dapper | C# | DapperLib/Dapper | dotnet add package Dapper | — | — | — | — | micro-ORM for hot query paths |
| BenchmarkDotNet | C# | dotnet/BenchmarkDotNet | dotnet add package BenchmarkDotNet | — | — | — | — | microbenchmarks |
Sources
- Microsoft, .NET documentation, including Native AOT deployment docs and The large object heap docs.
- Toub, Performance Improvements in .NET 8 (and the yearly series), .NET Blog. post
- ECMA-334, C# Language Specification. spec
- Microsoft, ASP.NET Core performance best practices. docs
- Unity, Scripting backends. docs · Microsoft, Desktop Guide for WPF and Windows Forms. docs