.NET Performance Tips die Daadwerkelijk Verschil Maken
Praktische optimalisaties voor .NET applicaties die substantiële impact hebben op performance en resource gebruik.
Jean-Pierre Broeders
Freelance DevOps Engineer
.NET Performance Tips die Daadwerkelijk Verschil Maken
Performance optimalisatie wordt vaak gezien als iets voor later. Tot de applicatie live gaat en productie servers beginnen te zweten onder realistische load. Een productie API die binnen 100ms moet antwoorden maar structureel 800ms doet, is een probleem dat niet met meer RAM opgelost wordt.
Span en Memory voor String Operaties
De meeste .NET applicaties verspillen geheugen aan substring operaties. Elke keer dat string.Substring() wordt aangeroepen, komt er een nieuwe string instance bij. Bij hoge throughput stapelt dat zich op.
// Traditioneel: nieuwe allocatie per substring
string input = "2026-02-22T06:30:00Z";
string date = input.Substring(0, 10); // alloceert nieuw object
// Met Span<T>: zero-copy slicing
ReadOnlySpan<char> input = "2026-02-22T06:30:00Z";
ReadOnlySpan<char> date = input.Slice(0, 10); // geen allocatie
Het verschil lijkt marginaal, maar in een API die 1000 requests per seconde verwerkt wordt dit snel merkbaar. Span
Voor parsing scenarios presteert dit bijzonder goed:
public static bool TryParseCustomFormat(ReadOnlySpan<char> input, out DateTime result)
{
if (input.Length != 19)
{
result = default;
return false;
}
int year = int.Parse(input.Slice(0, 4));
int month = int.Parse(input.Slice(5, 2));
int day = int.Parse(input.Slice(8, 2));
result = new DateTime(year, month, day);
return true;
}
ValueTask voor Hot Paths
Async/await is standaard in moderne .NET applicaties, maar creëert onnodige allocaties wanneer operaties synchroon kunnen voltooien. Een typisch scenario: cache lookup die meestal een hit oplevert.
// Task<T> alloceert altijd
public async Task<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
return user; // synchrone return, maar Task wordt toch gealloceerd
return await _database.GetUserAsync(id);
}
// ValueTask<T> voorkomt allocatie bij cache hit
public async ValueTask<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
return user; // geen heap allocatie
return await _database.GetUserAsync(id);
}
Een applicatie met 70% cache hit rate bespaart hiermee duizenden allocaties per seconde. Garbage collector heeft minder werk, latency daalt.
StringBuilder Capacity
StringBuilder wordt gebruikt om string concatenatie efficiënt te houden, maar wordt vaak verkeerd geïnitialiseerd.
// Groeit incrementeel, meerdere allocaties
var builder = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
builder.Append($"Item {i}\n");
}
// Pre-allocate met geschatte capacity
var builder = new StringBuilder(capacity: 15000); // ~15 chars per item
for (int i = 0; i < 1000; i++)
{
builder.Append($"Item {i}\n");
}
Wanneer capacity niet wordt opgegeven, start StringBuilder op 16 characters en verdubbelt bij elke overschrijding. Dat betekent meerdere array copies. Bij known workloads scheelt pre-allocatie aanzienlijk.
Array Pooling voor Buffers
Tijdelijke buffers worden vaak gealloceerd en direct weer weggegooid. ArrayPool
// Standaard: nieuwe allocatie per request
byte[] ProcessRequest(Stream input)
{
byte[] buffer = new byte[4096];
// ... verwerk data
return result;
} // buffer wordt GC'd
// Met ArrayPool: hergebruik
byte[] ProcessRequest(Stream input)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
// ... verwerk data
return result;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
Dit patroon is vooral effectief in high-throughput services waar tijdelijke buffers continu nodig zijn. De pool beheert automatisch beschikbare arrays en hergebruikt ze waar mogelijk.
LINQ Materialiseert Collections
LINQ is expressief en leesbaar, maar materialiseert intermediaire resultaten wanneer niet opgelet wordt.
// Materialiseert 3x
var result = items
.Where(x => x.Active) // IEnumerable
.ToList() // List - eerste materialisatie
.OrderBy(x => x.Priority) // IOrderedEnumerable
.ToList() // List - tweede materialisatie
.Take(10) // IEnumerable
.ToList(); // List - derde materialisatie
// Materialiseert 1x aan het einde
var result = items
.Where(x => x.Active)
.OrderBy(x => x.Priority)
.Take(10)
.ToList();
Elke .ToList() call creëert een nieuwe collection en kopieert alle elementen. Chain operaties eerst, materialiseer pas als nodig.
Voor grote datasets kan switch naar for-loops zelfs beter presteren:
// LINQ
var active = items.Where(x => x.Active && x.Priority > 5).ToList();
// Loop (sneller bij grote collections)
var active = new List<User>(capacity: items.Count / 2);
foreach (var item in items)
{
if (item.Active && item.Priority > 5)
active.Add(item);
}
Async Streams voor Grote Datasets
Wanneer grote datasets worden verwerkt, blokkeert traditioneel async vaak totdat alles geladen is. IAsyncEnumerable
// Laadt eerst alles, dan pas returnen
public async Task<List<User>> GetUsersAsync()
{
var users = new List<User>();
await foreach (var batch in _database.GetBatchesAsync())
{
users.AddRange(batch);
}
return users; // caller moet wachten op complete lijst
}
// Stream resultaten direct
public async IAsyncEnumerable<User> GetUsersStreamAsync()
{
await foreach (var batch in _database.GetBatchesAsync())
{
foreach (var user in batch)
{
yield return user; // consumer kan direct verwerken
}
}
}
De consumer kan beginnen met verwerken zodra eerste resultaten binnenkomen, in plaats van te wachten op het complete resultaat.
Conclusie
Deze optimalisaties zijn geen premature optimization. Het zijn fundamentele patterns die vanaf dag één toegepast kunnen worden zonder code complexiteit te verhogen. Span
Performance is geen feature die later toegevoegd wordt. Het wordt ingebouwd vanaf het begin, of het wordt een probleem in productie.
