Programowanie asynchroniczne w języku F#

Programowanie asynchroniczne to mechanizm, który jest niezbędny dla nowoczesnych aplikacji z różnych powodów. Istnieją dwa podstawowe przypadki użycia, które napotyka większość deweloperów:

  • Prezentowanie procesu serwera, który może obsługiwać znaczną liczbę współbieżnych żądań przychodzących, jednocześnie minimalizując zasoby systemowe zajmowane podczas przetwarzania żądań oczekuje na dane wejściowe z systemów lub usług zewnętrznych do tego procesu
  • Utrzymywanie dynamicznego interfejsu użytkownika lub głównego wątku podczas równoczesnego postępu pracy w tle

Mimo że praca w tle często wiąże się z wykorzystaniem wielu wątków, ważne jest, aby wziąć pod uwagę pojęcia asynchroniczne i wielowątkowy oddzielnie. W rzeczywistości są one oddzielnymi obawami, a jeden nie oznacza drugiego. W tym artykule opisano bardziej szczegółowo oddzielne pojęcia.

Zdefiniowano asynchronię

Poprzedni punkt - że asynchronia jest niezależna od wykorzystania wielu wątków - warto wyjaśnić nieco dalej. Istnieją trzy koncepcje, które czasami są powiązane, ale ściśle niezależne od siebie:

  • Współbieżności; gdy wiele obliczeń jest wykonywanych w nakładających się okresach czasu.
  • Równoległości prostych; gdy wiele obliczeń lub kilka części pojedynczego obliczenia jest uruchamianych dokładnie w tym samym czasie.
  • Asynchrony; gdy co najmniej jedno obliczenie może być wykonywane oddzielnie od głównego przepływu programu.

Wszystkie trzy są pojęciami ortogonalnymi, ale można je łatwo zawyżać, zwłaszcza gdy są używane razem. Na przykład może być konieczne równoległe wykonywanie wielu obliczeń asynchronicznych. Ta relacja nie oznacza, że równoległość ani asynchronia implikują siebie nawzajem.

Jeśli rozważysz etymologię słowa "asynchroniczne", istnieją dwa elementy:

  • "a", czyli "nie".
  • "synchroniczny", czyli "w tym samym czasie".

Po połączeniu tych dwóch terminów zobaczysz, że "asynchroniczne" oznacza "nie w tym samym czasie". I już! W tej definicji nie ma wpływu na współbieżność ani równoległość. Jest to również prawdą w praktyce.

W praktyce obliczenia asynchroniczne w języku F# mają być wykonywane niezależnie od głównego przepływu programu. To niezależne wykonanie nie oznacza współbieżności ani równoległości ani nie oznacza, że obliczenia zawsze odbywają się w tle. W rzeczywistości obliczenia asynchroniczne mogą nawet wykonywać synchronicznie, w zależności od charakteru obliczeń i środowiska wykonywanego przez obliczenia.

Głównym wnioskiem jest to, że obliczenia asynchroniczne są niezależne od głównego przepływu programu. Chociaż istnieje kilka gwarancji dotyczących czasu lub sposobu wykonywania obliczeń asynchronicznych, istnieją pewne podejścia do organizowania i planowania ich. W pozostałej części tego artykułu omówiono podstawowe pojęcia dotyczące asynchronii języka F# oraz sposób używania typów, funkcji i wyrażeń wbudowanych w język F#.

Podstawowe pojęcia

W języku F# programowanie asynchroniczne koncentruje się wokół dwóch podstawowych pojęć: obliczeń asynchronicznych i zadań.

Ogólnie rzecz biorąc, należy rozważyć użycie w task {…}async {…} nowym kodzie, jeśli pracujesz z bibliotekami platformy .NET korzystającymi z zadań, a jeśli nie korzystasz z asynchronicznych odwołań kodu tailcalls lub niejawnego propagacji tokenu anulowania.

Podstawowe pojęcia dotyczące asynchronicznego

Podstawowe pojęcia programowania asynchronicznego można zobaczyć w poniższym przykładzie:

open System
open System.IO

// Perform an asynchronous read of a file using 'async'
let printTotalFileBytesUsingAsync (path: string) =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    printTotalFileBytesUsingAsync "path-to-file.txt"
    |> Async.RunSynchronously

    Console.Read() |> ignore
    0

W tym przykładzie printTotalFileBytesUsingAsync funkcja jest typu string -> Async<unit>. Wywołanie funkcji nie wykonuje obliczeń asynchronicznych. Zamiast tego zwraca element Async<unit> , który działa jako specyfikacja pracy, która ma być wykonywana asynchronicznie. Wywołuje Async.AwaitTask w jego treści, która konwertuje wynik ReadAllBytesAsync na odpowiedni typ.

Innym ważnym wierszem jest wywołanie metody Async.RunSynchronously. Jest to jedna z funkcji uruchamiania modułu asynchronicznego, które należy wywołać, jeśli chcesz faktycznie wykonać obliczenia asynchroniczne języka F#.

Jest to podstawowa różnica w stylu async programowania w języku C#/Visual Basic. W języku F# obliczenia asynchroniczne można traktować jako zadania zimne. Muszą one być jawnie uruchomione, aby rzeczywiście wykonać. Ma to pewne zalety, ponieważ umożliwia łączenie i sekwencjonowanie pracy asynchronicznej znacznie łatwiej niż w języku C# lub Visual Basic.

Łączenie obliczeń asynchronicznych

Oto przykład, który opiera się na poprzednim, łącząc obliczenia:

open System
open System.IO

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Parallel
    |> Async.Ignore
    |> Async.RunSynchronously

    0

Jak widać, main funkcja ma jeszcze kilka elementów. Koncepcyjnie wykonuje następujące czynności:

  1. Przekształć argumenty wiersza polecenia w sekwencję Async<unit> obliczeń za pomocą polecenia Seq.map.
  2. Utwórz obiekt, który Async<'T[]> planuje i uruchamia printTotalFileBytes obliczenia równolegle podczas jego uruchamiania.
  3. Utwórz obiekt Async<unit> , który uruchomi obliczenia równoległe i zignoruje jego wynik (czyli unit[]).
  4. Jawnie uruchom ogólne skomponowane obliczenia z wartością Async.RunSynchronously, blokując ją do momentu ukończenia.

Po uruchomieniu printTotalFileBytes tego programu jest uruchamiany równolegle dla każdego argumentu wiersza polecenia. Ponieważ obliczenia asynchroniczne są wykonywane niezależnie od przepływu programu, nie ma zdefiniowanej kolejności, w której wyświetlają informacje i kończą wykonywanie. Obliczenia będą zaplanowane równolegle, ale ich kolejność wykonywania nie jest gwarantowana.

Obliczenia asynchroniczne sekwencji

Ponieważ Async<'T> jest to specyfikacja pracy, a nie już uruchomionego zadania, można łatwo wykonywać bardziej skomplikowane przekształcenia. Oto przykład, który sekwencjonuje zestaw obliczeń asynchronicznych, aby wykonywać je po drugim.

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Sequential
    |> Async.Ignore
    |> Async.RunSynchronously
    |> ignore

Spowoduje to zaplanowanie printTotalFileBytes wykonania w kolejności elementów argv zamiast planowania ich równolegle. Ponieważ każda kolejna operacja nie zostanie zaplanowana dopiero po zakończeniu wykonywania poprzednich obliczeń, obliczenia są sekwencjonowane tak, aby nie nakładały się na ich wykonywanie.

Ważne funkcje modułu asynchronicznego

Podczas pisania kodu asynchronicznego w języku F#zwykle będziesz korzystać z platformy obsługującej planowanie obliczeń. Jednak nie zawsze jest tak, więc dobrze jest zrozumieć różne funkcje, które mogą służyć do planowania pracy asynchronicznej.

Ponieważ obliczenia asynchroniczne języka F# są specyfikacją pracy, a nie reprezentacją już wykonywanej pracy, muszą być jawnie uruchomione z funkcją początkową. Istnieje wiele metod początkowych asynchronicznych , które są przydatne w różnych kontekstach. W poniższej sekcji opisano niektóre z bardziej typowych funkcji początkowych.

Async.StartChild

Uruchamia obliczenia podrzędne w ramach obliczeń asynchronicznych. Dzięki temu można wykonywać jednocześnie wiele obliczeń asynchronicznych. Obliczenia podrzędne współudzielą token anulowania z obliczeniami nadrzędnymi. Jeśli obliczenia nadrzędne są anulowane, obliczenia podrzędne również są anulowane.

Podpis:

computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>

Kiedy stosować:

  • Jeśli chcesz wykonać wiele obliczeń asynchronicznych jednocześnie, a nie jedno naraz, ale nie mają zaplanowane równolegle.
  • Jeśli chcesz powiązać okres istnienia obliczeń podrzędnych z obliczeniami nadrzędnymi.

Co należy uważać na:

  • Uruchamianie wielu obliczeń za pomocą Async.StartChild elementu nie jest takie samo jak równoległe planowanie. Jeśli chcesz zaplanować obliczenia równolegle, użyj polecenia Async.Parallel.
  • Anulowanie obliczeń nadrzędnych spowoduje wyzwolenie anulowania wszystkich uruchomionych obliczeń podrzędnych.

Async.StartImmediate

Uruchamia obliczenia asynchroniczne, uruchamiane natychmiast w bieżącym wątku systemu operacyjnego. Jest to przydatne, jeśli musisz zaktualizować coś w wątku wywołującym podczas obliczeń. Jeśli na przykład obliczenia asynchroniczne muszą zaktualizować interfejs użytkownika (taki jak aktualizowanie paska postępu), należy go Async.StartImmediate użyć.

Podpis:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

Kiedy stosować:

  • Jeśli musisz zaktualizować element w wątku wywołującym w środku obliczeń asynchronicznych.

Co należy uważać na:

  • Kod w obliczeniach asynchronicznych będzie uruchamiany na każdym wątku, który ma być zaplanowany. Może to być problematyczne, jeśli ten wątek jest w jakiś sposób poufny, na przykład wątek interfejsu użytkownika. W takich przypadkach Async.StartImmediate może być nieodpowiednie do użycia.

Async.StartAsTask

Wykonuje obliczenia w puli wątków. Zwraca wartość Task<TResult> , która zostanie ukończona w odpowiednim stanie po zakończeniu obliczeń (powoduje wygenerowanie wyniku, zgłoszenie wyjątku lub anulowanie). Jeśli nie podano tokenu anulowania, zostanie użyty domyślny token anulowania.

Podpis:

computation: Async<'T> * ?taskCreationOptions: TaskCreationOptions * ?cancellationToken: CancellationToken -> Task<'T>

Kiedy stosować:

  • Jeśli musisz wywołać interfejs API platformy Task<TResult> .NET, który zwraca wartość , aby reprezentować wynik obliczeń asynchronicznych.

Co należy uważać na:

  • To wywołanie przydzieli dodatkowy Task obiekt, który może zwiększyć obciążenie, jeśli jest często używany.

Async.Parallel

Planuje równoległe wykonywanie sekwencji obliczeń asynchronicznych, co daje tablicę wyników w kolejności ich dostarczenia. Stopień równoległości można opcjonalnie dostroić/ograniczyć, określając maxDegreeOfParallelism parametr .

Podpis:

computations: seq<Async<'T>> * ?maxDegreeOfParallelism: int -> Async<'T[]>

Kiedy należy go używać:

  • Jeśli musisz uruchomić zestaw obliczeń w tym samym czasie i nie ma zależności od ich kolejności wykonywania.
  • Jeśli nie potrzebujesz wyników z obliczeń zaplanowanych równolegle do momentu ich ukończenia.

Co należy uważać na:

  • Dostęp do wynikowej tablicy wartości można uzyskać tylko po zakończeniu wszystkich obliczeń.
  • Obliczenia będą uruchamiane za każdym razem, gdy zostaną zaplanowane. To zachowanie oznacza, że nie można polegać na ich kolejności wykonywania.

Async.Sequential

Planuje sekwencję obliczeń asynchronicznych, które mają być wykonywane w kolejności, w której są przekazywane. Pierwsze obliczenie zostanie wykonane, a następnie następne itd. Nie będą wykonywane równolegle żadne obliczenia.

Podpis:

computations: seq<Async<'T>> -> Async<'T[]>

Kiedy należy go używać:

  • Jeśli musisz wykonać wiele obliczeń w kolejności.

Co należy uważać na:

  • Dostęp do wynikowej tablicy wartości można uzyskać tylko po zakończeniu wszystkich obliczeń.
  • Obliczenia będą uruchamiane w kolejności przekazywania ich do tej funkcji, co może oznaczać, że więcej czasu upłynie przed zwróceniem wyników.

Async.AwaitTask

Zwraca asynchroniczne obliczenie, które oczekuje na ukończenie danej Task<TResult> operacji i zwraca jego wynik jako Async<'T>

Podpis:

task: Task<'T> -> Async<'T>

Kiedy stosować:

  • W przypadku korzystania z interfejsu API platformy .NET, który zwraca Task<TResult> wartość w ramach obliczeń asynchronicznych języka F#.

Co należy uważać na:

  • Wyjątki są opakowane zgodnie AggregateException z konwencją biblioteki równoległej zadań. To zachowanie różni się od tego, jak asynchroniczne środowisko F# zwykle przedstawia wyjątki.

Async.Catch

Tworzy asynchroniczne obliczenia, które wykonuje daną Async<'T>wartość , zwracając element Async<Choice<'T, exn>>. Jeśli dana wartość Async<'T> zakończy się pomyślnie, Choice1Of2 zostanie zwrócona wartość wynikowa. Jeśli wyjątek zostanie zgłoszony przed jego ukończeniem, zostanie Choice2of2 zwrócony z zgłoszonym wyjątkiem. Jeśli jest on używany w obliczeniach asynchronicznych, które składa się z wielu obliczeń, a jedno z tych obliczeń zgłasza wyjątek, obejmujące obliczenia zostaną całkowicie zatrzymane.

Podpis:

computation: Async<'T> -> Async<Choice<'T, exn>>

Kiedy stosować:

  • W przypadku wykonywania pracy asynchronicznej, która może zakończyć się niepowodzeniem z wyjątkiem i chcesz obsłużyć ten wyjątek w obiekcie wywołującym.

Co należy uważać na:

  • W przypadku używania połączonych lub sekwencjonowanych obliczeń asynchronicznych obejmujące obliczenia w pełni zatrzymają się, jeśli jedno z jego "wewnętrznych" obliczeń zgłasza wyjątek.

Async.Ignore

Tworzy asynchroniczne obliczenia, które uruchamia dane obliczenia, ale pominie jego wynik.

Podpis:

computation: Async<'T> -> Async<unit>

Kiedy stosować:

  • Jeśli masz obliczenia asynchroniczne, których wynik nie jest potrzebny. Jest to analogia do ignore funkcji dla kodu innego niż asynchroniczny.

Co należy uważać na:

  • Jeśli musisz użyć Async.Ignore funkcji , ponieważ chcesz użyć Async.Start lub innej funkcji, która wymaga Async<unit>, rozważ odrzucenie wyniku jest w porządku. Unikaj odrzucania wyników tylko w celu dopasowania podpisu typu.

Async.RunSynchronously

Uruchamia obliczenia asynchroniczne i oczekuje na jego wynik w wątku wywołującym. Propaguje wyjątek, jeśli obliczenie da jedną wartość. To wywołanie blokuje.

Podpis:

computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T

Kiedy należy go używać:

  • Jeśli jest ona potrzebna, użyj jej tylko raz w aplikacji — w punkcie wejścia pliku wykonywalnego.
  • Jeśli nie interesuje Cię wydajność i chcesz jednocześnie wykonać zestaw innych operacji asynchronicznych.

Co należy uważać na:

  • Wywołanie Async.RunSynchronously blokuje wątek wywołujący do momentu zakończenia wykonywania.

Async.Start

Uruchamia asynchroniczne obliczenie, które zwraca unit wartość w puli wątków. Nie czeka na ukończenie i/lub obserwuje wynik wyjątku. Zagnieżdżone obliczenia rozpoczęte Async.Start od są uruchamiane niezależnie od obliczeń nadrzędnych, które je nazwały; ich okres istnienia nie jest powiązany z żadnymi obliczeniami nadrzędnymi. Jeśli obliczenia nadrzędne zostaną anulowane, nie zostaną anulowane żadne obliczenia podrzędne.

Podpis:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

Użyj tylko wtedy, gdy:

  • Masz obliczenia asynchroniczne, które nie dają wyniku i/lub wymagają przetwarzania jednego.
  • Nie musisz wiedzieć, kiedy kończy się obliczenia asynchroniczne.
  • Nie obchodzi cię, na którym wątku jest uruchamiane obliczenia asynchroniczne.
  • Nie musisz mieć żadnych informacji o wyjątkach ani zgłaszać wyjątków wynikających z wykonania.

Co należy uważać na:

  • Wyjątki zgłaszane przez obliczenia rozpoczynające się Async.Start od nie są propagowane do elementu wywołującego. Stos wywołań będzie całkowicie unwound.
  • Każda praca (na przykład wywołanie printfn) rozpoczęta z elementem Async.Start nie spowoduje wystąpienia efektu w głównym wątku wykonywania programu.

Współdziałanie z platformą .NET

W przypadku korzystania z async { } programowania może być konieczne współdziałanie z biblioteką .NET lub bazą kodu języka C#, która korzysta z asynchronicznego programowania asynchronicznego w stylu asynchronicznego. Ponieważ język C# i większość bibliotek platformy .NET używają Task<TResult> typów i Task jako ich podstawowych abstrakcji, może to zmienić sposób pisania kodu asynchronicznego języka F#.

Jedną z opcji jest przejście na pisanie zadań platformy .NET bezpośrednio przy użyciu polecenia task { }. Alternatywnie możesz użyć Async.AwaitTask funkcji , aby oczekiwać na obliczenia asynchroniczne platformy .NET:

let getValueFromLibrary param =
    async {
        let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
        return value
    }

Za pomocą Async.StartAsTask funkcji można przekazać obliczenia asynchroniczne do wywołującego platformy .NET:

let computationForCaller param =
    async {
        let! result = getAsyncResult param
        return result
    } |> Async.StartAsTask

Aby pracować z interfejsami API, które używają Task (czyli obliczeń asynchronicznych platformy .NET, które nie zwracają wartości), może być konieczne dodanie dodatkowej funkcji, która przekonwertuje Async<'T> element na Taskwartość :

module Async =
    // Async<unit> -> Task
    let startTaskFromAsyncUnit (comp: Async<unit>) =
        Async.StartAsTask comp :> Task

Istnieje już element Async.AwaitTask , który akceptuje Task jako dane wejściowe. Dzięki temu i wcześniej zdefiniowanej startTaskFromAsyncUnit funkcji można uruchamiać typy i czekać Task na nie na podstawie obliczeń asynchronicznych języka F#.

Pisanie zadań platformy .NET bezpośrednio w języku F#

W języku F#można pisać zadania bezpośrednio przy użyciu polecenia task { }, na przykład:

open System
open System.IO

/// Perform an asynchronous read of a file using 'task'
let printTotalFileBytesUsingTasks (path: string) =
    task {
        let! bytes = File.ReadAllBytesAsync(path)
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    let task = printTotalFileBytesUsingTasks "path-to-file.txt"
    task.Wait()

    Console.Read() |> ignore
    0

W tym przykładzie printTotalFileBytesUsingTasks funkcja jest typu string -> Task<unit>. Wywołanie funkcji rozpoczyna wykonywanie zadania. Wywołanie task.Wait() oczekiwania na ukończenie zadania.

Relacja z wieloma wątkami

Mimo że wątkowanie zostało wymienione w tym artykule, należy pamiętać o dwóch ważnych kwestiach:

  1. Nie ma koligacji między obliczeniami asynchronicznymi a wątkiem, chyba że jawnie uruchomiono w bieżącym wątku.
  2. Programowanie asynchroniczne w języku F# nie jest abstrakcją w przypadku wielowątkowego.

Na przykład obliczenia mogą być rzeczywiście uruchamiane w wątku obiektu wywołującego, w zależności od charakteru pracy. Obliczenia mogą również "przeskoczyć" między wątkami, pożyczając je przez niewielki czas, aby wykonać przydatną pracę między okresami "oczekiwania" (np. gdy połączenie sieciowe jest w trakcie przesyłania).

Chociaż język F# zapewnia pewne możliwości uruchamiania obliczeń asynchronicznych w bieżącym wątku (lub jawnie nie w bieżącym wątku), asynchronia zazwyczaj nie jest skojarzona z określoną strategią wątkowania.

Zobacz też