Asynchrone Programmierszenarios

Wenn Sie E/A-gebundene Anforderungen haben (z. B. Daten aus einem Netzwerk anfordern, auf eine Datenbank zugreifen oder aus einem Dateisystem lesen oder hineinschreiben), sollten Sie asynchrone Programmierung verwenden. Sie könnten auch CPU-gebundenen Code haben, z.B. eine teure Berechnung, bei der es sich auch um ein gutes Szenario zum Schreiben von asynchronem Code handelt.

C# verfügt über ein asynchrones Programmiermodell auf Sprachebene, das ein problemloses Schreiben von asynchronem Code ermöglicht, ohne dass Sie Rückrufe koordinieren oder eine Bibliothek nutzen müssen, die Asynchronität unterstützt. Es folgt das so genannte Aufgabenbasierte asynchrone Muster (TAP).

Grundlegende Übersicht über das asynchrone Modell

Der Kern der asynchronen Programmierung sind die Task- und Task<T>-Objekte, die asynchrone Vorgänge modellieren. Sie werden von den async- und await-Schlüsselwörtern unterstützt. Das Modell ist in den meisten Fällen recht einfach:

  • Für E/A-gebundenen Code erwarten Sie einen Vorgang, der einen Task oder Task<T> innerhalb einer async-Methode zurückgibt.
  • Für CPU-gebundenen Code erwarten Sie einen Vorgang, der in einem Hintergrundthread mit der Task.Run-Methode gestartet wird.

Das Schlüsselwort await ist sozusagen der Zauberstab. Es übergibt die Steuerung an den Aufrufer der Methode, die await durchgeführt hat. Somit können Benutzeroberflächen letztendlich reaktionsfähig und Dienste elastisch werden. Obwohl es Möglichkeiten gibt, sich mit anderem asynchronem Code als async und await zu befassen, konzentriert sich dieser Artikel auf die Konstrukte auf Sprachebene.

Hinweis

In einigen der folgenden Beispiele wird die Klasse System.Net.Http.HttpClient verwendet, um einige Daten aus einem Webdienst herunterzuladen. Das s_httpClient-Objekt in diesen Beispielen verwendete Objekt ist ein statisches Feld der Program-Klasse (überprüfen Sie das vollständige Beispiel):

private static readonly HttpClient s_httpClient = new();

E/A-gebundenes Beispiel: Herunterladen von Daten von einem Webdienst

Möglicherweise müssen Sie einige Daten aus einem Webdienst herunterladen, wenn auf eine Schaltfläche geklickt wird, möchten aber den UI-Thread nicht blockieren. Das können Sie wie folgt erreichen:

s_downloadButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI as the request
    // from the web service is happening.
    //
    // The UI thread is now free to perform other work.
    var stringData = await s_httpClient.GetStringAsync(URL);
    DoSomethingWithData(stringData);
};

Der Code gibt die Absicht (Daten asynchron herunterladen) an, ohne durch Interaktion mit Task-Objekten vereitelt zu werden.

CPU-gebundenes Beispiel: Ausführen einer Berechnung für ein Spiel

Angenommen, Sie schreiben ein mobiles Spiel, in dem ein Knopfdruck vielen Feinden auf dem Bildschirm Schaden zufügen könnte. Das Durchführen der Schadensberechnung kann teuer sein, und sie auf dem UI-Thread durchzuführen, hält das Spiel scheinbar an, wenn die Berechnung ausgeführt wird!

Die beste Möglichkeit ist, einen Hintergrundthread zu starten, der die Arbeit mit Task.Run erledigt, und das Ergebnis mit await zu erwarten. So wird die Benutzeroberfläche nicht gestört, wenn die Berechnung durchgeführt wird.

static DamageResult CalculateDamageDone()
{
    return new DamageResult()
    {
        // Code omitted:
        //
        // Does an expensive calculation and returns
        // the result of that calculation.
    };
}

s_calculateButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI while CalculateDamageDone()
    // performs its work. The UI thread is free to perform other work.
    var damageResult = await Task.Run(() => CalculateDamageDone());
    DisplayDamage(damageResult);
};

Dieser Code drückt die Absicht des Klickereignisses der Schaltfläche deutlich aus, und zwar in einer nicht blockierenden Weise, und erfordert kein manuelles Verwalten eines Hintergrundthreads.

Was im Hintergrund geschieht

Auf der C#-Seite transformiert der Compiler Ihren Code in einen Zustandsautomaten, der z.B. die Rückgabe der Ausführung protokolliert, wenn await erreicht wird, und das Fortsetzen der Ausführung, wenn ein Hintergrundauftrag abgeschlossen ist.

Für theorieinteressierte Benutzer: Dies ist eine Implementierung des Promise-Modells der Asynchronie.

Wichtigste Bestandteile

  • Async-Code kann für E/A-gebundenen und CPU-gebundene Code, aber für jedes Szenario anders verwendet werden.
  • Async-Code verwendet die Konstrukte Task<T> und Task, die als Modell für Arbeit im Hintergrund verwendet werden können.
  • Das async-Schlüsselwort wandelt eine Methode in eine asynchrone Methode um, mit der Sie das await-Schlüsselwort in ihrem Nachrichtentext verwenden können.
  • Wenn das await-Schlüsselwort angewendet wird, hält es die aufrufende Methode an, und gibt die Steuerung wieder an den Aufrufer zurück, bis die Aufgabe abgeschlossen ist.
  • await kann nur innerhalb einer Async-Methode verwendet werden.

Erkennen von CPU-gebundener und E/A-gebundener Arbeit

Die ersten beiden Beispiele in diesem Leitfaden zeigen, wie Sie async und await für E/A-gebundene und CPU-gebundene Arbeit verwenden können. Es ist wichtig, dass Sie erkennen können, wenn ein zu erledigender Auftrag E/A-gebunden oder CPU-gebunden ist, da dies die Leistung Ihres Codes erheblich beeinflussen und möglicherweise zum fälschlichen Gebrauch bestimmter Konstrukte führen kann.

Hier sind zwei Fragen, die Sie stellen sollten, bevor Sie Code schreiben:

  1. Wird Ihr Code auf etwas „warten“, z.B. auf Daten aus einer Datenbank?

    Wenn Ihre Antwort „Ja“ lautet, ist Ihre Arbeit E/A-gebunden.

  2. Wird Ihr Code eine umfangreiche Berechnung durchführen?

    Wenn Ihre Antwort „Ja“ lautet, ist Ihre Arbeit CPU-gebunden.

Falls Ihre Arbeit E/A-gebunden ist, verwenden Sie async und awaitohneTask.Run. Sie sollten nicht die Task Parallel Library verwenden.

Falls Ihre Arbeit CPU-gebunden ist und die Reaktionsfähigkeit von Bedeutung ist, verwenden Sie async und await, aber übertragen Sie die Arbeit mitTask.Run an einen anderen Thread. Wenn die Arbeit für Parallelität und Konkurrenz geeignet ist, erwägen Sie auch die Verwendung der Task Parallel Library.

Darüber hinaus sollten Sie immer die Ausführung Ihres Codes messen. Sie könnten z.B. in eine Situation geraten, in der Ihre CPU-gebundene Arbeit im Vergleich zum Aufwand der Kontextwechsel beim Multithreading nicht kostspielig genug ist. Jede Entscheidung hat Nachteile, und Sie sollten die Nachteile je nach Ihrer Situation auswählen.

Weitere Beispiele

Die folgenden Beispiele veranschaulichen verschiedene Möglichkeiten, wie Sie asynchronen Code in C# schreiben können. Diese decken einige andere Szenarios ab, auf die Sie möglicherweise stoßen.

Extrahieren von Daten aus einem Netzwerk

Dieser Codeschnipsel lädt den HTML-Code unter der angegebenen URL herunter und zählt, wie oft die Zeichenfolge „.NET“ darin vorkommt. Er verwendet ASP.NET zur Definition einer Web-API-Controllermethode, die diesen Task ausführt und die Zahl zurückgibt.

Hinweis

Wenn Sie eine HTML-Analyse im Produktionscode durchführen möchten, nutzen Sie dafür nicht die regulären Ausdrücke. Verwenden Sie stattdessen eine Analysebibliothek.

[HttpGet, Route("DotNetCount")]
static public async Task<int> GetDotNetCount(string URL)
{
    // Suspends GetDotNetCount() to allow the caller (the web server)
    // to accept another request, rather than blocking on this one.
    var html = await s_httpClient.GetStringAsync(URL);
    return Regex.Matches(html, @"\.NET").Count;
}

Hier sehen Sie das gleiche Szenario, das für eine universelle Windows-App geschrieben wurde, die die gleiche Aufgabe ausführt, wenn auf eine Schaltfläche geklickt wird:

private readonly HttpClient _httpClient = new HttpClient();

private async void OnSeeTheDotNetsButtonClick(object sender, RoutedEventArgs e)
{
    // Capture the task handle here so we can await the background task later.
    var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("https://dotnetfoundation.org");

    // Any other work on the UI thread can be done here, such as enabling a Progress Bar.
    // This is important to do here, before the "await" call, so that the user
    // sees the progress bar before execution of this method is yielded.
    NetworkProgressBar.IsEnabled = true;
    NetworkProgressBar.Visibility = Visibility.Visible;

    // The await operator suspends OnSeeTheDotNetsButtonClick(), returning control to its caller.
    // This is what allows the app to be responsive and not block the UI thread.
    var html = await getDotNetFoundationHtmlTask;
    int count = Regex.Matches(html, @"\.NET").Count;

    DotNetCountLabel.Text = $"Number of .NETs on dotnetfoundation.org: {count}";

    NetworkProgressBar.IsEnabled = false;
    NetworkProgressBar.Visibility = Visibility.Collapsed;
}

Warten auf das Abschließen mehrerer Tasks

Sie könnten sich in einer Situation befinden, in der Sie mehrere Daten gleichzeitig abrufen müssen. Die Task-API enthält die Methoden Task.WhenAll und Task.WhenAny, mit denen Sie asynchronen Code schreiben können, der einen nicht blockierenden Wartevorgang für mehrere Hintergrundaufträge durchführt.

Dieses Beispiel zeigt, wie Sie einen User-Datensatz für einen Satz von userId nehmen könnten.

private static async Task<User> GetUserAsync(int userId)
{
    // Code omitted:
    //
    // Given a user Id {userId}, retrieves a User object corresponding
    // to the entry in the database with {userId} as its Id.

    return await Task.FromResult(new User() { id = userId });
}

private static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
{
    var getUserTasks = new List<Task<User>>();
    foreach (int userId in userIds)
    {
        getUserTasks.Add(GetUserAsync(userId));
    }

    return await Task.WhenAll(getUserTasks);
}

Hier sehen Sie eine weitere Möglichkeit, dies mithilfe von LINQ präziser zu schreiben:

private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
{
    var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
    return await Task.WhenAll(getUserTasks);
}

Auch wenn es weniger Code ist, sollten Sie trotzdem vorsichtig sein, wenn Sie LINQ mit asynchronem Code mischen. Da LINQ verzögerte (lazy) Ausführung verwendet, werden asynchrone Aufrufe nicht sofort ausgeführt, so wie in einer foreach-Schleife, es sei denn, Sie erzwingen, dass die generierte Sequenz einen Aufruf von .ToList() oder .ToArray() durchläuft. Im obigen Beispiel wird Enumerable.ToArray verwendet, um die Abfrage vorzeitig auszuführen und die Ergebnisse in einem Array zu speichern. Dadurch wird der Code id => GetUserAsync(id) ausgeführt und die Aufgabe gestartet.

Wichtige Informationen und Hinweise

Bei asynchroner Programmierung sind einige Details zu berücksichtigen, durch die ein unerwartetes Verhalten verhindert werden kann.

  • async-Methoden benötigen ein await-Schlüsselwort in Ihrem Textkörper, oder sie werden nie zurückgeben!

    Berücksichtigen Sie dies. Wenn await nicht im Textkörper einer async-Methode verwendet wird, generiert der C#-Compiler eine Warnung, aber der Code wird kompiliert und ausgeführt, als ob es sich um eine normale Methode handeln würde. Dies ist unglaublich ineffizient, da der Zustandsautomat, der vom C#-Compiler für die asynchrone Methode generiert wird, nichts erreicht.

  • Fügen Sie „Async“ als Suffix an die Namen aller async-Methoden an, die Sie schreiben.

    Mit dieser in .NET verwendeten Konvention kann leichter zwischen synchronen und asynchronen Methoden unterschieden werden. Bestimmte, von Ihrem Code nicht explizit aufgerufene Methoden (z. B. Ereignishandler oder Webcontrollermethoden) werden nicht unbedingt angewendet. Da sie von Ihrem Code nicht explizit aufgerufen werden, ist es nicht wichtig, ihre Namen explizit anzugeben.

  • async voidsollte nur für Ereignishandler verwendet werden.

    async void ist die einzige Möglichkeit, mit der asynchrone Ereignishandler ausgeführt werden können, da Ereignisse keine Rückgabetypen haben (und somit Task und Task<T> nicht verwenden können). Jede andere Verwendung der async void folgt nicht dem TAP-Modell und kann schwierig zu verwenden sein, wie beispielsweise:

    • Ausnahmen in einer async void-Methode können nicht außerhalb der Methode abgefangen werden.
    • async void-Methoden sind schwierig zu testen.
    • async void-Methoden können schlechte Nebeneffekte verursachen, wenn der Aufrufende nicht erwartet, dass sie asynchron sind.
  • Gehen Sie bei der Verwendung von asynchronen Lambdaausdrücken in LINQ-Ausdrücken sorgfältig vor

    Lambdaausdrücke in LINQ verwenden verzögerte Ausführung. Das bedeutet, dass Code zu einem Zeitpunkt ausgeführt werden kann, zu dem Sie es nicht erwarten. Die Einführung von blockierenden Aufgaben kann schnell zu einem Deadlock führen, wenn diese nicht ordnungsgemäß geschrieben werden. Darüber hinaus kann die Schachtelung von asynchronem Code die Ausführung des Codes erschweren. Async und LINQ sind leistungsstark, sollten zusammen aber so sorgfältig und deutlich wie möglich verwendet werden.

  • Schreiben Sie Code, der Aufgaben in einer nicht blockierenden Art und Weise erwartet

    Wenn Sie den aktuellen Thread blockieren, um auf den Abschluss eines Task zu warten, kann dies zu Deadlocks und blockierten Kontextthreads führen, und eine komplexere Fehlerbehandlung kann erforderlich sein. Die folgende Tabelle enthält Anleitungen zum nicht-blockierenden Warten auf Tasks:

    Verwenden Sie... Anstatt... Wenn Sie dies tun möchten...
    await Task.Wait oder Task.Result Abrufen des Ergebnisses einer Hintergrundaufgabe
    await Task.WhenAny Task.WaitAny Warten auf das Abschließen einer Aufgabe
    await Task.WhenAll Task.WaitAll Warten auf das Abschließen aller Aufgaben
    await Task.Delay Thread.Sleep Warten auf einen Zeitraum
  • Verwenden Sie ggf.ValueTask, soweit möglich.

    Das Zurückgeben eines Task-Objekts von asynchronen Methoden kann Leistungsengpässe in bestimmten Pfaden verursachen. Task ist ein Verweistyp, seine Verwendung bedeutet also das Zuordnen eines Objekts. In Fällen, in denen eine mit dem async-Modifizierer deklarierte Methode ein zwischengespeichertes Ergebnis zurückgibt oder synchron abschließt, können die zusätzlichen Zuordnungen viel Zeit bei der Ausführung kritischer Codeabschnitte kosten. Es kann kostspielig werden, wenn diese Zuordnungen in engen Schleifen auftreten. Weitere Informationen finden Sie unter Generalisierte asynchrone Rückgabetypen.

  • Verwenden Sie ggfConfigureAwait(false).

    Eine häufige Frage ist: „Wann sollte ich die Task.ConfigureAwait(Boolean)-Methode verwenden?“. Die Methode ermöglicht einer Task-Instanz, ihren Awaiter zu konfigurieren. Dies ist ein wichtiger Aspekt, und die falsche Festlegung kann möglicherweise Auswirkungen auf die Leistung und sogar Deadlocks zur Folge haben. Weitere Informationen zu ConfigureAwait finden Sie im ConfigureAwait-FAQ.

  • Schreiben eines weniger statusbehafteten Codes

    Machen Sie sich nicht abhängig vom Zustand globaler Objekte oder der Ausführung bestimmter Methoden. Seien Sie stattdessen nur abhängig von Rückgabewerten der Methoden. Warum?

    • Code wird leichter verständlich sein.
    • Code wird leichter zu testen sein.
    • Das Kombinieren von asynchronem und synchronem Code ist wesentlich einfacher.
    • Racebedingungen können in der Regel ganz vermieden werden.
    • Je nach Rückgabewerten ist das Koordinieren von asynchronem Code einfach.
    • (Bonus) funktioniert hervorragend mit Abhängigkeitsinjektion.

Ein empfohlenes Ziel ist das vollständige oder nahezu vollständige Erreichen referenzieller Transparenz in Ihrem Code. Dies führt zu einer vorhersagbaren, überprüfbaren und verwaltbaren Codebasis.

Vollständiges Beispiel

Der folgende Code besteht aus dem vollständigen Text der Program.cs-Datei für das Beispiel.

using System.Text.RegularExpressions;
using System.Windows;
using Microsoft.AspNetCore.Mvc;

class Button
{
    public Func<object, object, Task>? Clicked
    {
        get;
        internal set;
    }
}

class DamageResult
{
    public int Damage
    {
        get { return 0; }
    }
}

class User
{
    public bool isEnabled
    {
        get;
        set;
    }

    public int id
    {
        get;
        set;
    }
}

public class Program
{
    private static readonly Button s_downloadButton = new();
    private static readonly Button s_calculateButton = new();

    private static readonly HttpClient s_httpClient = new();

    private static readonly IEnumerable<string> s_urlList = new string[]
    {
            "https://learn.microsoft.com",
            "https://learn.microsoft.com/aspnet/core",
            "https://learn.microsoft.com/azure",
            "https://learn.microsoft.com/azure/devops",
            "https://learn.microsoft.com/dotnet",
            "https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio",
            "https://learn.microsoft.com/education",
            "https://learn.microsoft.com/shows/net-core-101/what-is-net",
            "https://learn.microsoft.com/enterprise-mobility-security",
            "https://learn.microsoft.com/gaming",
            "https://learn.microsoft.com/graph",
            "https://learn.microsoft.com/microsoft-365",
            "https://learn.microsoft.com/office",
            "https://learn.microsoft.com/powershell",
            "https://learn.microsoft.com/sql",
            "https://learn.microsoft.com/surface",
            "https://dotnetfoundation.org",
            "https://learn.microsoft.com/visualstudio",
            "https://learn.microsoft.com/windows",
            "https://learn.microsoft.com/xamarin"
    };

    private static void Calculate()
    {
        // <PerformGameCalculation>
        static DamageResult CalculateDamageDone()
        {
            return new DamageResult()
            {
                // Code omitted:
                //
                // Does an expensive calculation and returns
                // the result of that calculation.
            };
        }

        s_calculateButton.Clicked += async (o, e) =>
        {
            // This line will yield control to the UI while CalculateDamageDone()
            // performs its work. The UI thread is free to perform other work.
            var damageResult = await Task.Run(() => CalculateDamageDone());
            DisplayDamage(damageResult);
        };
        // </PerformGameCalculation>
    }

    private static void DisplayDamage(DamageResult damage)
    {
        Console.WriteLine(damage.Damage);
    }

    private static void Download(string URL)
    {
        // <UnblockingDownload>
        s_downloadButton.Clicked += async (o, e) =>
        {
            // This line will yield control to the UI as the request
            // from the web service is happening.
            //
            // The UI thread is now free to perform other work.
            var stringData = await s_httpClient.GetStringAsync(URL);
            DoSomethingWithData(stringData);
        };
        // </UnblockingDownload>
    }

    private static void DoSomethingWithData(object stringData)
    {
        Console.WriteLine("Displaying data: ", stringData);
    }

    // <GetUsersForDataset>
    private static async Task<User> GetUserAsync(int userId)
    {
        // Code omitted:
        //
        // Given a user Id {userId}, retrieves a User object corresponding
        // to the entry in the database with {userId} as its Id.

        return await Task.FromResult(new User() { id = userId });
    }

    private static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
    {
        var getUserTasks = new List<Task<User>>();
        foreach (int userId in userIds)
        {
            getUserTasks.Add(GetUserAsync(userId));
        }

        return await Task.WhenAll(getUserTasks);
    }
    // </GetUsersForDataset>

    // <GetUsersForDatasetByLINQ>
    private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
    {
        var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
        return await Task.WhenAll(getUserTasks);
    }
    // </GetUsersForDatasetByLINQ>

    // <ExtractDataFromNetwork>
    [HttpGet, Route("DotNetCount")]
    static public async Task<int> GetDotNetCount(string URL)
    {
        // Suspends GetDotNetCount() to allow the caller (the web server)
        // to accept another request, rather than blocking on this one.
        var html = await s_httpClient.GetStringAsync(URL);
        return Regex.Matches(html, @"\.NET").Count;
    }
    // </ExtractDataFromNetwork>

    static async Task Main()
    {
        Console.WriteLine("Application started.");

        Console.WriteLine("Counting '.NET' phrase in websites...");
        int total = 0;
        foreach (string url in s_urlList)
        {
            var result = await GetDotNetCount(url);
            Console.WriteLine($"{url}: {result}");
            total += result;
        }
        Console.WriteLine("Total: " + total);

        Console.WriteLine("Retrieving User objects with list of IDs...");
        IEnumerable<int> ids = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
        var users = await GetUsersAsync(ids);
        foreach (User? user in users)
        {
            Console.WriteLine($"{user.id}: isEnabled={user.isEnabled}");
        }

        Console.WriteLine("Application ending.");
    }
}

// Example output:
//
// Application started.
// Counting '.NET' phrase in websites...
// https://learn.microsoft.com: 0
// https://learn.microsoft.com/aspnet/core: 57
// https://learn.microsoft.com/azure: 1
// https://learn.microsoft.com/azure/devops: 2
// https://learn.microsoft.com/dotnet: 83
// https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio: 31
// https://learn.microsoft.com/education: 0
// https://learn.microsoft.com/shows/net-core-101/what-is-net: 42
// https://learn.microsoft.com/enterprise-mobility-security: 0
// https://learn.microsoft.com/gaming: 0
// https://learn.microsoft.com/graph: 0
// https://learn.microsoft.com/microsoft-365: 0
// https://learn.microsoft.com/office: 0
// https://learn.microsoft.com/powershell: 0
// https://learn.microsoft.com/sql: 0
// https://learn.microsoft.com/surface: 0
// https://dotnetfoundation.org: 16
// https://learn.microsoft.com/visualstudio: 0
// https://learn.microsoft.com/windows: 0
// https://learn.microsoft.com/xamarin: 6
// Total: 238
// Retrieving User objects with list of IDs...
// 1: isEnabled= False
// 2: isEnabled= False
// 3: isEnabled= False
// 4: isEnabled= False
// 5: isEnabled= False
// 6: isEnabled= False
// 7: isEnabled= False
// 8: isEnabled= False
// 9: isEnabled= False
// 0: isEnabled= False
// Application ending.

Weitere Ressourcen