Start Debugging

Lösung: CS1998 "This async method lacks 'await' operators and will run synchronously" in C#

CS1998 bedeutet, dass eine async-Methode kein await enthält und deshalb synchron läuft. Entfernen Sie async und geben Sie Task.FromResult zurück, oder ergänzen Sie das fehlende await.

CS1998 erscheint, wenn eine Methode den Modifizierer async trägt, ihr Rumpf aber keinen await-Ausdruck enthält. Die Methode läuft dann vollständig synchron, und Sie zahlen für die asynchrone Maschinerie, ohne Asynchronität zurückzubekommen. Die Lösung besteht fast immer darin, async zu entfernen und eine bereits abgeschlossene Task zurückzugeben: Task.CompletedTask, Task.FromResult(value) oder ValueTask.FromResult(value). Sollte die Methode etwas erwarten, ergänzen Sie das fehlende await. Unterdrücken Sie die Warnung nicht mit await Task.CompletedTask, denn damit bleiben alle Kosten bestehen, die die Warnung bemängelt. Eines hat sich geändert, und die meisten Suchergebnisse haben das noch nicht nachgezogen: Ab dem .NET 10 SDK gibt der C#-Compiler CS1998 überhaupt nicht mehr aus. Alles Folgende ist gegen SDK 10.0.201 (Roslyn 5.3.0) und .NET 10.0.5 verifiziert.

Die Warnung im Kontext

warning CS1998: This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Es handelt sich um eine Warnung, nicht um einen Fehler, der Build läuft also durch, sofern nicht <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in der .csproj steht. Microsoft dokumentiert sie als WRN_AsyncLacksAwaits in der Referenz der Compilermeldungen zu async und await. Die offizielle Empfehlung lautet dort, mindestens einen await-Ausdruck in den Methodenrumpf aufzunehmen oder den Modifizierer async zu entfernen und die Task direkt zurückzugeben.

Warum der Compiler das meldet

Eine async-Methode ohne await wird nie angehalten. Der Rumpf läuft von Anfang bis Ende auf dem aufrufenden Thread, genau wie bei einer synchronen Methode, und die vom Compiler erzeugte Zustandsmaschine übergibt dem Aufrufer anschließend eine Task, die bereits im Zustand RanToCompletion ist. Nichts wurde in den Hintergrund verlagert, nichts überlappte sich. Das Schlüsselwort async hat die Methode nicht asynchron gemacht, es hat nur verändert, wie Ergebnis und Ausnahmen der Methode verpackt werden.

Diese Verpackung ist nicht kostenlos. So viel kostet sie, gemessen auf .NET 10.0.5, x64, Release, mit einer schlichten Stopwatch-Schleife über zwei Millionen Aufrufe und GC.GetAllocatedBytesForCurrentThread für die Allokation. Das sind keine BenchmarkDotNet-Zahlen, betrachten Sie sie also als Größenordnungen und nicht als exakte Werte:

FormBytes pro Aufrufns pro Aufruf
async Task ohne await012,1
Task.CompletedTask02,3
async Task<string> ohne await7227,9
Task.FromResult("ok")7216,0
async ValueTask<int> ohne await015,6
ValueTask.FromResult(42)03,0

Zwei Dinge fallen auf. Die Allokationsspalte ist in jedem Paar identisch, denn eine synchron abschließende async-Methode boxt ihre Zustandsmaschine nie (das Struct bleibt auf dem Stack, solange es keine Unterbrechung gibt), und der nicht generische AsyncTaskMethodBuilder liefert eine zwischengespeicherte abgeschlossene Task zurück. Die Folklore “async allokiert” trifft hier also nicht zu. Was Sie tatsächlich zahlen, sind rund 10 bis 15 Nanosekunden Builder-Infrastruktur pro Aufruf. In einer Methode, die eine Datenbank anspricht, ist das vernachlässigbar, in einer heißen Schleife dagegen relevant. Genau deshalb war das eine Warnung und kein Fehler.

Minimales Beispiel

Der kleinste Code, der die Warnung auf jedem SDK bis einschließlich .NET 9 erzeugt:

// C# 14, .NET SDK 9.0.x or earlier
public class UserService
{
    private readonly Dictionary<int, User> _cache = new();

    public async Task<User> GetUserAsync(int id)   // CS1998
    {
        return _cache[id];
    }
}

Die häufigste Form in echtem Code ist die, die einmal korrekt war und dann verfallen ist:

// C# 14
public async Task<Report> BuildReportAsync(int id)
{
    // var rows = await _db.QueryAsync(id);   <- deleted during a refactor
    var rows = _cachedRows[id];
    return new Report(rows);                  // CS1998, and the method is now
}                                             // async for no reason at all

Die erste Variante schreibt niemand absichtlich. Die zweite taucht ständig auf, und das ist das gesamte Argument für die Warnung: Sie ist ein Verfallsdetektor, keine Stilregel.

Lösung 1: async entfernen und eine abgeschlossene Task zurückgeben

Das ist in der überwiegenden Mehrheit der Fälle die richtige Lösung. Entfernen Sie den Modifizierer, behalten Sie die Task-Signatur und verpacken Sie den Wert:

// C# 14, .NET 10
public Task<User> GetUserAsync(int id)
{
    return Task.FromResult(_cache[id]);
}

public Task SaveAsync(User user)
{
    _cache[user.Id] = user;
    return Task.CompletedTask;          // the Task equivalent of FromResult
}

public ValueTask<int> CountAsync()
{
    return ValueTask.FromResult(_cache.Count);   // no Task allocation at all
}

Die Signatur bleibt unverändert, kein Aufrufer muss angefasst werden, und die Zustandsmaschine verschwindet. Liegt die Methode auf einem heißen Pfad und ist ihr Ergebnis meist synchron verfügbar, entfällt mit ValueTask<T> zusätzlich die 72-Byte-Allokation von Task<T>; die Abwägungen stehen in was ValueTask ist und wann es sich lohnt.

Eine Verhaltensänderung müssen Sie berücksichtigen, und deshalb ist diese Lösung nicht rein mechanisch. In einer async-Methode wird eine im Rumpf geworfene Ausnahme aufgefangen und auf die zurückgegebene Task gelegt. Ohne async wird die Ausnahme synchron an der Aufrufstelle geworfen, bevor der Aufrufer überhaupt eine Task zum Erwarten erhält. Das lässt sich leicht zeigen:

// C# 14, .NET 10.0.5
static async Task ThrowsFromTaskAsync() => throw new InvalidOperationException("boom");
static Task ThrowsAtCallSiteAsync() => throw new InvalidOperationException("boom");

var t1 = ThrowsFromTaskAsync();   // returns a faulted task, no exception here
await t1;                          // InvalidOperationException surfaces here

var t2 = ThrowsAtCallSiteAsync();  // throws right here, before any await

In den meisten Fällen ist dieser Unterschied unsichtbar, weil der Aufrufer sofort erwartet. Sichtbar wird er, sobald der Aufruf nicht sofort erwartet wird: beim Sammeln von Tasks in einer Liste für Task.WhenAll, beim Ablegen einer Task in einem Feld oder bei einem try/catch, das nur das await umschließt. Kann Ihre Methode eine Ausnahme werfen, bevor sie einen Wert liefert, behalten Sie die Ausnahme in der Task:

// C# 14, .NET 10
public Task<Stream> OpenAsync(string path)
{
    try
    {
        return Task.FromResult<Stream>(new FileStream(path, FileMode.Open));
    }
    catch (Exception ex)
    {
        return Task.FromException<Stream>(ex);   // same shape as async would produce
    }
}

Genau dieses Szenario hat Stephen Toub in dotnet/roslyn#77001 angeführt, um zu begründen, dass ein naives Umschreiben auf Task.FromResult oft falsch ist.

Lösung 2: das fehlende await ergänzen

Ist die Warnung nach einem Refactoring aufgetaucht, besteht die ehrliche Lösung meist darin, den Aufruf wiederherzustellen, der erwartet werden sollte:

// C# 14, .NET 10
public async Task<Report> BuildReportAsync(int id, CancellationToken ct)
{
    var rows = await _db.QueryAsync(id, ct);
    return new Report(rows);
}

Suchen Sie in derselben Datei nach einem benachbarten CS4014 “because this call is not awaited”. Beide Warnungen zusammen, eine über fehlende awaits und eine über eine fallengelassene Task, sind ein nahezu sicheres Zeichen dafür, dass ein await verlorengegangen ist, und nicht dafür, dass die Methode nie asynchron war.

Lösung 3: Task.Run, und warum der eigene Vorschlag der Meldung meist falsch ist

Der Warnungstext schlägt await Task.Run(...) für CPU-lastige Arbeit vor. Für einen Desktop-Client ist dieser Rat korrekt, dort geht es darum, den UI-Thread zu entlasten:

// C# 14, .NET 10, WPF or MAUI
private async void OnCalculateClicked(object sender, EventArgs e)
{
    var result = await Task.Run(() => CrunchNumbers(_input));   // UI stays responsive
    ResultLabel.Text = result.ToString();
}

In ASP.NET Core ist derselbe Rat falsch. Es gibt keinen UI-Thread zu entlasten, und die Anfrage läuft bereits auf einem Threadpool-Thread; Task.Run reicht die Arbeit nur an einen anderen Threadpool-Thread weiter, fügt einen Kontextwechsel plus eine Task-Allokation hinzu und verkleinert gleichzeitig den Pool, der andere Anfragen bedienen soll. In einer Serveranwendung sollte eine synchrone Methode synchron bleiben oder durch Erwarten echter E/A tatsächlich asynchron werden.

Lösung 4: Interface-Implementierungen und Overrides, die Sie nicht ändern können

Am schlechtesten kam die Warnung mit einem Interface-Member oder einer virtuellen Methode zurecht, die Task zurückgeben muss, obwohl Ihre konkrete Implementierung nichts zu erwarten hat:

// C# 14, .NET 10
public interface INotifier
{
    Task NotifyAsync(string message);
}

public sealed class NullNotifier : INotifier
{
    public Task NotifyAsync(string message) => Task.CompletedTask;   // no async, no warning
}

async zu entfernen bleibt die Antwort. Wo das wirklich unmöglich ist, unterdrücken Sie eng begrenzt statt global:

// C# 14, .NET SDK 9.0.x or earlier
#pragma warning disable CS1998 // required by INotifier, nothing to await here
public async Task NotifyAsync(string message) { _log.Info(message); }
#pragma warning restore CS1998

Bevorzugen Sie #pragma mit einem begründenden Kommentar gegenüber <NoWarn>$(NoWarn);CS1998</NoWarn> in der Projektdatei. Projektweite Unterdrückung verbirgt jedes künftige Vorkommen, einschließlich des Refactoring-Verfalls, den die Warnung wirklich gut erkennt.

Wohin die Warnung in .NET 10 verschwunden ist

Wenn Sie das hier lesen, weil die Warnung verschwunden ist und nicht, weil sie aufgetaucht ist: Sie wurde aus dem Compiler entfernt. dotnet/roslyn#80144, gemergt am 2025-09-19 für den Meilenstein 18.0 P2, hat WRN_AsyncLacksAwaits vollständig entfernt, zusammen mit den C#-Codefixes “Remove async modifier” und “Make method synchronous”. Die Begründung aus dotnet/roslyn#77001: Die Warnung drängte Entwickler zu schlechterem Code. Wer einen Task-Vertrag erfüllen musste, schrieb await Task.FromResult(result), um sie loszuwerden. Das behält die Zustandsmaschine, fügt ein await hinzu und macht die Methode strikt teurer, ohne sie sicherer zu machen. Die abschließende Entscheidung im Thread war eindeutig: Nach der Diskussion und besonders im Hinblick auf Runtime Async werde diese Warnung vollständig entfernt.

Die Entfernung lässt sich mit einem einzigen Build überprüfen. Dieses Projekt kompiliert auf SDK 10.0.201 ohne Warnungen:

// C# 14, .NET SDK 10.0.201 -> 0 warnings
public class C
{
    public async Task Empty() { }
    public async Task<int> Value() { return 42; }
    public async void VoidMethod() { }
    public async IAsyncEnumerable<int> Stream() { yield return 1; }
}

Keine dieser Methoden erzeugt eine Diagnose, und weder -warnaserror:CS1998 noch dotnet_diagnostic.CS1998.severity = error in der .editorconfig bringen sie zurück, weil es keine Diagnose mehr gibt, die man hochstufen könnte. CS4014 meldet derselbe Compiler weiterhin, das Ganze betrifft also gezielt CS1998 und ist kein allgemeiner Verlust von async-Warnungen.

Die Funktion kam als optionale IDE-Analyzer zurück, in dotnet/roslyn#81835, gemergt am 2026-01-07 für den Meilenstein 18.4, bewusst auf zwei Diagnose-IDs aufgeteilt, damit der Fall der Interface-Implementierung separat eingestellt werden kann:

Beide erscheinen als “Make method synchronous” mit der Meldung “Method can be made synchronous”, und keine der beiden ist standardmäßig aktiv. So holen Sie das alte Verhalten dort zurück, wo Sie es wollen:

# .editorconfig
[*.cs]
dotnet_diagnostic.IDE0390.severity = warning
dotnet_diagnostic.IDE0391.severity = suggestion
<!-- .csproj: required to see IDE rules in dotnet build, not just in the IDE -->
<PropertyGroup>
  <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>

Ein Vorbehalt aus dem Test: Auf SDK 10.0.201 sind die beiden Analyzer noch nicht vorhanden. Die obige Konfiguration liefert nichts, während eine Kontrollregel wie IDE0161 bei gleicher Konfiguration normal meldet. Die Infrastruktur funktioniert also, die Regeln sind in diesem SDK-Band schlicht noch nicht enthalten. Sie zielen auf den Meilenstein 18.4, es braucht daher ein neueres SDK oder ein Update von Visual Studio 2026.

Fallstricke und Varianten

Das mentale Modell, das den Wegfall der Warnung überlebt: async ist eine Kompilierungsstrategie, kein API-Vertrag. Der Vertrag ist die Task-Signatur. Wenn es nichts zu erwarten gibt, behalten Sie den Vertrag und lassen die Strategie fallen, wobei alles, was werfen kann, die Task weiterhin fehlschlagen lassen soll, statt an der Aufrufstelle zu werfen. Das war die richtige Antwort, als CS1998 Sie noch angeschrien hat, und es ist die richtige Antwort, jetzt wo sie verstummt ist.

Verwandte Beiträge

Quellen

Comments

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

< Zurück