Typowe błędy async/await

Async/await upraszcza programowanie asynchroniczne, ale pewne błędy pojawiają się wielokrotnie. W tym artykule opisano pięć najczęściej występujących usterek w kodzie asynchroniczny i pokazano, jak naprawić każdy z nich.

Metoda asynchroniczna jest uruchamiana synchronicznie

Dodanie słowa kluczowego async do metody nie powoduje uruchomienia metody w wątku w tle. Nakazuje kompilatorowi zezwolenie na await wewnątrz ciała metody i opakowanie wartości zwracanej w Task. Wywołując asynchroniczną metodę, działa ona synchronicznie, dopóki nie osiągnie pierwszego await w nieukończonym awaitable. Jeśli metoda nie zawiera wyrażeń await lub jeśli każda oczekiwana funkcja została już ukończona, metoda jest całkowicie ukończona w wątku wywołującym.

public static class SyncExecutionExample
{
    public static Task<int> ComputeAsync()
    {
        // No await in this method — it runs entirely synchronously.
        return Task.FromResult(42);
    }
}
Public Module SyncExecutionExample
    Public Function ComputeAsync() As Task(Of Integer)
        ' No Await in this method — it runs entirely synchronously.
        Return Task.FromResult(42)
    End Function
End Module

W tym przypadku metoda zwraca ukończone zadanie natychmiast, ponieważ nigdy nie przerywa. Kompilator emituje ostrzeżenie, gdy w metodzie asynchronicznej brakuje await wyrażeń.

Jeśli twoim celem jest odciążenie pracy obciążającej procesor do wątku puli wątków, użyj Run zamiast async.

public static class OffloadExample
{
    public static int ComputeIntensive()
    {
        int sum = 0;
        for (int i = 0; i < 1_000; i++)
            sum += i;
        return sum;
    }

    public static Task<int> ComputeOnThreadPoolAsync()
    {
        return Task.Run(() => ComputeIntensive());
    }
}
Public Module OffloadExample
    Public Function ComputeIntensive() As Integer
        Dim sum As Integer = 0
        For i As Integer = 0 To 999
            sum += i
        Next
        Return sum
    End Function

    Public Function ComputeOnThreadPoolAsync() As Task(Of Integer)
        Return Task.Run(Function() ComputeIntensive())
    End Function
End Module

Aby uzyskać więcej wskazówek dotyczących tego, kiedy używać Task.Run, zobacz Asynchroniczne otoki dla metod synchronicznych.

Nie można oczekiwać na metodę async void

Po przekształceniu synchronicznej metody w asynchroniczną void, zmień typ zwracany na Task. Jeśli pozostawisz typ zwracany jako void, metoda stanie się "async void", czego nie można oczekiwać:

public static class AsyncVoidExample
{
    // BAD: async void — can't be awaited.
    public static async void DoWorkBadAsync()
    {
        await Task.Delay(100);
    }

    // GOOD: async Task — callers can await this.
    public static async Task DoWorkGoodAsync()
    {
        await Task.Delay(100);
    }
}
Public Module AsyncVoidExample
    ' BAD: Async Sub — can't be awaited.
    Public Async Sub DoWorkBadAsync()
        Await Task.Delay(100)
    End Sub

    ' GOOD: Async Function returning Task — callers can await this.
    Public Async Function DoWorkGoodAsync() As Task
        Await Task.Delay(100)
    End Function
End Module

Metody asynchroniczne void służą do określonego celu: procedury obsługi zdarzeń najwyższego poziomu w frameworkach interfejsu użytkownika. Poza procedurami obsługi zdarzeń zawsze zwracaj Task lub Task<T> z metod asynchronicznych. Metody asynchroniczne void mają następujące wady:

  • Wyjątki są nieobserwowane. Wyjątki zgłoszone w metodzie async void są propagowane do SynchronizationContext, która była aktywna w momencie rozpoczęcia metody. Obiekt wywołujący nie może przechwycić tych wyjątków.
  • Osoby wywołujące nie mogą śledzić ukończenia. Bez Task nie ma mechanizmu, który pozwala ustalić, kiedy operacja się zakończy.
  • Testowanie jest trudne. Nie można czekać na metodę w teście, aby zweryfikować jej zachowanie.

Zakleszczenia wynikające z blokowania kodu asynchronicznego

Ta usterka jest najczęstszą przyczyną kodu asynchronicznego, który "nigdy się nie kończy". Dzieje się tak, gdy synchronicznie blokujesz (wywołaj Wait, Task<TResult>.Result, lub GetAwaiter.GetResult) na wątku, który ma jednowątkowy SynchronizationContext.

Sekwencja powodująca zakleszczenie:

  1. Kod w wątku interfejsu użytkownika (lub wątek żądania ASP.NET w starszych ASP.NET) wywołuje metodę asynchronizowaną i blokuje zwrócone zadanie.
  2. Metoda async oczekuje na niekompletne zadanie bez użycia ConfigureAwait(false).
  3. Po zakończeniu oczekiwanego zadania kontynuacja próbuje przywrócić do oryginalnego SynchronizationContext.
  4. Wątek tego kontekstu jest zablokowany w oczekiwaniu na ukończenie zadania — zakleszczenie.
public static class DeadlockExample
{
    public static async Task<string> GetDataAsync()
    {
        // Without ConfigureAwait(false), this continuation
        // posts back to the original SynchronizationContext.
        await Task.Delay(100);
        return "data";
    }

    public static void CallerThatDeadlocks()
    {
        // On a single-threaded SynchronizationContext (e.g. UI thread),
        // the following line deadlocks because the continuation needs
        // the same thread that .Result is blocking.
        string result = GetDataAsync().Result;
    }
}
Public Module DeadlockExample
    Public Async Function GetDataAsync() As Task(Of String)
        ' Without ConfigureAwait(False), this continuation
        ' posts back to the original SynchronizationContext.
        Await Task.Delay(100)
        Return "data"
    End Function

    Public Sub CallerThatDeadlocks()
        ' On a single-threaded SynchronizationContext (e.g. UI thread),
        ' the following line deadlocks because the continuation needs
        ' the same thread that .Result is blocking.
        Dim result As String = GetDataAsync().Result
    End Sub
End Module

Jak uniknąć zakleszczeń

Użyj co najmniej jednej z następujących strategii:

  • Nie blokuj. Użyj await zamiast .Result lub .Wait():

    public static class DeadlockFix1
    {
        public static async Task CallerFixedAsync()
        {
            // Use await instead of .Result
            string result = await DeadlockExample.GetDataAsync();
            Console.WriteLine(result);
        }
    }
    
    Public Module DeadlockFix1
        Public Async Function CallerFixedAsync() As Task
            ' Use Await instead of .Result
            Dim result As String = Await DeadlockExample.GetDataAsync()
            Console.WriteLine(result)
        End Function
    End Module
    
  • Użyj ConfigureAwait(false) w kodzie biblioteki. Jeśli metoda biblioteki nie musi być wznowiona w kontekście obiektu wywołującego, określ ConfigureAwait(false) dla każdego await.

    public static class DeadlockFix2
    {
        public static async Task<string> GetDataSafeAsync()
        {
            await Task.Delay(100).ConfigureAwait(false);
            return "data";
        }
    }
    
    Public Module DeadlockFix2
        Public Async Function GetDataSafeAsync() As Task(Of String)
            Await Task.Delay(100).ConfigureAwait(False)
            Return "data"
        End Function
    End Module
    

    Użycie ConfigureAwait(false) informuje środowisko uruchomieniowe, aby nie przywrócić kontynuacji do oryginalnego SynchronizationContext. Takie podejście chroni osoby wywołujące, które blokują, i zwiększa wydajność, unikając niepotrzebnych przeskoków wątków.

Ostrzeżenie

Zablokowania konstruktora statycznego. CLR przechowuje blokadę podczas uruchamiania konstruktorów statycznych (cctors). Jeśli konstruktor statyczny blokuje realizację zadania, a kontynuacja tego zadania musi uruchomić kod w tym samym typie (lub typ zaangażowany w proces konstruowania), kontynuacja nie może być kontynuowana, ponieważ blokada cctor jest utrzymywana. Unikaj całkowicie blokowania wywołań wewnątrz konstruktorów statycznych.

Rozpakowanie zadania<>

Gdy przekazujesz asynchroniczne lambda do metody takiej jak StartNew, zwracany obiekt to Task<Task> (lub Task<Task<TResult>>), a nie prosty Task. Zadanie zewnętrzne zostanie zakończone, gdy tylko asynchroniczne lambda osiągnie swój pierwszy wynik await. Nie czeka na zakończenie zadania wewnętrznego:

public static class TaskTaskBugExample
{
    public static async Task DemoAsync()
    {
        var sw = Stopwatch.StartNew();
        // StartNew returns Task<Task>, not Task.
        // The outer task completes immediately when the lambda yields.
        await Task.Factory.StartNew(async () =>
        {
            await Task.Delay(1000);
        });
        // Elapsed shows ~0 seconds, not ~1 second.
        Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
    }
}
Public Module TaskTaskBugExample
    Public Async Function DemoAsync() As Task
        Dim sw = Stopwatch.StartNew()
        ' StartNew returns Task(Of Task), not Task.
        ' The outer task completes immediately when the lambda yields.
        Await Task.Factory.StartNew(Async Function()
                                        Await Task.Delay(1000)
                                    End Function)
        ' Elapsed shows ~0 seconds, not ~1 second.
        Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
    End Function
End Module

Rozwiąż ten problem na jeden z trzech sposobów:

  • Użyj Run zamiast tego. Task.Runautomatycznie odpakowuje:Task<Task>

    public static class TaskTaskFix1
    {
        public static async Task DemoAsync()
        {
            var sw = Stopwatch.StartNew();
            await Task.Run(async () =>
            {
                await Task.Delay(1000);
            });
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
        }
    }
    
    Public Module TaskTaskFix1
        Public Async Function DemoAsync() As Task
            Dim sw = Stopwatch.StartNew()
            Await Task.Run(Async Function()
                               Await Task.Delay(1000)
                           End Function)
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
        End Function
    End Module
    
  • Wywołaj Unwrap dla wyniku:

    public static class TaskTaskFix2
    {
        public static async Task DemoAsync()
        {
            var sw = Stopwatch.StartNew();
            await Task.Factory.StartNew(async () =>
            {
                await Task.Delay(1000);
            }).Unwrap();
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
        }
    }
    
    Public Module TaskTaskFix2
        Public Async Function DemoAsync() As Task
            Dim sw = Stopwatch.StartNew()
            Await Task.Factory.StartNew(Async Function()
                                            Await Task.Delay(1000)
                                        End Function).Unwrap()
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
        End Function
    End Module
    
  • Poczekaj dwa razy (najpierw zadanie zewnętrzne, a następnie wewnętrzne):

    public static class TaskTaskFix3
    {
        public static async Task DemoAsync()
        {
            var sw = Stopwatch.StartNew();
            Task<Task> outerTask = Task.Factory.StartNew(async () =>
            {
                await Task.Delay(1000);
            });
            Task innerTask = await outerTask;
            await innerTask;
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s");
        }
    }
    
    Public Module TaskTaskFix3
        Public Async Function DemoAsync() As Task
            Dim sw = Stopwatch.StartNew()
            Dim outerTask As Task(Of Task) = Task.Factory.StartNew(Async Function()
                                                                       Await Task.Delay(1000)
                                                                   End Function)
            Dim innerTask As Task = Await outerTask
            Await innerTask
            Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds:F2}s")
        End Function
    End Module
    

Brak użycia 'await' przy wywołaniu funkcji zwracającej zadanie

Jeśli wywołasz metodę zwracającą zadanie w metodzie async bez oczekiwania na nią, metoda uruchamia operację asynchroniczną, ale nie czeka na jej zakończenie. Kompilator ostrzega Cię o tym przypadku z CS4014 w języku C# i BC42358 w Visual Basic:

public static class MissingAwaitExample
{
    // BAD: Task.Delay is started but never awaited.
    public static async Task PauseOneSecondBuggyAsync()
    {
        Task.Delay(1000); // CS4014 warning
    }

    // GOOD: await the task.
    public static async Task PauseOneSecondAsync()
    {
        await Task.Delay(1000);
    }
}
Public Module MissingAwaitExample
    ' BAD: Task.Delay is started but never awaited.
    Public Async Function PauseOneSecondBuggyAsync() As Task
        Task.Delay(1000) ' Warning BC42358
    End Function

    ' GOOD: Await the task.
    Public Async Function PauseOneSecondAsync() As Task
        Await Task.Delay(1000)
    End Function
End Module

Przechowywanie wyniku w zmiennej pomija ostrzeżenie, ale nie naprawia bazowej usterki. Zawsze await zadanie, chyba że celowo chcesz użyć zachowania "fire-and-forget" (zignoruj po rozpoczęciu).

Zobacz także