.NET Core

.NET Performance-Optimierung: Profiling und Best Practices

15 Min. Lesezeit9. Februar 2026Aktualisiert 9. März 2026
.NET performanceC# optimizationBenchmarkDotNet.NET profilingMemory management C#Span T C#Async performanceGC optimization

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:

bash
dotnet-counters monitor --process-id <PID> --counters System.Runtime

Damit 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:

bash
dotnet-trace collect --process-id <PID> --providers Microsoft-DotNETCore-SampleProfiler

BenchmarkDotNet

Der Goldstandard für Mikrobenchmarks. Verlassen Sie sich niemals auf Stopwatch für Performance-Vergleiche -- BenchmarkDotNet übernimmt Warmup, statistische Analyse und GC-Messung automatisch.

csharp
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:

csharp
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.

csharp
// 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:

csharp
// 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>:

csharp
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.

csharp
// 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:

csharp
// 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:

csharp
// 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.

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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

Haben Sie ein Flutter-Projekt?

Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

Kontakt aufnehmen