.NET Core
.NET Performance-Optimierung: Profiling und Best Practices
Performance-Optimierung in .NET sollte immer evidenzbasiert erfolgen. Zuerst profilen und benchmarken, dann gezielt die wirkungsvollsten Engpässe beseitigen. Auf Verdacht zu optimieren führt fast immer zu verschwendeter Arbeit. Bei einem Hochdurchsatz-Service, den ich optimiert habe, war die anfängliche Vermutung, dass Datenbankaufrufe der Flaschenhals seien -- das Profiling zeigte jedoch, dass überflüssige String-Allokationen in einer Logging-Middleware 40 % der Gen0-Collections verursachten. Nachdem wir die eigentliche Ursache kannten, war das Problem in 20 Minuten behoben.
Messwerkzeuge
Bevor man Code anfasst, braucht man die richtigen Instrumente.
dotnet-counters
Echtzeit-Monitoring von Runtime-Metriken ohne Neustart der Anwendung:
dotnet-counters monitor --process-id <PID> --counters System.RuntimeDamit sieht man live GC-Counts, Allokationsraten, Thread-Pool-Warteschlangenlängen und Exception-Zähler. Das ist das Erste, wonach ich greife, wenn ein Service in Produktion Probleme macht.
dotnet-trace
Erfasst detaillierte Execution-Traces, die man in PerfView oder Visual Studio analysieren kann:
dotnet-trace collect --process-id <PID> --providers Microsoft-DotNETCore-SampleProfilerBenchmarkDotNet
Der Goldstandard für Mikrobenchmarks. Verlassen Sie sich niemals auf Stopwatch für Performance-Vergleiche -- BenchmarkDotNet übernimmt Warmup, statistische Analyse und GC-Messung automatisch.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
[RankColumn]
public class StringConcatBenchmark
{
private readonly string[] _items = Enumerable.Range(0, 1000)
.Select(i => i.ToString())
.ToArray();
[Benchmark(Baseline = true)]
public string MitPlusOperator()
{
var result = "";
foreach (var item in _items)
result += item;
return result;
}
[Benchmark]
public string MitStringBuilder()
{
var sb = new StringBuilder(4096);
foreach (var item in _items)
sb.Append(item);
return sb.ToString();
}
[Benchmark]
public string MitStringJoin()
{
return string.Join("", _items);
}
}
// In Program.cs:
BenchmarkRunner.Run<StringConcatBenchmark>();APM-Werkzeuge
Application Insights, Datadog oder OpenTelemetry für produktionsbezogene Latenz- und Fehlertrends. Mikrobenchmarks zeigen, was möglich ist; APM zeigt, was unter realer Last tatsächlich passiert.
Profiling-Workflow
Ein strukturiertes Vorgehen verhindert, dass man die falsche Stelle optimiert.
Schritt 1: Ziele definieren
Konkrete Ziele festlegen, bevor man Code schreibt. "Schneller machen" ist kein Ziel. "P99-Latenz unter 200ms für den /api/orders-Endpoint bei 500 RPS" ist ein Ziel.
Schritt 2: Baseline erfassen
Den aktuellen Zustand unter realistischer Last messen. Dafür eignen sich Lasttestwerkzeuge wie k6 oder NBomber:
using NBomber.CSharp;
using NBomber.Http.CSharp;
var scenario = Scenario.Create("orders_api", async context =>
{
var request = Http.CreateRequest("GET", "https://localhost:5001/api/orders");
var response = await Http.Send(request);
return response;
})
.WithLoadSimulations(
Simulation.Inject(rate: 500, interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromMinutes(2))
);
NBomberRunner.RegisterScenarios(scenario).Run();Schritt 3: Profilen und Identifizieren
Während des Lasttests dotnet-trace starten oder den Visual Studio Profiler anhängen. Nach inklusiver CPU-Zeit oder Allokationsanzahl sortieren. Auf die Top-3-Hotspots konzentrieren -- die machen in der Regel 80 % des Problems aus.
Schritt 4: Jeweils eine Änderung
Niemals mehrere Optimierungen gleichzeitig durchführen. Jede Änderung muss isoliert messbar sein.
Schritt 5: Erneut messen und validieren
Denselben Lasttest mit denselben Parametern erneut ausführen. Falls keine messbare Verbesserung eintritt, die Änderung zurücknehmen. Komplexität ohne nachgewiesenen Nutzen ist technische Schuld.
GC-Optimierung
Das Verständnis des Garbage Collectors ist essenziell für performanten .NET-Code.
Generationenbasierte Sammlung
Der .NET GC arbeitet mit drei Generationen:
- Gen0: Kurzlebige Objekte. Wird häufig und günstig gesammelt. Die meisten Objekte sollten hier sterben.
- Gen1: Puffer zwischen kurz- und langlebigen Objekten. Objekte, die Gen0 überleben, landen hier.
- Gen2: Langlebige Objekte. Vollständige Collections sind teuer und können spürbare Pausen verursachen.
GC-Druck reduzieren
Die beste Garbage Collection ist die, die gar nicht stattfindet. Jede vermiedene Allokation ist eine verhinderte Collection.
// SCHLECHT: Alloziert bei jedem Aufruf ein neues Byte-Array
public byte[] ProcessRequest(Stream input)
{
var buffer = new byte[4096];
int bytesRead = input.Read(buffer, 0, buffer.Length);
// verarbeiten...
return buffer;
}
// GUT: Aus dem gemeinsamen Pool mieten
public void ProcessRequest(Stream input)
{
var buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
int bytesRead = input.Read(buffer, 0, buffer.Length);
// verarbeiten...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}Span\<T\> für allokationsfreies Parsen
Span<T> ermöglicht das Zerlegen von Speicher, ohne neue Arrays oder Strings zu erzeugen:
// SCHLECHT: Substring alloziert jedes Mal einen neuen String
public (string Key, string Value) ParseHeader(string header)
{
int colonIndex = header.IndexOf(':');
string key = header.Substring(0, colonIndex);
string value = header.Substring(colonIndex + 1).Trim();
return (key, value);
}
// GUT: ReadOnlySpan vermeidet alle Allokationen
public (ReadOnlySpan<char> Key, ReadOnlySpan<char> Value) ParseHeader(ReadOnlySpan<char> header)
{
int colonIndex = header.IndexOf(':');
var key = header[..colonIndex];
var value = header[(colonIndex + 1)..].Trim();
return (key, value);
}Bei einem Hochdurchsatz-Service, den ich optimiert habe, hat die Umstellung des HTTP-Header-Parsings von string.Split auf Span<T>-basiertes Slicing rund 12 MB/s an Gen0-Allokationen eliminiert. Der Durchsatz stieg um 35 % -- ohne jegliche Logikänderung.
Object Pooling
Für teure Objekte, die häufig erzeugt und zerstört werden, eignet sich ObjectPool<T>:
using Microsoft.Extensions.ObjectPool;
public class JsonSerializerPoolPolicy : PooledObjectPolicy<JsonSerializerOptions>
{
public override JsonSerializerOptions Create()
{
return new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
public override bool Return(JsonSerializerOptions obj) => true;
}
// Registrierung in DI
services.AddSingleton<ObjectPool<JsonSerializerOptions>>(
new DefaultObjectPool<JsonSerializerOptions>(new JsonSerializerPoolPolicy(), 32));
// Verwendung
public class OrderService
{
private readonly ObjectPool<JsonSerializerOptions> _optionsPool;
public OrderService(ObjectPool<JsonSerializerOptions> optionsPool)
{
_optionsPool = optionsPool;
}
public string Serialize(Order order)
{
var options = _optionsPool.Get();
try
{
return JsonSerializer.Serialize(order, options);
}
finally
{
_optionsPool.Return(options);
}
}
}Async-Performance-Fallstricke
Async/await ist mächtig, steckt aber voller Fallen, die den Durchsatz leise abtöten.
Sync-over-Async
Das Blockieren auf asynchronem Code ist der häufigste Performance-Killer, den ich in Code-Reviews antreffe. Es führt zu Thread-Pool-Starvation und Deadlocks.
// FATAL: Blockiert einen Thread-Pool-Thread beim Warten auf async-Arbeit
public string GetData()
{
// Kann in ASP.NET zu Deadlocks führen und hungert den Thread-Pool aus
return _httpClient.GetStringAsync("https://api.example.com/data").Result;
}
// KORREKT: Durchgehend async
public async Task<string> GetDataAsync()
{
return await _httpClient.GetStringAsync("https://api.example.com/data");
}ConfigureAwait(false) in Bibliothekscode
In Bibliothekscode immer ConfigureAwait(false) verwenden, um das Erfassen des Synchronisierungskontexts zu vermeiden. Das verhindert Deadlocks und verbessert die Performance:
// In Bibliotheks-/gemeinsamem Code (nicht in Controllern oder UI)
public async Task<Order> GetOrderAsync(int id)
{
var json = await _httpClient
.GetStringAsync($"/orders/{id}")
.ConfigureAwait(false);
return JsonSerializer.Deserialize<Order>(json)!;
}Async-State-Machine bei trivialen Fällen vermeiden
Wenn man direkt einen abgeschlossenen Task zurückgeben kann, sollte man die Async-Maschinerie überspringen:
// Unnötig: Erzeugt eine volle Async-State-Machine nur für einen gecachten Wert
public async Task<Config> GetConfigAsync()
{
if (_cache.TryGetValue("config", out var config))
return config;
return await LoadConfigFromDatabaseAsync();
}
// Besser: State-Machine für den Hot Path vermeiden
public Task<Config> GetConfigAsync()
{
if (_cache.TryGetValue("config", out var config))
return Task.FromResult(config);
return LoadConfigFromDatabaseAsync();
}Parallelität begrenzen
Unbegrenzte Parallelität ist keine Nebenläufigkeit -- es ist ein DoS-Angriff auf die eigenen Downstream-Abhängigkeiten.
// GEFÄHRLICH: Feuert Tausende HTTP-Aufrufe gleichzeitig ab
var tasks = orderIds.Select(id => _httpClient.GetAsync($"/orders/{id}"));
await Task.WhenAll(tasks);
// KONTROLLIERT: Begrenzt auf 20 gleichzeitige Anfragen
using var semaphore = new SemaphoreSlim(20);
var tasks = orderIds.Select(async id =>
{
await semaphore.WaitAsync();
try
{
return await _httpClient.GetAsync($"/orders/{id}");
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);Häufige Performance-Fehler
Diese Muster begegnen mir immer wieder in produktiven .NET-Codebasen.
LINQ in Hot Paths
LINQ ist elegant, alloziert aber stark. In engen Schleifen gewinnt manuelle Iteration:
// Alloziert Iterator, Delegate und möglicherweise Zwischenkollektionen
var total = orders.Where(o => o.Status == OrderStatus.Completed)
.Sum(o => o.Amount);
// Allokationsfreie Alternative für Hot Paths
decimal total = 0;
foreach (var order in CollectionsMarshal.AsSpan(orders))
{
if (order.Status == OrderStatus.Completed)
total += order.Amount;
}Exceptions als Kontrollfluss
Das Werfen und Fangen von Exceptions ist extrem teuer. Niemals für erwartete Zustände verwenden:
// SCHLECHT: 50-mal langsamer als eine einfache Null-Prüfung
try
{
var order = _repository.GetOrder(id);
return order;
}
catch (NotFoundException)
{
return null;
}
// GUT: Pattern-basierte APIs nutzen
public Order? TryGetOrder(int id)
{
return _repository.FindOrder(id); // gibt null zurück, wenn nicht gefunden
}Allokationen durch Logging
String-Interpolation in Log-Aufrufen wird auch dann ausgeführt, wenn das Log-Level deaktiviert ist:
// SCHLECHT: Alloziert den String auch wenn Debug deaktiviert ist
_logger.LogDebug($"Verarbeite Bestellung {order.Id} mit {order.Items.Count} Positionen");
// GUT: Strukturiertes Logging mit verzögerter Auswertung
_logger.LogDebug("Verarbeite Bestellung {OrderId} mit {ItemCount} Positionen",
order.Id, order.Items.Count);Large-Object-Heap-Fragmentierung
Jede Allokation über 85.000 Bytes landet auf dem Large Object Heap (LOH), der nur bei Gen2-Collections aufgeräumt wird und fragmentieren kann:
// SCHLECHT: Alloziert jedes Mal ein 100KB+-Array auf dem LOH
var largeBuffer = new byte[100_000];
// GUT: Aus dem Pool mieten, LOH-Allokationen werden wiederverwendet
var largeBuffer = ArrayPool<byte>.Shared.Rent(100_000);
try
{
// Buffer verwenden...
}
finally
{
ArrayPool<byte>.Shared.Return(largeBuffer);
}Dictionary-Kapazität
Wenn die Anzahl der Elemente bekannt ist und die Kollektion nicht vorbelegt wird, kommt es zu wiederholtem Resize und Rehashing:
// SCHLECHT: Startet mit Standardkapazität, wird mehrfach vergrößert
var lookup = new Dictionary<int, Order>();
foreach (var order in orders) // 10.000 Bestellungen
lookup[order.Id] = order;
// GUT: Mit bekannter Kapazität vorbelegen
var lookup = new Dictionary<int, Order>(orders.Count);
foreach (var order in orders)
lookup[order.Id] = order;Datenzugriffs-Optimierung
N+1-Abfragen eliminieren
Das N+1-Problem ist das häufigste Datenbank-Performance-Problem. Es verwandelt eine Abfrage in Hunderte:
// SCHLECHT: 1 Abfrage für Bestellungen + N Abfragen für Positionen
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// Jede Iteration löst eine Lazy-Load-Abfrage aus
var items = order.Items.ToList();
}
// GUT: Einzelne Abfrage mit Eager Loading
var orders = await _context.Orders
.Include(o => o.Items)
.AsSplitQuery()
.ToListAsync();Projektion für Lesezugriffe
Niemals vollständige Entitäten laden, wenn nur wenige Felder benötigt werden:
// SCHLECHT: Lädt den gesamten Entity-Graphen und trackt Änderungen
var orders = await _context.Orders.Include(o => o.Customer).ToListAsync();
// GUT: Nur das projizieren, was gebraucht wird -- kein Change-Tracking-Overhead
var orderSummaries = await _context.Orders
.AsNoTracking()
.Select(o => new OrderSummaryDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
Total = o.Items.Sum(i => i.Price * i.Quantity)
})
.ToListAsync();Fazit
Nachhaltige .NET-Performance entsteht durch diszipliniertes Messen, gezielte Änderungen und kontinuierliche Regressionsüberwachung. Das Muster ist immer dasselbe: messen, den tatsächlichen Engpass identifizieren, beheben, die Behebung verifizieren und weiterziehen. Widerstehen Sie dem Drang, spekulativ zu optimieren -- jede Optimierung bringt Komplexität, und Komplexität ohne nachgewiesenen Nutzen ist nur technische Schuld.
Die wirkungsvollsten Optimierungen, die ich in Produktion umgesetzt habe, waren die Reduktion von Allokationen in Hot Paths mit Span<T> und ArrayPool, die Behebung von Sync-over-Async-Thread-Starvation und die Beseitigung von N+1-Abfragen. Diese drei Kategorien machen den Großteil der realen .NET-Performance-Probleme aus.
Gerne unterstütze ich bei einem strukturierten Performance-Audit und Optimierungsplan für Ihre .NET-Services.
Verwandte Artikel
RESTful APIs mit ASP.NET Core entwickeln
Lernen Sie die Grundlagen für produktionsreife REST-APIs mit ASP.NET Core. Controller, Routing und Best Practices.
Entity Framework Core: Der moderne Weg für Datenbankoperationen
Verwalten Sie Datenbankoperationen mit Entity Framework Core. Code-First, Migrations und Performance.
Caching-Strategien in .NET: In-Memory, Distributed und Redis
Implementieren Sie effektive Caching-Strategien in .NET. In-Memory, Distributed und Redis.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen