Udostępnij za pośrednictwem


Scenariusze programowania asynchronicznego

Jeśli kod implementuje scenariusze związane z we/wy w celu obsługi żądań danych sieciowych, dostępu do bazy danych lub odczytu/zapisu systemu plików, programowanie asynchroniczne jest najlepszym rozwiązaniem. Możesz również napisać kod asynchroniczny dla scenariuszy powiązanych z procesorem CPU, takich jak kosztowne obliczenia.

Język C# ma asynchroniczny model programowania, który umożliwia łatwe pisanie kodu asynchronicznego bez konieczności żonglowania wywołaniami zwrotnymi lub dostosowania się do biblioteki obsługującej asynchronię. Model jest zgodny z tym, co jest znane jako asynchroniczny wzorzec oparty na zadaniach (TAP).

Eksplorowanie modelu programowania asynchronicznego

Obiekty Task i Task<T> reprezentują rdzeń programowania asynchronicznego. Te obiekty są używane do modelowania operacji asynchronicznych przez obsługę słów kluczowych async i await. W większości przypadków model jest dość prosty zarówno w scenariuszach związanych z wejściem/wyjściem, jak i zależnych od CPU. Wewnątrz metody async:

  • kod związany z we/wy uruchamia operację reprezentowaną przez obiekt Task lub Task<T> w metodzie async.
  • kod powiązany z procesorem CPU rozpoczyna operację w tle przy użyciu metody Task.Run.

W obu przypadkach aktywny Task reprezentuje operację asynchroniczną, która może nie zostać ukończona.

Słowo await kluczowe to miejsce, w którym dzieje się magia. Daje ona kontrolę obiektowi wywołującym metodę zawierającą wyrażenie await, a ostatecznie umożliwia interfejsowi użytkownika odpowiadanie lub elastyczne działanie usługi. Chociaż istnieją sposoby podejścia do kodu asynchronicznego innego niż używanie wyrażeń async i await, ten artykuł koncentruje się na konstrukcjach na poziomie języka.

Uwaga

Niektóre przykłady przedstawione w tym artykule używają klasy System.Net.Http.HttpClient do pobierania danych z usługi internetowej. W przykładowym kodzie obiekt s_httpClient jest polem statycznym typu Program klasy:

private static readonly HttpClient s_httpClient = new();

Aby uzyskać więcej informacji, sprawdź kompletny przykładowy kod na końcu tego artykułu.

Przegląd podstawowych pojęć

Podczas implementowania programowania asynchronicznego w kodzie języka C# kompilator przekształca program w maszynę stanu. Ta konstrukcja śledzi różne operacje i stan w kodzie, takie jak przekazywanie wykonywania, gdy kod osiągnie wyrażenie await, oraz wznowienie wykonywania, gdy zakończy się zadanie w tle.

Jeśli chodzi o teorię informatyki, programowanie asynchroniczne jest implementacją modelu asynchronii Promise.

W modelu programowania asynchronicznego istnieje kilka kluczowych pojęć, które należy zrozumieć:

  • Możesz użyć kodu asynchronicznego zarówno dla kodu powiązanego z operacjami we/wy, jak i powiązanego z procesorem CPU, ale implementacja jest inna.
  • Kod asynchroniczny używa obiektów Task<T> i Task jako konstrukcji do modelowania pracy działającej w tle.
  • Słowo kluczowe async deklaruje metodę jako metodę asynchroniczną, która umożliwia użycie słowa kluczowego await w treści metody.
  • Kiedy zastosujesz słowo kluczowe await, kod zawiesza wykonywanie metody wywołującej i zwraca kontrolę do jej wywoływacza do momentu zakończenia zadania.
  • Wyrażenie await można używać tylko w metodzie asynchronicznej.

Przykład związany z wejściem/wyjściem: pobieranie danych z serwisu internetowego

W tym przykładzie, gdy użytkownik wybierze przycisk, aplikacja pobiera dane z usługi internetowej. Nie chcesz blokować wątku interfejsu użytkownika dla aplikacji podczas procesu pobierania. Następujący kod wykonuje to zadanie:

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);
};

Kod wyraża intencję (pobieranie danych asynchronicznie) bez zakłócania interakcji z obiektami Task .

Przykład związany z CPU: Uruchom obliczenia gry

W następnym przykładzie gra mobilna zadaje obrażenia kilku agentom na ekranie w odpowiedzi na zdarzenie naciśnięcia przycisku. Obliczanie szkód może być kosztowne. Uruchomienie obliczeń w wątku interfejsu użytkownika może spowodować problemy z wyświetlaniem i interakcją z interfejsem użytkownika podczas obliczania.

Najlepszym sposobem obsługi zadania jest uruchomienie wątku w tle, aby ukończyć pracę za pomocą metody Task.Run. Operacja daje wynik za pomocą wyrażenia await. Operacja jest wznawiana po zakończeniu zadania. Takie podejście umożliwia bezproblemowe działanie interfejsu użytkownika podczas wykonywania pracy w tle.

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);
};

Kod wyraźnie wyraża cel zdarzenia przycisku Clicked. Nie wymaga ręcznego zarządzania wątkiem w tle i wykonuje zadanie w sposób nieblokujący.

Rozpoznaj scenariusze ograniczone przez CPU i operacje wejścia/wyjścia

W poprzednich przykładach pokazano, jak używać modyfikatora async i wyrażenia await dla pracy wejścia/wyjścia i intensywnie wykorzystującej CPU. Przykład dla każdego scenariusza przedstawia sposób, w jaki kod różni się w zależności od tego, gdzie operacja jest powiązana. Aby przygotować się do implementacji, musisz zrozumieć, jak określić, kiedy operacja jest ograniczona przez I/O czy CPU. Wybór implementacji może znacznie wpłynąć na wydajność kodu i potencjalnie prowadzić do błędnego zastosowania konstrukcji.

Przed napisaniem dowolnego kodu należy zadać dwa podstawowe pytania:

Pytanie Scenariusz Implementacja
Czy kod powinien czekać na wynik lub akcję, na przykład dane z bazy danych? Ograniczony przez we/wy Użyj modyfikatora async i wyrażenia awaitbez metody Task.Run.

Unikaj korzystania z Biblioteki Równoległych Zadań.
Czy kod powinien uruchamiać kosztowne obliczenia? zależne od procesora Użyj modyfikatora async i wyrażenia await, ale zduplikuj pracę w innym wątku przy użyciu metody Task.Run. Takie podejście rozwiązuje problemy związane z czasem reakcji CPU.

Jeśli praca jest odpowiednia dla współbieżności i równoległości, rozważ również użycie Task Parallel Library.

Zawsze mierz wykonywanie kodu. Możesz zauważyć, że praca związana z procesorem CPU nie jest wystarczająco kosztowna w porównaniu z obciążeniem przełączników kontekstowych podczas wielowątku. Każdy wybór ma kompromisy. Wybierz prawidłowy kompromis w twojej sytuacji.

Eksplorowanie innych przykładów

Przykłady w tej sekcji przedstawiają kilka sposobów pisania kodu asynchronicznego w języku C#. Obejmują one kilka scenariuszy, które mogą wystąpić.

Wyodrębnianie danych z sieci

Poniższy kod pobiera kod HTML z danego adresu URL i zlicza liczbę wystąpień ciągu .NET w kodzie HTML. Kod używa ASP.NET do zdefiniowania metody kontrolera interfejsu API sieci Web, która wykonuje zadanie i zwraca liczbę.

Uwaga

Jeśli planujesz analizowanie kodu HTML w kodzie produkcyjnym, nie używaj wyrażeń regularnych. Zamiast tego użyj biblioteki analizowania.

[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;
}

Możesz napisać podobny kod dla aplikacji uniwersalnej systemu Windows i wykonać zadanie zliczania po naciśnięciu przycisku:

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.
    // It's important to do the extra work here before the "await" call,
    // so 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 action 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;
}

Poczekaj na ukończenie wielu zadań

W niektórych scenariuszach kod musi pobierać wiele fragmentów danych jednocześnie. Interfejsy API Task udostępniają metody, które umożliwiają pisanie kodu asynchronicznego, który wykonuje nieblokujące oczekiwanie na wiele zadań w tle:

W poniższym przykładzie pokazano, jak można pobrać dane obiektu User dla zbioru obiektów userId.

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);
}

Ten kod można napisać bardziej zwięźle przy użyciu LINQ:

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

Mimo że piszesz mniej kodu przy używaniu LINQ, zachowaj ostrożność podczas mieszania LINQ z kodem asynchronicznym. LINQ używa odroczonego (lub leniwego) wykonywania. Wywołania asynchroniczne nie są wykonywane natychmiast, jak dzieje się to w pętli foreach, chyba że wymusisz iterowanie wygenerowanej sekwencji za pomocą wywołania metody .ToList() lub .ToArray(). W tym przykładzie użyto metody Enumerable.ToArray do wykonywania zapytania z niecierpliwością i przechowywania wyników w tablicy. Takie podejście wymusza wykonanie instrukcji id => GetUserAsync(id), aby uruchomić i zainicjować zadanie.

Zapoznaj się z zagadnieniami dotyczącymi programowania asynchronicznego

W przypadku programowania asynchronicznego należy pamiętać o kilku szczegółach, które mogą zapobiec nieoczekiwanemu zachowaniu.

Używanie metody await wewnątrz metody async()

W przypadku używania modyfikatora async należy uwzględnić co najmniej jedno wyrażenie await w treści metody. Jeśli kompilator nie napotka wyrażenia await, metoda nie uda się wywołać. Mimo że kompilator generuje ostrzeżenie, kod nadal jest kompilowany, a kompilator uruchamia metodę . Maszyna stanu wygenerowana przez kompilator języka C# dla metody asynchronicznej nie wykonuje niczego, więc cały proces jest wysoce nieefektywny.

Dodawanie sufiksu "Async" do asynchronicznych nazw metod

Konwencja stylu platformy .NET polega na dodaniu sufiksu "Async" do wszystkich asynchronicznych nazw metod. Takie podejście ułatwia odróżnienie metod synchronicznych i asynchronicznych. Niektóre metody, które nie są jawnie wywoływane przez kod (takie jak procedury obsługi zdarzeń lub metody kontrolera sieci Web), nie muszą być stosowane w tym scenariuszu. Ponieważ te elementy nie są jawnie wywoływane przez kod, używanie jawnego nazewnictwa nie jest tak ważne.

Zwracanie wartości "async void" tylko z programów obsługi zdarzeń

Obsługiwacze zdarzeń muszą deklarować typy zwrotne jako void i nie mogą używać ani zwracać obiektów Task i Task<T>, tak jak robią to inne metody. Podczas pisania asynchronicznych procedur obsługi zdarzeń należy użyć modyfikatora async w metodzie obsługi zwracającej void. Inne implementacje metod zwracających async void nie są zgodne z modelem TAP i mogą stwarzać wyzwania:

  • Wyjątki zgłoszone w metodzie async void nie mogą być przechwytywane poza tą metodą
  • async void metody są trudne do przetestowania
  • async void metody mogą powodować negatywne skutki uboczne, jeśli obiekt wywołujący nie spodziewa się, że będą asynchroniczne

Należy zachować ostrożność z asynchronicznymi lambdami w LINQ

Należy zachować ostrożność podczas implementowania asynchronicznych lambd w wyrażeniach LINQ. Wyrażenia lambda w LINQ używają odroczonego wykonywania, co oznacza, że kod może zostać wykonany w nieoczekiwanym czasie. Wprowadzenie zadań blokujących w tym scenariuszu może łatwo spowodować zakleszczenie, jeśli kod nie jest poprawnie napisany. Ponadto zagnieżdżanie kodu asynchronicznego może utrudniać zrozumienie wykonania kodu. Asynchroniczność i LINQ są potężne, ale te techniki powinny być stosowane razem tak starannie i jasno, jak to możliwe.

Wydajność zadań w sposób nieblokujący

Jeśli program potrzebuje wyniku zadania, napisz kod, który implementuje wyrażenie await w sposób nieblokujący. Zablokowanie bieżącego wątku jako środka do synchronicznego oczekiwania na ukończenie elementu Task może prowadzić do zakleszczeń i blokady wątków kontekstowych. Takie podejście programistyczne może wymagać bardziej złożonej obsługi błędów. Poniższa tabela zawiera wskazówki dotyczące sposobu uzyskiwania dostępu do wyników z zadań w sposób nieblokujący:

Scenariusz zadania Bieżący kod Zastąp słowem 'await'
Pobierz wynik zadania w tle Task.Wait lub Task.Result await
Kontynuuj po zakończeniu dowolnego zadania Task.WaitAny await Task.WhenAny
kontynuuj , gdy wszystkie zadania są zakończone Task.WaitAll await Task.WhenAll
Kontynuuj po pewnym czasie Thread.Sleep await Task.Delay

Rozważ użycie typu ValueTask

Gdy metoda asynchroniczna zwraca obiekt Task, na określonych ścieżkach mogą zostać wprowadzone wąskie gardła wydajności. Ponieważ Task jest typem referencyjnym, obiekt Task jest przydzielany ze sterty. Jeśli metoda zadeklarowana za pomocą modyfikatora async zwraca buforowany wynik lub kończy się synchronicznie, dodatkowe alokacje mogą naliczać znaczne koszty czasu w krytycznych dla wydajności sekcjach kodu. Ten scenariusz może stać się kosztowny, gdy alokacje występują w ciasnych pętlach. Aby uzyskać więcej informacji, zapoznaj się z uogólnionymi asynchronicznymi typami zwrotnymi.

Dowiedz się, kiedy ustawić element ConfigureAwait(false)

Deweloperzy często pytają, kiedy używać wartości typu boolean Task.ConfigureAwait(Boolean). API umożliwia Task instancji skonfigurowanie kontekstu maszyny stanów, która implementuje dowolne wyrażenie await. Jeśli wartość logiczna nie jest poprawnie ustawiona, wydajność może się pogorszyć lub mogą wystąpić zakleszczenia. Aby uzyskać więcej informacji, zobacz ConfigureAwait FAQ.

Pisz kod mniej zależny od stanu

Unikaj pisania kodu, który zależy od stanu obiektów globalnych lub wykonywania określonych metod. Zamiast tego, polegaj tylko na zwracanych wartościach metod. Istnieje wiele korzyści związanych z pisaniem kodu, który jest mniej stanowy:

  • Łatwiej jest wnioskować o kodzie
  • Łatwiejsze testowanie kodu
  • Łatwiejsze do łączenia kodu asynchronicznego i synchronicznego
  • Możliwość uniknięcia warunków wyścigu w kodzie
  • Prosty do koordynowania kodu asynchronicznego, który zależy od zwracanych wartości
  • (Bonus) Dobrze sprawdza się z iniekcją zależności w kodzie

Zalecanym celem jest osiągnięcie pełnej lub niemal pełnej przezroczystości referencyjnej w kodzie. Takie podejście powoduje przewidywalną, testową i konserwalną bazę kodu.

Przejrzyj kompletny przykład

Poniższy kod reprezentuje kompletny przykład, który jest dostępny w przykładowym pliku Program.cs.

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/maui"
    };

    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/maui: 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.