Udostępnij za pośrednictwem


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:

  • Prezentacja procesu serwera zdolnego do obsługi dużej liczby równoczesnych nadchodzących żądań, minimalizując jednocześnie wykorzystanie zasobów systemowych w czasie oczekiwania na dane wejściowe z zewnętrznych systemów lub usług.
  • Utrzymywanie dynamicznego interfejsu użytkownika lub głównego wątku podczas równoczesnego postępu pracy w tle

Chociaż praca w tle często wiąże się z wykorzystaniem wielu wątków, ważne jest, aby oddzielnie rozważać pojęcia asynchroniczności i wielowątkowości. 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 bardziej. 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ść; gdy wiele obliczeń lub kilka części pojedynczego obliczenia jest wykonywanych 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 pomylić, 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", oznacza "nie".
  • "synchroniczny", czyli "w tym samym czasie".

Po połączeniu tych dwóch terminów zobaczysz, że "asynchroniczne" oznacza "nie w tym samym czasie". To wszystko! W tej definicji nie ma oznaczeń współbieżności ani równoległości. 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ń.

  • Typ Async<'T> z async { } wyrażeniami, który reprezentuje komponowalne obliczenia asynchroniczne, które można rozpocząć w celu utworzenia zadania.
  • Typ Task<'T> z task { } wyrażeniami reprezentującymi wykonywanie zadania platformy .NET.

Ogólnie rzecz biorąc, należy rozważyć użycie task {…} zamiast async {…} w nowym kodzie, jeśli pracujesz z bibliotekami .NET, które korzystają z zadań, i jeżeli nie polegasz na asynchronicznych wywołaniach zwrotnych kodu ani na propagacji tokenu anulowania.

Podstawowe pojęcia dotyczące asynchroniczności

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 przykładzie funkcja printTotalFileBytesUsingAsync jest typu string -> Async<unit>. Faktyczne wywołanie funkcji nie powoduje wykonania 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 swojej treści, co 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#.

Różnica w stylu programowania w C#/Visual Basic jest zasadnicza. W języku F# obliczenia asynchroniczne można traktować jako zadania zimne. Aby rzeczywiście zostały wykonane, muszą być jawnie uruchomione. 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 Async<'T[]>, który planuje i uruchamia printTotalFileBytes obliczenia równolegle, kiedy działa.
  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 tego programu, printTotalFileBytes 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.

Sekwencjonowanie obliczeń asynchronicznych

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 jedno 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

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

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 potomne dzielą token anulowania z obliczeniami nadrzędnymi. Jeśli obliczenia nadrzędne zostaną anulowane, obliczenia podrzędne również zostaną anulowane.

Podpis:

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

Kiedy należy użyć:

  • Kiedy chcesz wykonywać wiele obliczeń asynchronicznych jednocześnie, zamiast jednego po drugim, ale bez ich planowania równolegle.
  • Jeśli chcesz powiązać czas trwania obliczeń podrzędnych z czasem trwania obliczeń nadrzędnych.

Co należy uważać na:

  • Uruchamianie wielu obliczeń za pomocą Async.StartChild nie jest tym samym co planowanie ich równocześnie. Jeśli chcesz zaplanować obliczenia równolegle, użyj polecenia Async.Parallel.
  • Anulowanie obliczeń nadrzędnych spowoduje automatyczne anulowanie wszystkich obliczeń podrzędnych, które zostały uruchomione.

Async.StartImmediate

Uruchamia obliczenia asynchroniczne, które natychmiast rozpoczynają się na bieżącym wątku systemu operacyjnego. Jest to przydatne, jeśli musisz zaktualizować coś w wątku wywołującym podczas obliczeń. Na przykład, jeśli obliczenia asynchroniczne muszą zaktualizować interfejs użytkownika (taki jak pasek postępu), wtedy należy użyć Async.StartImmediate.

Podpis:

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

Kiedy należy użyć:

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

Co należy uważać na:

  • Kod w obliczeniach asynchronicznych będzie uruchamiany na każdym wątku, na którym akurat jest 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 zakończona w odpowiednim momencie po zakończeniu obliczeń (produkuje wynik, rzuci wyjątek lub zostanie anulowana). Jeśli nie podano tokenu anulowania, zostanie użyty domyślny token anulowania.

Podpis:

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

Kiedy należy użyć:

  • Jeśli musisz wywołać interfejs API platformy .NET, który zwraca Task<TResult> w celu reprezentacji wyniku 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 musisz się martwić o ich kolejność 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, gdy tylko 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 należy użyć:

  • Podczas korzystania z interfejsu API platformy .NET, który zwraca wartość Task<TResult> w asynchronicznym obliczeniu w języku F#.

Co należy uważać na:

  • Wyjątki są opakowane w AggregateException, zgodnie z konwencją Task Parallel Library; to zachowanie różni się od sposobu, w jaki asynchroniczne środowisko F# zazwyczaj przedstawia wyjątki.

Async.Catch

Tworzy asynchroniczne obliczenie, które wykonuje podany Async<'T>, zwracając Async<Choice<'T, exn>>. Jeśli dane Async<'T> zakończy się pomyślnie, zostanie zwrócony Choice1Of2 z wartością wynikową. Jeśli wyjątek zostanie zgłoszony przed ukończeniem zadania, zostanie Choice2of2 zwrócony razem z podniesionym wyjątkiem. Jeśli jest on używany w obliczeniach asynchronicznych, które same są złożone z wielu obliczeń, a jedno z tych obliczeń zgłasza wyjątek, obejmujące obliczenie zostanie całkowicie zatrzymane.

Podpis:

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

Kiedy należy użyć:

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

Co należy uważać na:

  • W przypadku używania złożonych lub sekwencyjnych obliczeń asynchronicznych otaczające obliczenia zatrzymają się w pełni, jeśli jedno z jego "wewnętrznych" obliczeń wyrzuci wyjątek.

Async.Ignore

Tworzy asynchroniczne obliczenia, które wykonuje podane obliczenia, ale pomija jego wynik.

Podpis:

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

Kiedy należy użyć:

  • 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, ponieważ chcesz użyć Async.Start lub innej funkcji, która wymaga Async<unit>, rozważ czy odrzucenie wyniku jest w porządku. Unikaj odrzucania wyników tylko w celu dopasowania sygnatury 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 jest blokujące.

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 jego wykonywania.

Async.Start

Uruchamia asynchroniczne obliczenia zwracające unit w puli wątków. Nie czeka na ukończenie ani nie zauważa wyniku wyjątku. Zagnieżdżone obliczenia rozpoczęte z Async.Start są uruchamiane niezależnie od obliczeń nadrzędnych, które je wywołały; ich czas trwania nie jest związany z 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 obliczenie asynchroniczne, które nie daje wyniku i/lub wymaga przetworzenia wyniku.
  • Nie musisz wiedzieć, kiedy kończy się obliczenia asynchroniczne.
  • Nieważne jest dla ciebie, na którym wątku jest uruchamiane obliczenie asynchroniczne.
  • Nie musisz być świadomym wyjątków ani zgłaszać wyjątków powstałych podczas wykonania.

Co należy uważać na:

  • Wyjątki zgłaszane przez obliczenia rozpoczęte z Async.Start nie są propagowane do wywołującego. Stos wywołań będzie całkowicie unwound.
  • Każda praca (na przykład wywołanie printfn) rozpoczęta przy użyciu Async.Start nie spowoduje 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 w języku C#, która korzysta ze stylu asynchronicznego programowania z użyciem async/await. 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ć funkcji Async.AwaitTask, aby poczekać na asynchroniczne obliczenia platformy .NET.

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

Za pomocą funkcji Async.StartAsTask można przekazać obliczenia asynchroniczne do wywołującego .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ą żadnej wartości), może być konieczne dodanie dodatkowej funkcji, która przekonwertuje element Async<'T> na wartość Task:

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 przykładzie funkcja printTotalFileBytesUsingTasks jest typu string -> Task<unit>. Wywołanie funkcji rozpoczyna wykonywanie zadania. Wywołanie task.Wait() czeka na ukończenie zadania.

Relacja do wielowątkowości

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

  1. Nie ma związku między obliczeniami asynchronicznymi a wątkiem, chyba że zostanie explicytnie uruchomione na bieżącym wątku.
  2. Programowanie asynchroniczne w języku F# nie jest abstrakcją dla wielowątkowości.

Na przykład obliczenia mogą być faktycznie wykonywane 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 na chwilę, aby wykonać między okresami "oczekiwania" przydatną pracę (np. w trakcie przesyłania połączenia sieciowego).

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 także