Notatka
Dostęp do tej strony wymaga autoryzacji. Może spróbować zalogować się lub zmienić katalogi.
Dostęp do tej strony wymaga autoryzacji. Możesz spróbować zmienić katalogi.
Jeśli masz metodę synchroniczną w bibliotece, możesz być kuszeni udostępnieniem asynchronicznego odpowiednika, który ją opakowuje w Task.Run.
public T Foo() { /* synchronous work */ }
// Don't do this in a library:
public Task<T> FooAsync()
{
return Task.Run(() => Foo());
}
W tym artykule wyjaśniono, dlaczego takie podejście jest prawie zawsze błędne dla bibliotek i jak myśleć o kompromisach.
Skalowalność a odciążanie
Programowanie asynchroniczne zapewnia dwie odrębne korzyści:
- Skalowalność — zmniejsz zużycie zasobów, zwalniając wątki podczas oczekiwania na operacje wejścia/wyjścia.
- Odciążanie — Przenieś pracę do innego wątku, aby utrzymać responsywność (na przykład utrzymywanie wolnego wątku interfejsu użytkownika) lub osiągnąć równoległość.
Te korzyści wymagają różnych podejść. Kluczowa różnica: zawijanie metody synchronicznej w Task.Run pomaga w odciążeniu, ale nie poprawia skalowalności.
Dlaczego Task.Run nie poprawia skalowalności
Prawdziwie asynchroniczna implementacja zmniejsza liczbę wątków używanych podczas długotrwałej operacji.
Task.Run Opakowanie nadal blokuje wątek — po prostu przenosi blokowanie z jednego wątku na inny:
public static class TimerExampleWrong
{
public static Task SleepAsync(int millisecondsTimeout)
{
return Task.Run(() => Thread.Sleep(millisecondsTimeout));
}
}
Public Module TimerExampleWrong
Public Function SleepAsync(millisecondsTimeout As Integer) As Task
Return Task.Run(Sub() Thread.Sleep(millisecondsTimeout))
End Function
End Module
Porównaj to podejście z naprawdę asynchroniczną implementacją, która nie używa wątków podczas oczekiwania:
public static class TimerExampleRight
{
public static Task SleepAsync(int millisecondsTimeout)
{
var tcs = new TaskCompletionSource<bool>();
var timer = new Timer(
_ => tcs.TrySetResult(true), null, millisecondsTimeout, Timeout.Infinite);
tcs.Task.ContinueWith(
_ => timer.Dispose(), TaskScheduler.Default);
return tcs.Task;
}
}
Public Module TimerExampleRight
Public Function SleepAsync(millisecondsTimeout As Integer) As Task
Dim tcs As New TaskCompletionSource(Of Boolean)()
Dim tmr As New Timer(
Sub(state) tcs.TrySetResult(True), Nothing, millisecondsTimeout, Timeout.Infinite)
tcs.Task.ContinueWith(
Sub(t) tmr.Dispose(), TaskScheduler.Default)
Return tcs.Task
End Function
End Module
Obie implementacje zakończą się po określonym opóźnieniu, ale druga implementacja nie blokuje żadnego wątku podczas oczekiwania. W przypadku aplikacji serwerowych obsługujących wiele współbieżnych żądań różnica ma bezpośredni wpływ na liczbę żądań, które serwer może przetwarzać jednocześnie.
Odciążanie to odpowiedzialność konsumenta
Zawijanie synchronicznych wywołań w programie Task.Run jest przydatne do odciążania pracy z wątku interfejsu użytkownika. Jednak użytkownik, a nie biblioteka, powinien obsługiwać to zawijanie:
public static class UIOffloadExample
{
public static int ComputeIntensive(int input)
{
int result = 0;
for (int i = 0; i < input; i++)
{
result += i;
}
return result;
}
public static async Task ConsumeFromUIThreadAsync()
{
int result = await Task.Run(() => ComputeIntensive(10_000));
Console.WriteLine($"Result: {result}");
}
}
Public Module UIOffloadExample
Public Function ComputeIntensive(input As Integer) As Integer
Dim result As Integer = 0
For i As Integer = 0 To input - 1
result += i
Next
Return result
End Function
Public Async Function ConsumeFromUIThreadAsync() As Task
Dim result As Integer = Await Task.Run(Function() ComputeIntensive(10_000))
Console.WriteLine($"Result: {result}")
End Function
End Module
Użytkownik zna swój kontekst: czy znajduje się w wątku interfejsu użytkownika, ile szczegółowości potrzebuje, i czy odciążanie zwiększa wartość. Biblioteka nie.
Dlaczego biblioteki nie powinny uwidaczniać otoek asynchronicznych za pośrednictwem synchronizacji
Gdy biblioteka udostępnia tylko metodę synchroniczną (a nie asynchroniczne opakowanie), konsumenci korzystają na kilka sposobów:
- Zmniejszony obszar powierzchni interfejsu API: mniej metod uczenia się, testowania i konserwacji.
- Brak mylących oczekiwań dotyczących skalowalności: użytkownicy wiedzą, że tylko metody uwidocznione jako asynchroniczne zapewniają korzyści ze skalowalności.
-
Kontrola konsumentów: dzwoniący wybierają, czy i jak odciążać, na odpowiednim poziomie szczegółowości. Aplikacja serwera o wysokiej przepływności może bezpośrednio wywołać metodę synchroniczną, unikając niepotrzebnych obciążeń z usługi
Task.Run. - Lepsza wydajność: Asynchroniczne opakowania dodają obciążenie za pośrednictwem alokacji, przełączenia kontekstów i harmonogramowania puli wątków. W przypadku precyzyjnych operacji obciążenie to może być znaczące.
Wyjątki od reguły
Niektóre klasy bazowe uwidaczniają metody asynchroniczne, dzięki czemu klasy pochodne mogą zastąpić je prawdziwie asynchronicznymi implementacjami. Podstawowa klasa udostępnia domyślną metodę async-over-sync.
Na przykład eksponuje StreamReadAsync i WriteAsync. Podstawowe implementacje obejmują metody synchroniczne Read i Write. Klasy pochodne, takie jak FileStream i NetworkStream przesłaniają te metody za pomocą asynchronicznych implementacji I/O, które zapewniają rzeczywiste korzyści ze skalowalności.
Podobnie, TextReader zapewnia ReadToEndAsync na klasie bazowej jako otokę, a StreamReader nadpisuje ten element, wdrażając prawdziwie asynchroniczną implementację, która wewnętrznie wywołuje ReadAsync.
Te wyjątki są prawidłowe, ponieważ:
- Wzorzec jest zaprojektowany z myślą o polimorfizmie. Osoby wywołujące wchodzą w interakcję z typem podstawowym.
- Typy pochodne zapewniają prawdziwie asynchroniczne przesłonięcia.
Wytyczna
Uwidaczniaj metody asynchroniczne z biblioteki tylko wtedy, gdy implementacja zapewnia rzeczywiste korzyści ze skalowalności w odniesieniu do jego synchronicznego odpowiednika. Nie ujawniaj metod asynchronicznych wyłącznie do odciążania. Pozostaw ten wybór konsumentowi.