Udostępnij za pomocą


Korzystanie ze wzorca asynchronicznego opartego na zadaniach

Korzystając ze wzorca asynchronicznego opartego na zadaniach (TAP) do pracy z operacjami asynchronicznymi, można użyć wywołań zwrotnych, aby czekać bez blokowania. W przypadku zadań można to osiągnąć za pomocą metod takich jak Task.ContinueWith. Obsługa asynchroniczna oparta na języku ukrywa wywołania zwrotne, umożliwiając oczekiwanie na operacje asynchroniczne w normalnym przepływie sterowania, a kod generowany przez kompilator zapewnia tę samą obsługę na poziomie interfejsu API.

Wstrzymywanie wykonywania za pomocą funkcji Await

Możesz użyć słowa kluczowego await w języku C# i operatora Await w Visual Basic, aby asynchronicznie oczekiwać na obiekty Task i Task<TResult>. Gdy oczekujesz na element Task, wyrażenie await ma typ void. Gdy oczekujesz na element Task<TResult>, wyrażenie await ma typ TResult. Wyrażenie await musi występować wewnątrz treści metody asynchronicznej. (Te funkcje językowe zostały wprowadzone w programie .NET Framework 4.5).

Za kulisami, funkcja await instaluje funkcję zwrotną do zadania za pomocą mechanizmu kontynuacji. To wywołanie zwrotne wznawia metodę asynchroniczną w momencie zawieszenia. Po wznowieniu metody asynchronicznej, jeśli oczekiwana operacja została ukończona pomyślnie i jest Task<TResult>, to zwracany jest jej TResult. Jeśli Task lub Task<TResult> zakończył się w stanie Canceled, zgłaszany jest wyjątek OperationCanceledException. Jeśli oczekiwane zadanie Task lub Task<TResult> zostało zakończone w stanie Faulted, zgłaszany jest wyjątek, który spowodował wystąpienie błędu. Może wystąpić usterka w Task z powodu wielu wyjątków, ale propagowany jest tylko jeden z tych wyjątków. Jednak Task.Exception właściwość zwraca AggregateException wyjątek zawierający wszystkie błędy.

Jeśli kontekst synchronizacji (SynchronizationContext obiekt) jest skojarzony z wątkiem, który wykonywał metodę asynchroniczną w czasie zawieszenia (na przykład jeśli właściwość SynchronizationContext.Current nie jest null), metoda asynchroniczna zostanie podjęta w tym samym kontekście synchronizacji przy użyciu metody kontekstu Post. W przeciwnym razie opiera się na harmonogramie zadań (TaskScheduler obiekt), który był aktualny w momencie zawieszenia. Zazwyczaj jest to domyślny harmonogram zadań (TaskScheduler.Default), który jest przeznaczony dla puli wątków. Ten harmonogram zadań określa, czy operacja asynchroniczna powinna zostać wznowiona w miejscu, w którym została zakończona, czy też wznowienie powinno zostać zaplanowane. Domyślny harmonogram zwykle umożliwia kontynuowanie działania w wątku, który zakończył oczekiwaną operację.

Gdy wywoływana jest metoda asynchroniczna, jej treść jest wykonywana synchronicznie aż do pierwszego wyrażenia await w instancji oczekującej, która nie została jeszcze zakończona, po czym wywołanie wraca do obiektu wywołującego. Jeśli metoda asynchroniczna nie zwraca wartości void, zwracany jest obiekt Task lub Task<TResult>, który reprezentuje bieżące obliczenia. W metodzie asynchronicznej niepustej, jeśli napotkana zostanie instrukcja return lub osiągnięty zostanie koniec treści metody, zadanie jest zakończone w RanToCompletion stanie końcowym. Jeśli nieobsługiwany wyjątek powoduje, że kontrola opuszcza ciało metody asynchronicznej, zadanie kończy się w Faulted stanie. Jeśli ten wyjątek to OperationCanceledException, zadanie kończy się w stanie Canceled. W ten sposób wynik lub wyjątek zostanie ostatecznie opublikowany.

Istnieje kilka ważnych odmian tego zachowania. Ze względu na wydajność, jeśli zadanie zostało już ukończone w momencie oczekiwania, kontrola nie zostanie przekazana, a funkcja będzie nadal wykonywana. Ponadto powrót do oryginalnego kontekstu nie zawsze jest pożądanym zachowaniem i można go zmienić; opisano to bardziej szczegółowo w następnej sekcji.

Konfigurowanie zawieszenia i wznowienia przy użyciu Yield i ConfigureAwait

Kilka metod zapewnia większą kontrolę nad wykonywaniem metody asynchronicznej. Na przykład można użyć Task.Yield metody , aby wprowadzić punkt wydajności do metody asynchronicznej:

public class Task : …
{
    public static YieldAwaitable Yield();
    …
}

Jest to odpowiednik asynchronicznego umieszczania lub ponownego planowania w bieżącym kontekście.

Task.Run(async delegate
{
    for(int i=0; i<1000000; i++)
    {
        await Task.Yield(); // fork the continuation into a separate work item
        ...
    }
});

Można również skorzystać z metody Task.ConfigureAwait, aby lepiej kontrolować zawieszanie i wznawianie w metodzie asynchronicznej. Jak wspomniano wcześniej, domyślnie bieżący kontekst jest przechwytywany w czasie wstrzymania metody asynchronicznej, a przechwycony kontekst jest używany do wywoływania kontynuacji metody asynchronicznej po wznowieniu. W wielu przypadkach jest to dokładnie takie zachowanie, jakiego chcesz. W innych przypadkach możesz nie przejmować się kontekstem kontynuacji i można osiągnąć lepszą wydajność, unikając takich wpisów z powrotem do oryginalnego kontekstu. Aby to włączyć, użyj metody Task.ConfigureAwait, aby poinformować, że operacja await nie powinna przechwytywać i wznawiać w kontekście, lecz kontynuować wykonanie wszędzie tam, gdzie została zakończona operacja asynchroniczna, na którą oczekiwano.

await someTask.ConfigureAwait(continueOnCapturedContext:false);

Anulowanie operacji asynchronicznej

Począwszy od wersji .NET Framework 4, metody TAP, które obsługują anulowanie, zapewniają co najmniej jedno przeciążenie, które przyjmuje token anulowania (CancellationToken obiekt).

Token anulowania jest tworzony za pośrednictwem źródła tokenu anulowania (CancellationTokenSource obiektu). Właściwość źródła Token zwraca token anulowania, który zostanie zasygnalizowany po wywołaniu metody źródła Cancel . Jeśli na przykład chcesz pobrać jedną stronę internetową i chcesz mieć możliwość anulowania operacji, utworzysz CancellationTokenSource obiekt, przekaż jego token do metody TAP, a następnie wywołaj metodę źródła Cancel , gdy wszystko będzie gotowe do anulowania operacji:

var cts = new CancellationTokenSource();
string result = await DownloadStringTaskAsync(url, cts.Token);
… // at some point later, potentially on another thread
cts.Cancel();

Aby anulować wiele wywołań asynchronicznych, możesz przekazać ten sam token do wszystkich wywołań:

var cts = new CancellationTokenSource();
    IList<string> results = await Task.WhenAll(from url in urls select DownloadStringTaskAsync(url, cts.Token));
    // at some point later, potentially on another thread
    …
    cts.Cancel();

Możesz też przekazać ten sam token do selektywnego podzestawu operacji:

var cts = new CancellationTokenSource();
    byte [] data = await DownloadDataAsync(url, cts.Token);
    await SaveToDiskAsync(outputPath, data, CancellationToken.None);
    … // at some point later, potentially on another thread
    cts.Cancel();

Ważne

Żądania anulowania mogą być inicjowane z dowolnego wątku.

Wartość można przekazać CancellationToken.None do dowolnej metody, która akceptuje token anulowania, aby wskazać, że anulowanie nigdy nie zostanie zażądane. To powoduje, że właściwość CancellationToken.CanBeCanceled zwraca false, a wywołana metoda może zoptymalizować działanie zgodnie z tym. W celach testowych można również przekazać token anulowania, który jest wstępnie anulowany i utworzony za pomocą konstruktora przyjmującego wartość logiczną, wskazującą, czy token powinien być początkowo anulowany, czy w stanie, którego nie można anulować.

Takie podejście do anulowania ma kilka zalet:

  • Możesz przekazać ten sam token anulowania do dowolnej liczby operacji asynchronicznych i synchronicznych.

  • To samo żądanie anulowania może być dystrybuowane do dowolnej liczby odbiorników.

  • Deweloper asynchronicznego interfejsu API ma pełną kontrolę nad tym, czy może zostać zażądane anulowanie i kiedy może zostać zastosowane.

  • Kod korzystający z interfejsu API może selektywnie określić asynchroniczne wywołania, do których będą propagowane żądania anulowania.

Monitorowanie postępu

Niektóre metody asynchroniczne pokazują postęp za pośrednictwem interfejsu informowania o postępie przekazanego do metody asynchronicznej. Rozważmy na przykład funkcję, która asynchronicznie pobiera ciąg tekstu, a po drodze wyświetla aktualizacje postępu, które obejmują procent dotychczas pobranego materiału. Taką metodę można użyć w aplikacji Windows Presentation Foundation (WPF) w następujący sposób:

private async void btnDownload_Click(object sender, RoutedEventArgs e)
{
    btnDownload.IsEnabled = false;
    try
    {
        txtResult.Text = await DownloadStringTaskAsync(txtUrl.Text,
            new Progress<int>(p => pbDownloadProgress.Value = p));
    }
    finally { btnDownload.IsEnabled = true; }
}

Korzystanie z wbudowanych kombinatorów opartych na zadaniach

System.Threading.Tasks Przestrzeń nazw zawiera kilka metod komponowania i pracy z zadaniami.

Task.Run

Klasa Task zawiera kilka Run metod, które pozwalają łatwo oddelegować pracę jako Task lub Task<TResult> do puli wątków, na przykład:

public async void button1_Click(object sender, EventArgs e)
{
    textBox1.Text = await Task.Run(() =>
    {
        // … do compute-bound work here
        return answer;
    });
}

Niektóre z tych Run metod, takie jak przeciążenie Task.Run(Func<Task>), są skrótem dla metody TaskFactory.StartNew. To przeciążenie umożliwia użycie funkcji await w ramach odciążonej pracy, na przykład:

public async void button1_Click(object sender, EventArgs e)
{
    pictureBox1.Image = await Task.Run(async() =>
    {
        using(Bitmap bmp1 = await DownloadFirstImageAsync())
        using(Bitmap bmp2 = await DownloadSecondImageAsync())
        return Mashup(bmp1, bmp2);
    });
}

Takie przeciążenia są logicznie równoważne użyciu metody TaskFactory.StartNew w połączeniu z metodą rozszerzenia Unwrap w Bibliotece Zadań Równoległych.

Task.FromResult

Użyj metody FromResult w sytuacjach, gdy dane mogą już być dostępne i wystarczy je zwrócić z metody zwracającej zadanie, która została podniesiona do Task<TResult>.

public Task<int> GetValueAsync(string key)
{
    int cachedValue;
    return TryGetCachedValue(out cachedValue) ?
        Task.FromResult(cachedValue) :
        GetValueAsyncInternal();
}

private async Task<int> GetValueAsyncInternal(string key)
{
    …
}

Task.WhenAll

WhenAll Użyj metody , aby asynchronicznie czekać na wiele operacji asynchronicznych, które są reprezentowane jako zadania. Metoda ma wiele przeciążeń, które wspierają zestaw niegenerycznych zadań lub niejednolity zestaw zadań ogólnych (na przykład asynchronicznie oczekujących na wiele operacji zwracających void lub asynchronicznie oczekujących na wiele metod zwracających wartość, gdzie każda wartość może mieć inny typ) i wspierają jednolity zestaw zadań ogólnych (takich jak asynchroniczne oczekiwanie na wiele TResult metod zwracających).

Załóżmy, że chcesz wysyłać wiadomości e-mail do kilku klientów. Możesz wysyłać komunikaty równocześnie, aby nie czekać na zakończenie jednej wiadomości przed wysłaniem następnej. Możesz również dowiedzieć się, kiedy operacje wysyłania zostały ukończone i czy wystąpiły jakiekolwiek błędy:

IEnumerable<Task> asyncOps = from addr in addrs select SendMailAsync(addr);
await Task.WhenAll(asyncOps);

Ten kod nie obsługuje jawnie wyjątków, które mogą wystąpić, ale pozwala im propagować się z await na wynikowe zadanie z WhenAll. Aby obsłużyć wyjątki, możesz użyć kodu, takiego jak:

IEnumerable<Task> asyncOps = from addr in addrs select SendMailAsync(addr);
try
{
    await Task.WhenAll(asyncOps);
}
catch(Exception exc)
{
    ...
}

W takim przypadku, jeśli jakakolwiek operacja asynchroniczna zakończy się niepowodzeniem, wszystkie wyjątki zostaną skonsolidowane w jeden wyjątek, który jest przechowywany w AggregateException, zwracanym przez metodę Task. Jednak tylko jeden z tych wyjątków jest propagowany za pomocą słowa kluczowego await. Jeśli chcesz zbadać wszystkie wyjątki, możesz ponownie napisać poprzedni kod w następujący sposób:

Task [] asyncOps = (from addr in addrs select SendMailAsync(addr)).ToArray();
try
{
    await Task.WhenAll(asyncOps);
}
catch(Exception exc)
{
    foreach(Task faulted in asyncOps.Where(t => t.IsFaulted))
    {
        … // work with faulted and faulted.Exception
    }
}

Rozważmy przykład pobierania wielu plików z sieci Web asynchronicznie. W takim przypadku wszystkie operacje asynchroniczne mają homogeniczne typy wyników i łatwo jest uzyskać dostęp do wyników:

string [] pages = await Task.WhenAll(
    from url in urls select DownloadStringTaskAsync(url));

Możesz użyć tych samych technik obsługi wyjątków omówionych w poprzednim scenariuszu zwracania pustki:

Task<string> [] asyncOps =
    (from url in urls select DownloadStringTaskAsync(url)).ToArray();
try
{
    string [] pages = await Task.WhenAll(asyncOps);
    ...
}
catch(Exception exc)
{
    foreach(Task<string> faulted in asyncOps.Where(t => t.IsFaulted))
    {
        … // work with faulted and faulted.Exception
    }
}

Task.WhenAny

Możesz użyć WhenAny metody , aby asynchronicznie czekać tylko na jedną z wielu operacji asynchronicznych reprezentowanych jako zadania do wykonania. Ta metoda obsługuje cztery główne przypadki użycia:

  • Nadmiarowość: wykonanie operacji wiele razy i wybranie tej, która zostanie ukończona jako pierwsza (na przykład skontaktowanie się z wieloma usługami internetowymi ofert giełdowych, które spowodują wygenerowanie jednego wyniku i wybranie tej, która zakończy się najszybciej).

  • Przeplatanie: uruchamianie wielu operacji i oczekiwanie na ich zakończenie, ale przetwarzanie ich w miarę jak się kończą.

  • Ograniczanie przepustowości: zezwalanie na rozpoczęcie dodatkowych operacji w miarę ukończenia innych operacji. Jest to rozszerzenie scenariusza przeplatania.

  • Wczesne ratowanie: na przykład operacja reprezentowana przez zadanie t1 może być zgrupowana w WhenAny zadaniu z innym zadaniem t2 i można czekać na WhenAny zadanie. Zadanie t2 może reprezentować limit czasu lub anulowanie lub inny sygnał, który powoduje WhenAny zakończenie zadania przed ukończeniem t1.

Redundancja

Rozważ przypadek, w którym chcesz podjąć decyzję o tym, czy kupić akcje. Istnieje kilka zaufanych usług internetowych rekomendujących akcje, ale w zależności od codziennego obciążenia każda z usług może działać wolniej o różnych porach. Możesz użyć metody WhenAny, aby otrzymywać powiadomienie po zakończeniu dowolnej operacji.

var recommendations = new List<Task<bool>>()
{
    GetBuyRecommendation1Async(symbol),
    GetBuyRecommendation2Async(symbol),
    GetBuyRecommendation3Async(symbol)
};
Task<bool> recommendation = await Task.WhenAny(recommendations);
if (await recommendation) BuyStock(symbol);

W przeciwieństwie do WhenAll, która zwraca wyniki wszystkich zadań zakończonych pomyślnie, WhenAny zwraca zadanie, które zostało ukończone. Jeśli zadanie zakończy się niepowodzeniem, ważne jest, aby wiedzieć, że nie powiodło się, a jeśli zadanie zakończy się pomyślnie, ważne jest, aby wiedzieć, z którym zadaniem jest skojarzona wartość zwracana. W związku z tym musisz uzyskać dostęp do wyniku zwróconego zadania lub poczekać dalej, jak pokazano w tym przykładzie.

Podobnie jak w przypadku WhenAll, musisz mieć możliwość obsługi wyjątków. Ponieważ otrzymujesz ukończone zadanie z powrotem, możesz poczekać na wystąpienie błędów w zwróconym zadaniu i odpowiednio je poprawić; na przykład:

Task<bool> [] recommendations = …;
while(recommendations.Count > 0)
{
    Task<bool> recommendation = await Task.WhenAny(recommendations);
    try
    {
        if (await recommendation) BuyStock(symbol);
        break;
    }
    catch(WebException exc)
    {
        recommendations.Remove(recommendation);
    }
}

Ponadto nawet jeśli pierwsze zadanie zakończy się pomyślnie, kolejne zadania mogą zakończyć się niepowodzeniem. W tym momencie masz kilka opcji obsługi wyjątków: możesz poczekać, aż wszystkie uruchomione zadania zostały ukończone, w takim przypadku można użyć WhenAll metody lub zdecydować, że wszystkie wyjątki są ważne i muszą być rejestrowane. W tym celu można użyć kontynuacji, aby otrzymywać powiadomienie, gdy zadania zostały wykonane asynchronicznie:

foreach(Task recommendation in recommendations)
{
    var ignored = recommendation.ContinueWith(
        t => { if (t.IsFaulted) Log(t.Exception); });
}

lub:

foreach(Task recommendation in recommendations)
{
    var ignored = recommendation.ContinueWith(
        t => Log(t.Exception), TaskContinuationOptions.OnlyOnFaulted);
}

a nawet:

private static async void LogCompletionIfFailed(IEnumerable<Task> tasks)
{
    foreach(var task in tasks)
    {
        try { await task; }
        catch(Exception exc) { Log(exc); }
    }
}
…
LogCompletionIfFailed(recommendations);

Na koniec możesz anulować wszystkie pozostałe operacje:

var cts = new CancellationTokenSource();
var recommendations = new List<Task<bool>>()
{
    GetBuyRecommendation1Async(symbol, cts.Token),
    GetBuyRecommendation2Async(symbol, cts.Token),
    GetBuyRecommendation3Async(symbol, cts.Token)
};

Task<bool> recommendation = await Task.WhenAny(recommendations);
cts.Cancel();
if (await recommendation) BuyStock(symbol);

Przeplot

Rozważ przypadek, w którym pobierasz obrazy z Internetu i przetwarzasz każdy obraz (na przykład dodanie obrazu do kontrolki interfejsu użytkownika). Przetwarzasz obrazy sekwencyjnie w wątku interfejsu użytkownika, ale chcesz je pobierać tak równolegle, jak to możliwe. Ponadto nie chcesz przechowywać dodawania obrazów do interfejsu użytkownika, dopóki nie zostaną pobrane. Zamiast tego chcesz dodawać je, gdy zostaną ukończone.

List<Task<Bitmap>> imageTasks =
    (from imageUrl in urls select GetBitmapAsync(imageUrl)).ToList();
while(imageTasks.Count > 0)
{
    try
    {
        Task<Bitmap> imageTask = await Task.WhenAny(imageTasks);
        imageTasks.Remove(imageTask);

        Bitmap image = await imageTask;
        panel.AddImage(image);
    }
    catch{}
}

Można również zastosować przeplatanie do scenariusza obejmującego wymagające intensywnych obliczeń przetwarzanie na ThreadPool pobranych obrazach, na przykład:

List<Task<Bitmap>> imageTasks =
    (from imageUrl in urls select GetBitmapAsync(imageUrl)
         .ContinueWith(t => ConvertImage(t.Result)).ToList();
while(imageTasks.Count > 0)
{
    try
    {
        Task<Bitmap> imageTask = await Task.WhenAny(imageTasks);
        imageTasks.Remove(imageTask);

        Bitmap image = await imageTask;
        panel.AddImage(image);
    }
    catch{}
}

Ograniczanie prędkości

Rozważmy przykład przeplatania, jednak tym razem użytkownik pobiera tak wiele obrazów, że konieczne jest ograniczenie pobierania; na przykład chcesz umożliwić tylko określoną liczbę pobrań równocześnie. Aby to osiągnąć, możesz uruchomić podzbiór operacji asynchronicznych. Po zakończeniu operacji możesz uruchomić dodatkowe operacje, które je zastąpią.

const int CONCURRENCY_LEVEL = 15;
Uri [] urls = …;
int nextIndex = 0;
var imageTasks = new List<Task<Bitmap>>();
while(nextIndex < CONCURRENCY_LEVEL && nextIndex < urls.Length)
{
    imageTasks.Add(GetBitmapAsync(urls[nextIndex]));
    nextIndex++;
}

while(imageTasks.Count > 0)
{
    try
    {
        Task<Bitmap> imageTask = await Task.WhenAny(imageTasks);
        imageTasks.Remove(imageTask);

        Bitmap image = await imageTask;
        panel.AddImage(image);
    }
    catch(Exception exc) { Log(exc); }

    if (nextIndex < urls.Length)
    {
        imageTasks.Add(GetBitmapAsync(urls[nextIndex]));
        nextIndex++;
    }
}

Wczesna pomoc finansowa

Należy wziąć pod uwagę, że oczekujesz asynchronicznie na ukończenie operacji, jednocześnie odpowiadając na żądanie anulowania użytkownika (na przykład użytkownik kliknął przycisk anuluj). Poniższy kod ilustruje ten scenariusz:

private CancellationTokenSource m_cts;

public void btnCancel_Click(object sender, EventArgs e)
{
    if (m_cts != null) m_cts.Cancel();
}

public async void btnRun_Click(object sender, EventArgs e)
{
    m_cts = new CancellationTokenSource();
    btnRun.Enabled = false;
    try
    {
        Task<Bitmap> imageDownload = GetBitmapAsync(txtUrl.Text);
        await UntilCompletionOrCancellation(imageDownload, m_cts.Token);
        if (imageDownload.IsCompleted)
        {
            Bitmap image = await imageDownload;
            panel.AddImage(image);
        }
        else imageDownload.ContinueWith(t => Log(t));
    }
    finally { btnRun.Enabled = true; }
}

private static async Task UntilCompletionOrCancellation(
    Task asyncOp, CancellationToken ct)
{
    var tcs = new TaskCompletionSource<bool>();
    using(ct.Register(() => tcs.TrySetResult(true)))
        await Task.WhenAny(asyncOp, tcs.Task);
    return asyncOp;
}

Ta implementacja ponownie włącza interfejs użytkownika natychmiast po podjęciu decyzji o wycofaniu się, ale nie anuluje działających w tle operacji asynchronicznych. Inną alternatywą byłoby anulowanie oczekujących operacji po podjęciu decyzji o rezygnowaniu, ale bez ponownego przywrócenia interfejsu użytkownika do momentu zakończenia operacji, co potencjalnie może nastąpić wcześniej z powodu żądania anulowania.

private CancellationTokenSource m_cts;

public async void btnRun_Click(object sender, EventArgs e)
{
    m_cts = new CancellationTokenSource();

    btnRun.Enabled = false;
    try
    {
        Task<Bitmap> imageDownload = GetBitmapAsync(txtUrl.Text, m_cts.Token);
        await UntilCompletionOrCancellation(imageDownload, m_cts.Token);
        Bitmap image = await imageDownload;
        panel.AddImage(image);
    }
    catch(OperationCanceledException) {}
    finally { btnRun.Enabled = true; }
}

Innym przykładem wczesnego ratowania jest użycie WhenAny metody w połączeniu z metodą, zgodnie z Delay opisem w następnej sekcji.

Task.Delay

Możesz użyć metody Task.Delay, aby wprowadzić pauzy w wykonaniu metody asynchronicznej. Jest to przydatne w przypadku wielu rodzajów funkcji, w tym tworzenia pętli sondowania i opóźniania obsługi danych wejściowych użytkownika przez wstępnie określony okres czasu. Metoda Task.Delay może być również przydatna w połączeniu z Task.WhenAny do implementacji limitów czasu na oczekiwania.

Jeśli zadanie będące częścią większej operacji asynchronicznej (na przykład usługa internetowa ASP.NET) trwa zbyt długo, ogólna operacja może ulec awarii, zwłaszcza jeśli kiedykolwiek zakończy się niepowodzeniem. Z tego powodu ważne jest, aby mieć możliwość ustawienia limitu czasu podczas oczekiwania na operację asynchroniczną. Metody synchroniczne Task.Wait, Task.WaitAll oraz Task.WaitAny akceptują wartości limitu czasu, ale odpowiednie TaskFactory.ContinueWhenAll/TaskFactory.ContinueWhenAny i wcześniej wymienione Task.WhenAll/Task.WhenAny metody nie. Zamiast tego możesz użyć funkcji Task.Delay i Task.WhenAny w połączeniu, aby zaimplementować limit czasu.

Na przykład w aplikacji interfejsu użytkownika załóżmy, że chcesz pobrać obraz i wyłączyć interfejs użytkownika podczas pobierania obrazu. Jeśli jednak pobieranie trwa zbyt długo, chcesz ponownie włączyć interfejs użytkownika i odrzucić pobieranie:

public async void btnDownload_Click(object sender, EventArgs e)
{
    btnDownload.Enabled = false;
    try
    {
        Task<Bitmap> download = GetBitmapAsync(url);
        if (download == await Task.WhenAny(download, Task.Delay(3000)))
        {
            Bitmap bmp = await download;
            pictureBox.Image = bmp;
            status.Text = "Downloaded";
        }
        else
        {
            pictureBox.Image = null;
            status.Text = "Timed out";
            var ignored = download.ContinueWith(
                t => Trace("Task finally completed"));
        }
    }
    finally { btnDownload.Enabled = true; }
}

To samo dotyczy wielu pobrań, ponieważ WhenAll zwraca zadanie:

public async void btnDownload_Click(object sender, RoutedEventArgs e)
{
    btnDownload.Enabled = false;
    try
    {
        Task<Bitmap[]> downloads =
            Task.WhenAll(from url in urls select GetBitmapAsync(url));
        if (downloads == await Task.WhenAny(downloads, Task.Delay(3000)))
        {
            foreach(var bmp in downloads.Result) panel.AddImage(bmp);
            status.Text = "Downloaded";
        }
        else
        {
            status.Text = "Timed out";
            downloads.ContinueWith(t => Log(t));
        }
    }
    finally { btnDownload.Enabled = true; }
}

Kompilowanie kombinatorów opartych na zadaniach

Ponieważ zadanie jest w stanie całkowicie reprezentować operację asynchroniczną i zapewnić synchroniczne i asynchroniczne możliwości łączenia z operacją, pobierania jej wyników itd., można tworzyć przydatne biblioteki kombinatorów tworzących zadania do tworzenia większych wzorców. Jak wspomniano w poprzedniej sekcji, platforma .NET zawiera kilka wbudowanych kombinatorów, ale można również tworzyć własne. W poniższych sekcjach przedstawiono kilka przykładów potencjalnych metod i typów kombinatora.

RetryOnFault

W wielu sytuacjach można ponowić próbę wykonania operacji, jeśli poprzednia próba zakończy się niepowodzeniem. W przypadku kodu synchronicznego można utworzyć metodę pomocnika, taką jak RetryOnFault w poniższym przykładzie, aby to osiągnąć:

public static T RetryOnFault<T>(
    Func<T> function, int maxTries)
{
    for(int i=0; i<maxTries; i++)
    {
        try { return function(); }
        catch { if (i == maxTries-1) throw; }
    }
    return default(T);
}

Możesz utworzyć niemal identyczną metodę pomocniczą dla operacji asynchronicznych, które są implementowane za pomocą wzorca TAP i dzięki temu zwracają zadania.

public static async Task<T> RetryOnFault<T>(
    Func<Task<T>> function, int maxTries)
{
    for(int i=0; i<maxTries; i++)
    {
        try { return await function().ConfigureAwait(false); }
        catch { if (i == maxTries-1) throw; }
    }
    return default(T);
}

Następnie można użyć tego kombinatora, aby zakodować ponawianie prób w logice aplikacji; na przykład:

// Download the URL, trying up to three times in case of failure
string pageContents = await RetryOnFault(
    () => DownloadStringTaskAsync(url), 3);

Możesz jeszcze bardziej rozszerzyć RetryOnFault funkcję. Na przykład funkcja może zaakceptować kolejną Func<Task>, która zostanie wywołana pomiędzy ponownymi próbami, aby określić, kiedy ponowić próbę wykonania operacji; na przykład:

public static async Task<T> RetryOnFault<T>(
    Func<Task<T>> function, int maxTries, Func<Task> retryWhen)
{
    for(int i=0; i<maxTries; i++)
    {
        try { return await function().ConfigureAwait(false); }
        catch { if (i == maxTries-1) throw; }
        await retryWhen().ConfigureAwait(false);
    }
    return default(T);
}

Następnie możesz użyć funkcji w następujący sposób, aby poczekać na sekundę przed ponowieniu próby wykonania operacji:

// Download the URL, trying up to three times in case of failure,
// and delaying for a second between retries
string pageContents = await RetryOnFault(
    () => DownloadStringTaskAsync(url), 3, () => Task.Delay(1000));

NeedOnlyOne

Czasami można skorzystać z nadmiarowości, aby poprawić opóźnienie operacji i szanse na powodzenie. Rozważ wiele usług internetowych, które dostarczają oferty giełdowe, ale o różnych porach dnia każda usługa może zapewnić różne poziomy czasu jakości i odpowiedzi. Aby radzić sobie z tymi fluktuacjami, możesz wysyłać żądania do wszystkich usług internetowych, a gdy tylko otrzymasz odpowiedź od jednego, anuluj pozostałe żądania. Możesz zaimplementować funkcję pomocnika, aby ułatwić implementację tego wspólnego wzorca uruchamiania wielu operacji, oczekiwania na dowolne operacje, a następnie anulowania reszty. Funkcja NeedOnlyOne w poniższym przykładzie ilustruje ten scenariusz:

public static async Task<T> NeedOnlyOne(
    params Func<CancellationToken,Task<T>> [] functions)
{
    var cts = new CancellationTokenSource();
    var tasks = (from function in functions
                 select function(cts.Token)).ToArray();
    var completed = await Task.WhenAny(tasks).ConfigureAwait(false);
    cts.Cancel();
    foreach(var task in tasks)
    {
        var ignored = task.ContinueWith(
            t => Log(t), TaskContinuationOptions.OnlyOnFaulted);
    }
    return completed;
}

Następnie możesz użyć tej funkcji w następujący sposób:

double currentPrice = await NeedOnlyOne(
    ct => GetCurrentPriceFromServer1Async("msft", ct),
    ct => GetCurrentPriceFromServer2Async("msft", ct),
    ct => GetCurrentPriceFromServer3Async("msft", ct));

Operacje przeplatane

Podczas pracy z dużymi zestawami zadań istnieje potencjalny problem z wydajnością przy użyciu metody WhenAny do obsługi scenariusza z przeplataniem. Każde wywołanie WhenAny powoduje zarejestrowanie kontynuacji przy użyciu każdego zadania. W przypadku N liczby zadań powoduje to kontynuacje O(N2) utworzone w okresie istnienia operacji przeplatania. Jeśli pracujesz z dużym zestawem zadań, możesz użyć kombinatora (Interleaved w poniższym przykładzie), aby rozwiązać problem z wydajnością:

static IEnumerable<Task<T>> Interleaved<T>(IEnumerable<Task<T>> tasks)
{
    var inputTasks = tasks.ToList();
    var sources = (from _ in Enumerable.Range(0, inputTasks.Count)
                   select new TaskCompletionSource<T>()).ToList();
    int nextTaskIndex = -1;
    foreach (var inputTask in inputTasks)
    {
        inputTask.ContinueWith(completed =>
        {
            var source = sources[Interlocked.Increment(ref nextTaskIndex)];
            if (completed.IsFaulted)
                source.TrySetException(completed.Exception.InnerExceptions);
            else if (completed.IsCanceled)
                source.TrySetCanceled();
            else
                source.TrySetResult(completed.Result);
        }, CancellationToken.None,
           TaskContinuationOptions.ExecuteSynchronously,
           TaskScheduler.Default);
    }
    return from source in sources
           select source.Task;
}

Następnie można użyć kombinatora, aby przetworzyć wyniki zadań podczas ich wykonywania; na przykład:

IEnumerable<Task<int>> tasks = ...;
foreach(var task in Interleaved(tasks))
{
    int result = await task;
    …
}

WhenAllOrFirstException

W niektórych scenariuszach rozdzielania/zbiegania możesz poczekać na wszystkie zadania w zestawie, chyba że w jednym z nich wystąpi błąd, w takim przypadku chcesz przestać czekać natychmiast po wystąpieniu wyjątku. Można to osiągnąć za pomocą metody kombinatora, takiej jak WhenAllOrFirstException w poniższym przykładzie:

public static Task<T[]> WhenAllOrFirstException<T>(IEnumerable<Task<T>> tasks)
{
    var inputs = tasks.ToList();
    var ce = new CountdownEvent(inputs.Count);
    var tcs = new TaskCompletionSource<T[]>();

    Action<Task> onCompleted = (Task completed) =>
    {
        if (completed.IsFaulted)
            tcs.TrySetException(completed.Exception.InnerExceptions);
        if (ce.Signal() && !tcs.Task.IsCompleted)
            tcs.TrySetResult(inputs.Select(t => t.Result).ToArray());
    };

    foreach (var t in inputs) t.ContinueWith(onCompleted);
    return tcs.Task;
}

Tworzenie struktur danych opartych na zadaniach

Oprócz możliwości tworzenia niestandardowych kombinatorów opartych na zadaniach, posiadanie struktury danych w Task i Task<TResult>, która reprezentuje zarówno wyniki operacji asynchronicznej, jak i niezbędną synchronizację do jego połączenia, sprawia, że jest to zaawansowany typ, na którym można budować niestandardowe struktury danych do użycia w scenariuszach asynchronicznych.

AsyncCache

Jednym z ważnych aspektów zadania jest to, że może być przydzielone wielu odbiorcom, którzy wszyscy mogą czekać na nie, zarejestrować jego kontynuacje, uzyskać jego wynik lub wyjątki (w przypadku Task<TResult>), itd. To sprawia, że Task i Task<TResult> doskonale nadają się do użycia w infrastrukturze asynchronicznego buforowania. Oto przykład małej, ale wydajnej asynchronicznej pamięci podręcznej opartej na Task<TResult>.

public class AsyncCache<TKey, TValue>
{
    private readonly Func<TKey, Task<TValue>> _valueFactory;
    private readonly ConcurrentDictionary<TKey, Lazy<Task<TValue>>> _map;

    public AsyncCache(Func<TKey, Task<TValue>> valueFactory)
    {
        if (valueFactory == null) throw new ArgumentNullException("valueFactory");
        _valueFactory = valueFactory;
        _map = new ConcurrentDictionary<TKey, Lazy<Task<TValue>>>();
    }

    public Task<TValue> this[TKey key]
    {
        get
        {
            if (key == null) throw new ArgumentNullException("key");
            return _map.GetOrAdd(key, toAdd =>
                new Lazy<Task<TValue>>(() => _valueFactory(toAdd))).Value;
        }
    }
}

Klasa AsyncCache<TKey,TValue> przyjmuje jako delegatę w konstruktorze funkcję, która przyjmuje TKey i zwraca Task<TResult>. Wszystkie wcześniej dostępne wartości z pamięci podręcznej są przechowywane w słowniku wewnętrznym i AsyncCache zapewnia, że tylko jedno zadanie jest generowane dla każdego klucza, nawet jeśli pamięć podręczna jest uzyskiwana współbieżnie.

Na przykład można utworzyć pamięć podręczną dla pobranych stron internetowych:

private AsyncCache<string,string> m_webPages =
    new AsyncCache<string,string>(DownloadStringTaskAsync);

Następnie możesz użyć tej pamięci podręcznej w metodach asynchronicznych, gdy potrzebujesz zawartości strony internetowej. Klasa AsyncCache zapewnia, że pobierasz jak najmniej stron i buforuje wyniki.

private async void btnDownload_Click(object sender, RoutedEventArgs e)
{
    btnDownload.IsEnabled = false;
    try
    {
        txtContents.Text = await m_webPages["https://www.microsoft.com"];
    }
    finally { btnDownload.IsEnabled = true; }
}

AsyncProducerConsumerCollection

Za pomocą zadań można również tworzyć struktury danych do koordynowania działań asynchronicznych. Rozważmy jeden z klasycznych wzorców projektowania równoległego: producent/konsument. W tym wzorcu producenci generują dane używane przez konsumentów, a producenci i konsumenci mogą działać równolegle. Na przykład konsument przetwarza element 1, który został wcześniej wygenerowany przez producenta, który produkuje teraz element 2. W przypadku wzorca producenta/konsumenta niezmiennie potrzebna jest struktura danych do przechowywania pracy utworzonej przez producentów, dzięki czemu konsumenci mogą otrzymywać powiadomienia o nowych danych i znajdować je, gdy są dostępne.

Oto prosta struktura danych oparta na zadaniach, która umożliwia użycie metod asynchronicznych jako producentów i konsumentów:

public class AsyncProducerConsumerCollection<T>
{
    private readonly Queue<T> m_collection = new Queue<T>();
    private readonly Queue<TaskCompletionSource<T>> m_waiting =
        new Queue<TaskCompletionSource<T>>();

    public void Add(T item)
    {
        TaskCompletionSource<T> tcs = null;
        lock (m_collection)
        {
            if (m_waiting.Count > 0) tcs = m_waiting.Dequeue();
            else m_collection.Enqueue(item);
        }
        if (tcs != null) tcs.TrySetResult(item);
    }

    public Task<T> Take()
    {
        lock (m_collection)
        {
            if (m_collection.Count > 0)
            {
                return Task.FromResult(m_collection.Dequeue());
            }
            else
            {
                var tcs = new TaskCompletionSource<T>();
                m_waiting.Enqueue(tcs);
                return tcs.Task;
            }
        }
    }
}

Po wykonaniu tej struktury danych można napisać kod, taki jak:

private static AsyncProducerConsumerCollection<int> m_data = …;
…
private static async Task ConsumerAsync()
{
    while(true)
    {
        int nextItem = await m_data.Take();
        ProcessNextItem(nextItem);
    }
}
…
private static void Produce(int data)
{
    m_data.Add(data);
}

System.Threading.Tasks.Dataflow Przestrzeń nazw zawiera BufferBlock<T> typ, którego można użyć w podobny sposób, ale bez konieczności tworzenia niestandardowego typu kolekcji:

private static BufferBlock<int> m_data = …;
…
private static async Task ConsumerAsync()
{
    while(true)
    {
        int nextItem = await m_data.ReceiveAsync();
        ProcessNextItem(nextItem);
    }
}
…
private static void Produce(int data)
{
    m_data.Post(data);
}

Uwaga / Notatka

System.Threading.Tasks.Dataflow Przestrzeń nazw jest dostępna jako pakiet NuGet. Aby zainstalować zestaw zawierający System.Threading.Tasks.Dataflow przestrzeń nazw, otwórz projekt w programie Visual Studio, wybierz pozycję Zarządzaj pakietami NuGet z menu Project i wyszukaj pakiet w trybie online System.Threading.Tasks.Dataflow .

Zobacz także