Start Debugging

Einen Task direkt zurückgeben vs. async/await-Durchreichen in einer C#-Repository-Methode: Was sollten Sie verwenden?

Das Weglassen von async/await in einer durchreichenden Repository-Methode spart etwa 6 ns und 72 Bytes und kostet einen Stack-Frame, die try/catch-Semantik und die sichere Freigabe von Ressourcen. Behalten Sie return await, sofern die Methode nicht reines Durchreichen auf einem gemessenen heißen Pfad ist.

Sie haben eine Repository-Methode, die nichts anderes tut, als an EF Core, Dapper oder einen HttpClient weiterzureichen. Sie können sie als public Task<Order> GetAsync(int id) => _db.Orders.FindAsync(id).AsTask(); schreiben und die Zustandsmaschine einsparen, oder als public async Task<Order> GetAsync(int id) => await _db.Orders.FindAsync(id); und sie behalten. Behalten Sie das await. Das Weglassen bringt unter .NET 10 rund 6 Nanosekunden und 72 Bytes pro Aufruf, was neben jedem Datenbank-Roundtrip unsichtbar ist, und kostet einen Frame in jedem Stack Trace sowie drei Verhaltensweisen, die sich still ändern, sobald die Methode jemals ein using, ein try oder ein lock bekommt. Lassen Sie es nur weg, wenn die Methode ein echtes einzeiliges Durchreichen auf einem Pfad ist, den Sie profiliert haben. Alle Messungen unten laufen auf .NET 10.0.10 mit C# 14; die .NET-11-Geschichte (Preview 7, finale Version am 2026-11-10) steht am Ende und schwächt das Argument fürs Weglassen, statt es zu stärken.

Die beiden Formen im Überblick

Verhaltenreturn await inner() (async)return inner() (weggelassen)
Zustandsmaschine wird erzeugtjanein
Erscheint im Stack Trace der Ausnahmejanein
Kosten, innerer Aufruf endet synchron8,5 ns / 144 B2,6 ns / 72 B
Kosten, innerer Aufruf suspendiert wirklich1111 ns / 286 B1010 ns / 191 B
Sicher innerhalb von using / await usingjanein
try/catch um den Aufruf greift tatsächlichjanein
Ausnahmen aus der Argumentprüfung erscheinenbeim awaitan der Aufrufstelle
Rückgabetyp darf vom inneren abweichenja (Kovarianz, ValueTask)nein (CS0029)
ConfigureAwait(false) anwendbarjan/v (erbt vom inneren)
Löst CS1998 aus, wenn das letzte await entfälltjan/v

Zwei Zeilen dieser Tabelle sind Fakten zur Kompilierzeit, der Rest ist Laufzeitverhalten, das Sie erst in der Produktion entdecken. Diese Asymmetrie ist das gesamte Argument für den Standard.

Was der Compiler tatsächlich erzeugt

async ist keine Aufrufkonvention, sondern eine Umschreibung. Wenn Sie eine Methode als async markieren, verwandelt Roslyn sie in ein Struct, das IAsyncStateMachine implementiert, hebt jede lokale Variable in ein Feld dieses Structs und ersetzt den Rumpf durch ein switch innerhalb von MoveNext(). Die Methode selbst wird zu einem Stub, der einen AsyncTaskMethodBuilder<T> erzeugt, die Maschine startet und builder.Task zurückgibt. Dieses zurückgegebene Task<T> ist ein neuer Task, verschieden von dem, den der innere Aufruf erzeugt hat, und der Builder ist dafür zuständig, ihn abzuschließen, sobald der innere Task fertig ist.

Lassen Sie das async weg, passiert nichts davon. Die Methode kompiliert zu einem einfachen Aufruf plus einem return, und der Aufrufer erhält dieselbe Task<T>-Instanz, die die innere Methode erzeugt hat. Es gibt keinen Builder, keine Zustandsmaschine auf dem Heap, keine Registrierung einer Fortsetzung und keinen zweiten Task.

// .NET 10, C# 14
public sealed class OrderRepository(AppDbContext db)
{
    // elided: the caller gets the exact Task instance EF Core created
    public Task<List<Order>> GetOpenAsync(CancellationToken ct) =>
        db.Orders.Where(o => o.Status == OrderStatus.Open).ToListAsync(ct);

    // await passthrough: EF Core's task is awaited, and a second task is handed out
    public async Task<List<Order>> GetOpenAwaitedAsync(CancellationToken ct) =>
        await db.Orders.Where(o => o.Status == OrderStatus.Open).ToListAsync(ct);
}

Beide kompilieren. Beide sind korrekt für genau diesen Rumpf. Die Unterschiede beginnen in dem Moment, in dem der Rumpf nicht mehr genau dieser ist.

Was das zusätzliche await wirklich kostet

Ich habe beide Formen mit BenchmarkDotNet 0.15.8 auf einem Apple M4 (10 Kerne), macOS 26.6.2, .NET SDK 10.0.302, Host-Laufzeit .NET 10.0.10, Arm64 RyuJIT, mit aktiviertem MemoryDiagnoser und Workstation-GC gemessen. Zwei Szenarien: eine innere Methode, die synchron abschließt (Task.FromResult, der Treffer im First-Level-Cache von EF Core), und eine, die wirklich suspendiert (await Task.Yield(), der echte E/A-Fall).

MethodeMittelRatioAlloziertAlloc-Ratio
Elided_Completed2,63 ns1,0072 B1,00
Awaited_Completed8,47 ns3,22144 B2,00
Elided_Suspends1009,95 ns383,5191 B2,65
Awaited_Suspends1110,81 ns421,8286 B3,97

Liest man die Verhältnisse, sieht das Weglassen nach einem 3x-Gewinn aus. Liest man die absoluten Zahlen, sind es 5,8 Nanosekunden und 72 Bytes auf dem synchronen Pfad, 101 Nanosekunden und 95 Bytes auf dem suspendierenden Pfad. Die 72 Bytes auf dem schnellen Pfad sind der zweite Task<int>, den der Builder alloziert; die 95 Bytes auf dem langsamen Pfad sind die Zustandsmaschine auf dem Heap plus dieser Task.

Stellen Sie das nun neben das, was eine Repository-Methode tatsächlich tut. Ein Roundtrip zu einem lokalen PostgreSQL dauert 200 bis 500 Mikrosekunden. Einer über Availability Zones hinweg dauert einige Millisekunden. 101 Nanosekunden liegen zwischen 0,002 % und 0,05 % einer einzigen Abfrage. Sie bräuchten in der Größenordnung von zehntausend weggelassenen Durchreichungen, um die Zeit einer Abfrage zurückzuholen. Der Fall des synchronen Abschlusses ist der einzige, in dem das Verhältnis nicht vollständig geschluckt wird, und dieser Fall zählt genau dort, wo man es erwartet: eine enge Schleife über einen bereits gecachten Wert, ein schneller ValueTask-Pfad, eine heiße Serialisierungsschleife. Nicht GetOrderByIdAsync.

Wo das Weglassen still das Verhalten ändert

Der Stack-Frame verschwindet

Das sind die Kosten, die Sie täglich zahlen und erst um 3 Uhr nachts bemerken. Eine Methode, die einen Task zurückgibt, ohne ihn zu erwarten, ist in dem Augenblick fertig, in dem sie zurückkehrt; wenn die Ausnahme geworfen wird, ist ihr Frame längst verschwunden. Stack Traces in asynchronem Code sind ein Protokoll ausstehender Fortsetzungen, nicht davon, wer wen aufgerufen hat.

// .NET 10, C# 14
static Task ElidedPassthroughAsync() => ThrowAsync();
static async Task AwaitedPassthroughAsync() => await ThrowAsync();

static async Task ThrowAsync()
{
    await Task.Yield();
    throw new InvalidOperationException("boom");
}

Fängt man oben ab und gibt ex.StackTrace aus, ergeben sich zwei verschiedene Bilder:

=== ELIDED ===
   at Program.<<Main>$>g__ThrowAsync|0_2() in Program.cs:line 16
   at Program.<Main>$(String[] args) in Program.cs:line 4

=== AWAITED ===
   at Program.<<Main>$>g__ThrowAsync|0_2() in Program.cs:line 16
   at Program.<<Main>$>g__AwaitedPassthroughAsync|0_1() in Program.cs:line 11
   at Program.<Main>$(String[] args) in Program.cs:line 7

ElidedPassthroughAsync taucht im Trace überhaupt nicht auf. Bei einem Beispiel aus zwei Methoden ist das eine Kuriosität. In einem echten Dienst, in dem das Gegenstück zu ThrowAsync (eine SqlException aus ToListAsync) aus elf verschiedenen Repository-Methoden erreicht wird, sind genau die weggelassenen Frames diejenigen, die Ihnen gesagt hätten, welche Funktion kaputtgegangen ist. Wenn Sie bereits gelesen haben, wie Runtime Async in .NET 11 asynchrone Stack Traces aufräumt, beachten Sie: es macht die Frames, die Sie haben, weit lesbarer, kann aber keinen Frame wiederbeleben, der nie eine Fortsetzung registriert hat.

using gibt frei, bevor die Arbeit fertig ist

Das ist der Fehler, kein Kompromiss. using var kompiliert zu einem try/finally um den Rest des Gültigkeitsbereichs, und das finally läuft, wenn die Methode zurückkehrt. Eine Methode ohne await kehrt zurück, sobald der innere Aufruf einen unvollständigen Task liefert.

// .NET 10, C# 14 -- broken: the resource is disposed while the task is still running
static Task<int> BadAsync()
{
    using var res = new Resource();
    return res.UseAsync();
}

// correct: the finally runs after the awaited work completes
static async Task<int> GoodAsync()
{
    using var res = new Resource();
    return await res.UseAsync();
}

BadAsync wirft jedes Mal ObjectDisposedException: Cannot access a disposed object. Object name: 'Resource'; GoodAsync schließt ab. Dasselbe gilt für await using über einem IAsyncDisposable, für ein in einem finally freigegebenes SemaphoreSlim und für jeden Transaktionsbereich. Wenn Ihr Repository eine Verbindung öffnet, eine Transaktion beginnt oder aus einem Pool leiht, ist das Weglassen keine Optimierung, sondern ein Zugriff nach der Freigabe. Die Regeln zur Freigabereihenfolge sind ausführlicher in IAsyncDisposable mit await using implementieren und konsumieren behandelt.

try/catch fängt nicht mehr

Derselbe Mechanismus, anderes Symptom. Ein catch-Block fängt nur Ausnahmen, die geworfen werden, während der Frame auf dem Stack liegt. Eine Ausnahme, die geworfen wird, nachdem die innere Methode suspendiert hat, wird über den zurückgegebenen Task ausgeliefert, lange nachdem Ihr try-Block verlassen wurde.

// .NET 10, C# 14
static Task<string> ElidedTryAsync()
{
    try { return ThrowAsync(); }                              // catch never runs
    catch (InvalidOperationException) { return Task.FromResult("caught"); }
}

static async Task<string> AwaitedTryAsync()
{
    try { return await ThrowAsync(); }                        // catch runs
    catch (InvalidOperationException) { return "caught"; }
}

Die weggelassene Version lässt InvalidOperationException zum Aufrufer entweichen; die Version mit await gibt "caught" zurück. Das ist die Variante des Fehlers, die das Code-Review überlebt, weil das try/catch direkt da steht und aussieht, als täte es etwas.

Prüf-Ausnahmen wandern an die Aufrufstelle

Eine async-Methode wirft nie synchron. Jede Ausnahme, auch eine aus der ersten Zeile, wird eingefangen und auf den zurückgegebenen Task gelegt. Eine Methode ohne async hat keinen Builder, in den sie einfangen könnte, also wirft eine Guard-Klausel sofort, an der Aufrufexpression, bevor der Aufrufer überhaupt einen Task zum Erwarten hat.

// .NET 10, C# 14
static Task<int> ElidedValidateAsync(string? id)
{
    ArgumentNullException.ThrowIfNull(id);   // throws at the call site
    return Task.FromResult(id.Length);
}

static async Task<int> AsyncValidateAsync(string? id)
{
    ArgumentNullException.ThrowIfNull(id);   // throws when the task is awaited
    await Task.Yield();
    return id.Length;
}

Aufrufer, die var t = repo.GetAsync(null); /* ... */ await t; schreiben oder die Methode innerhalb eines Select an Task.WhenAll übergeben, verhalten sich zwischen beiden Formen unterschiedlich. Bei der weggelassenen Form kann Select(x => repo.GetAsync(x)).ToList() während der Materialisierung werfen, noch bevor WhenAll überhaupt erreicht wird, und keiner der bereits gestarteten Tasks wird beobachtet. Für sich genommen ist keines der beiden Verhalten falsch, aber zwischen ihnen zu wechseln, indem man ein await hinzufügt oder entfernt, ist kein Refactoring, mit dem Leser rechnen.

Die Fälle, in denen das Weglassen gar nicht kompiliert

Task<T> ist eine Klasse und damit invariant. Task<Dog> ist kein Task<Animal>, und der Compiler sagt es Ihnen:

error CS0029: Cannot implicitly convert type 'System.Threading.Tasks.Task<Dog>'
              to 'System.Threading.Tasks.Task<Animal>'

Dieselbe Wand erscheint, wenn die innere Methode ValueTask<int> zurückgibt und Ihr Vertrag Task<int> lautet, was üblich ist, sobald Sie FindAsync oder eine IAsyncEnumerable-Brücke berühren:

error CS0029: Cannot implicitly convert type 'System.Threading.Tasks.ValueTask<int>'
              to 'System.Threading.Tasks.Task<int>'

await erledigt die Konvertierung kostenlos. Ohne es brauchen Sie .AsTask() (eine Allokation, die die Ersparnis auslöscht) oder eine explizite Umwandlung, die es nicht gibt. Da eine Repository-Schnittstelle fast immer die Abstraktion (Task<IReadOnlyList<Order>>) statt des konkreten Rückgabetyps des Providers (Task<List<Order>>) offenlegt, ist das kein Randfall, sondern der Großteil der Schnittstelle. Und falls Sie erwogen haben, ValueTask stattdessen durch die Schichten nach oben zu reichen, lesen Sie zuerst wann ValueTask sich lohnt: die Einschränkungen kosten mehr als die Allokation.

Das Weglassen entfernt außerdem die Naht, an der Sie ConfigureAwait(false) setzen würden. In einer Bibliothek, die noch auf einen Host mit SynchronizationContext zielt, erbt ein weggelassenes Durchreichen das, was die innere Methode konfiguriert hat, und das kann nichts sein. Das ist eine Stelle weniger zum Annotieren, aber auch eine Stelle weniger zum Korrigieren. Ob diese Naht 2026 noch lohnt, behandelt ConfigureAwait(false) gegenüber dem Standard in .NET 11.

Was Runtime Async in .NET 11 an dieser Abwägung ändert

Runtime Async, das auf net11.0-Projekten kein <EnablePreviewFeatures> mehr braucht, verlagert die Suspendierung aus compilergenerierten Zustandsmaschinen in die CLR. Preview 7 hat zwei Dinge ergänzt, die diesen Vergleich direkt treffen. Asynchrone Methoden durchlaufen jetzt die gestufte Kompilierung, statt dauerhaft den tier0-Code auszuführen, und der JIT hat eine Tail-await-Optimierung bekommen: wenn die letzte Handlung einer asynchronen Methode darin besteht, einen Aufruf zu erwarten, dessen zurückgegebener Task dem eigenen Rückgabetyp der Methode entspricht, kann die Laufzeit einen impliziten Tailcall erzeugen und so “Codegröße und Instruktionszahl deutlich reduzieren”. Diese Optimierung beschreibt genau async Task<T> M() => await Inner();. Es ist das Weglassen, angewandt von der Laufzeit, ohne dass Ihr Quellcode die Frame-Semantik aufgibt.

Dieselben Release Notes berichten, dass die Tail-await-Arbeit in tier0 die maximale Allokationsrate während des Aufwärmens von TechEmpower platform-json von 110.580.952 B/s auf 8.030.616 B/s gesenkt hat. Die Richtung ist eindeutig: die Laufzeit schließt genau die Lücke, die Sie von Hand optimieren würden. Heute return inner() zu schreiben, um 72 Bytes zu sparen, heißt, eine Compiler-Optimierung abzuschreiben, die im November erscheint, und dabei jedes Verhaltensrisiko dauerhaft zu behalten.

Die Analyzer, die Sie in die falsche Richtung drängen

Zwei verbreitete Analyzer melden return await als redundant. RCS1174 “Remove redundant async/await” von Roslynator ist der erste, auf den Sie treffen, und es gibt eine seit Langem offene Bitte, ihn standardmäßig abzuschalten, genau weil Stephen Cleary und das .NET-Team die Transformation als pauschale Regel für unsicher halten. AsyncFixer01 “Unnecessary async/await usage” macht denselben Vorschlag. Keiner von beiden kann sehen, ob Ihre Methode im nächsten Sprint ein using bekommt, und keiner weiß, dass Sie sich in Produktions-Traces auf diesen Frame verlassen.

Die praktische Einstellung ist, beide auszuschalten oder auf suggestion zu setzen und niemals lösungsweit automatisch zu korrigieren. Ein pauschales “RCS1174 auf alle Dokumente anwenden” gehört zu den wenigen Refactorings, die ObjectDisposedException in eine funktionierende Codebasis einschleusen können. Beachten Sie, dass dies die Gegenrichtung zu CS1998 ist: diese Warnung schlägt an, wenn eine async-Methode überhaupt kein await enthält, und dort ist das Entfernen des Modifizierers tatsächlich die richtige Korrektur, wie in CS1998 beheben, ohne die Methode zu zerstören beschrieben.

Die Regel, die ich in Repository-Code anwende

Das Unangenehme an diesem Vergleich ist, dass die weggelassene Form nicht langsamer, nicht hässlicher und nicht falsch ist. Sie ist tatsächlich schneller, um einen Betrag, den kein Repository jemals bemerken wird, im Tausch gegen eine Methode, deren Semantik sich ändert, sobald jemand sie bearbeitet. Das ist zu jedem Kurs ein schlechter Handel, und .NET 11 macht den Zähler gerade zu null.

Verwandte Artikel

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück