Поделиться через


Асинхронное программирование в F#

Асинхронное программирование — это механизм, необходимый для современных приложений по различным причинам. Существует два основных варианта использования, с которыми сталкиваются большинство разработчиков:

  • Представление процесса сервера, который может обслуживать значительное количество одновременных входящих запросов, минимизируя занятые системные ресурсы, пока обработка запросов ожидает входные данные из систем или служб вне этого процесса.
  • Обслуживание адаптивного пользовательского интерфейса или основного потока при одновременном выполнении фоновой работы

Хотя фоновая работа часто включает использование нескольких потоков, важно учитывать понятия асинхронного и многопотокового использования отдельно. На самом деле, они являются отдельными проблемами, и один не подразумевает другого. В этой статье подробно описаны отдельные понятия.

Определение асинхронности

Предыдущий пункт - что асинхронность не зависит от использования нескольких потоков - стоит объяснить более подробно. Существует три понятия, которые иногда связаны, но строго независимо друг от друга:

  • Параллелизм; при выполнении нескольких вычислений в перекрывающихся периодах времени.
  • Параллелизм; при одновременном выполнении нескольких вычислений или нескольких частей одного вычисления.
  • Асинхронный; если одно или несколько вычислений может выполняться отдельно от основного потока программы.

Все три являются ортогональными понятиями, но их легко спутать, особенно когда они используются вместе. Например, может потребоваться выполнить несколько асинхронных вычислений параллельно. Эта связь не означает, что параллелизм или асинхронность подразумевают друг друга.

Если вы рассматриваете этимологию слова "асинхронный", существует два элемента:

  • "a", в значении "не".
  • "синхронный", то есть "одновременно".

При объединении этих двух терминов, вы выясните, что "асинхронный" означает "не одновременно". Вот и все! В этом определении нет никакого намека на конкурентность или параллелизм. Это также верно на практике.

Практическим образом асинхронные вычисления в F# должны выполняться независимо от основного потока программы. Это независимое выполнение не подразумевает конкурентность или параллелизм, и не означает, что вычисление всегда происходит в фоновом режиме. Фактически асинхронные вычисления могут даже выполняться синхронно в зависимости от характера вычисления и среды, в которой выполняется вычисление.

Основной вывод должен быть связан с тем, что асинхронные вычисления не зависят от основного потока программы. Хотя имеются лишь немного гарантий относительно того, когда или как выполняется асинхронное вычисление, существуют некоторые подходы к их оркестрации и планированию. В остальной части этой статьи рассматриваются основные понятия для асинхронности F# и способы использования типов, функций и выражений, встроенных в F#.

Основные понятия

В F# асинхронное программирование сосредоточено на двух основных понятиях: асинхронных вычислениях и задачах.

  • Async<'T> Тип с async { } выражениями, представляющий составные асинхронные вычисления, которые можно запустить для формирования задачи.
  • Тип Task<'T> с task { } выражениями, представляющими задачу .NET.

Как правило, следует рассмотреть возможность использования task {…} вместо async {…} в новом коде, если вы взаимодействуете с .NET библиотеками, использующими задачи, и если вы не полагаетесь на асинхронные хвостовые вызовы или неявное распространение токена отмены.

Основные понятия асинхронности

Основные понятия асинхронного программирования можно увидеть в следующем примере:

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

В примере printTotalFileBytesUsingAsync функция имеет тип string -> Async<unit>. Вызов функции фактически не выполняет асинхронные вычисления. Вместо этого он возвращает значение Async<unit> , которое выступает в качестве спецификации работы, выполняемой асинхронно. Он вызывает Async.AwaitTask в своем тексте, который преобразует результат ReadAllBytesAsync в соответствующий тип.

Еще одна важная строка — вызов Async.RunSynchronously. Это один из начальных функций модуля Async, который необходимо вызвать, если вы хотите фактически выполнить асинхронное вычисление F#.

Это основное различие с стилем программирования async C#/Visual Basic. В F# асинхронные вычисления можно рассматривать как холодные задачи. Они должны быть явно запущены для фактического выполнения. Это имеет свои преимущества, так как позволяет объединять асинхронные задачи и упорядочивать их гораздо проще, чем в C# или Visual Basic.

Объединение асинхронных вычислений

Ниже приведен пример, который основан на предыдущем, объединяя вычисления:

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

Как видно, функция main имеет довольно много элементов. Концептуально это делает следующее:

  1. Преобразуйте аргументы командной строки в последовательность вычислений Async<unit> с Seq.map помощью.
  2. Создайте Async<'T[]>, который планирует и параллельно выполняет вычисления printTotalFileBytes при запуске.
  3. Async<unit> Создайте объект, который будет выполнять параллельные вычисления и игнорировать его результат (т. еunit[].
  4. Выполните явным образом полное составное вычисление с использованием Async.RunSynchronously, блокируя выполнение до его завершения.

При запуске этой программы printTotalFileBytes выполняется параллельно для каждого аргумента командной строки. Так как асинхронные вычисления выполняются независимо от потока программы, нет определенного порядка, в котором они печатают свои сведения и завершают выполнение. Вычисления будут запланированы параллельно, но их порядок выполнения не гарантируется.

Последовательность асинхронных вычислений

Так как Async<'T> это спецификация работы, а не уже запущенная задача, можно легко выполнять более сложные преобразования. Ниже приведен пример последовательности набора вычислений Async, чтобы они выполнялись друг за другом.

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

Это запланирует printTotalFileBytes выполнение в порядке элементов argv вместо параллельного планирования. Так как каждая последовательная операция не будет запланирована до завершения выполнения предыдущего вычисления, вычисления последовательно выполняются таким образом, что их выполнение не перекрывается.

Важные функции модуля Async

При написании асинхронного кода в F#вы обычно взаимодействуете с платформой, которая обрабатывает планирование вычислений. Однако это не всегда так, поэтому хорошо понять различные функции, которые можно использовать для планирования асинхронной работы.

Так как асинхронные вычисления F# — это спецификация работы, а не представление уже выполняемой работы, они должны быть явно запущены с начальной функцией. Существует множество асинхронных начальных методов , которые полезны в разных контекстах. В следующем разделе описаны некоторые из наиболее распространенных начальных функций.

Async.StartChild

Запускает дочерние вычисления в рамках асинхронного вычисления. Это позволяет одновременно выполнять несколько асинхронных вычислений. Дочерние вычисления совместно используют маркер отмены с родительским вычислением. Если родительское вычисление отменено, дочерние вычисления также отменяются.

Подпись:

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

Когда следует использовать:

  • Если вы хотите выполнять несколько асинхронных вычислений одновременно, а не по отдельности, но при этом не запускать их параллельно.
  • Если вы хотите связать срок службы дочернего вычисления с родительским вычислением.

Что следить за:

  • Запуск нескольких вычислений Async.StartChild не равнозначен планированию их выполнения параллельно. Если вы хотите запланировать вычисления параллельно, используйте Async.Parallel.
  • Отмена родительского вычисления приведет к отмене всех запущенных дочерних вычислений.

Async.StartImmediate

Выполняет асинхронное вычисление, начиная с текущего потока операционной системы. Это полезно, если необходимо обновить что-то в вызывающем потоке во время вычисления. Например, если асинхронное вычисление должно обновлять пользовательский интерфейс (например, индикатор выполнения), следует использовать Async.StartImmediate.

Подпись:

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

Когда следует использовать:

  • Когда нужно обновить что-либо в вызывающем потоке во время асинхронного вычисления.

Что следить за:

  • Код в асинхронном вычислении будет выполняться в любом потоке, на котором он случайно оказался запланирован. Это может быть проблематично, если этот поток каким-то образом учитывается, например поток пользовательского интерфейса. В таких случаях, скорее всего, Async.StartImmediate не подходит для использования.

Async.StartAsTask

Выполняет вычисление в пуле потоков. Возвращает значение Task<TResult> , которое будет завершено в соответствующем состоянии после завершения вычисления (создает результат, вызывает исключение или получает отмену). Если маркер отмены не указан, используется маркер отмены по умолчанию.

Подпись:

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

Когда следует использовать:

  • Когда необходимо вызвать API .NET, который возвращает Task<TResult> в качестве представления результата асинхронного вычисления.

Что следить за:

  • Этот вызов выделяет дополнительный Task объект, который может увеличить издержки, если он часто используется.

Async.Parallel

Планирует параллельно выполнять последовательность асинхронных вычислений, предоставляя массив результатов в том порядке, в который они были предоставлены. Степень параллелизма можно настроить или регулировать, указав maxDegreeOfParallelism параметр.

Подпись:

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

Когда его следует использовать:

  • Если необходимо одновременно запустить набор вычислений и не зависеть от их порядка выполнения.
  • Если вам не нужны результаты от вычислений, запланированных для параллельного выполнения, до их завершения.

Что следить за:

  • Доступ к полученному массиву значений можно получить только после завершения всех вычислений.
  • Вычисления будут выполняться всякий раз, когда они будут запланированы. Это поведение означает, что вы не можете полагаться на порядок их выполнения.

Асинхронный.Последовательный

Планирует выполнение последовательности асинхронных вычислений в том порядке, в который они передаются. Первое вычисление будет выполнено, затем следующее и т. д. Вычисления не будут выполняться параллельно.

Подпись:

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

Когда его следует использовать:

  • Если необходимо выполнить несколько вычислений по порядку.

Что следить за:

  • Доступ к полученному массиву значений можно получить только после завершения всех вычислений.
  • Вычисления будут выполняться в том порядке, в котором они передаются этой функции, что может означать, что больше времени будет истекать до возврата результатов.

Async.AwaitTask

Возвращает асинхронное вычисление, которое ожидает завершения заданного Task<TResult> и возвращает результат в виде Async<'T>

Подпись:

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

Когда следует использовать:

  • При использовании API .NET, который возвращает Task<TResult> в F#-асинхронном вычислении.

Что следить за:

  • Исключения упаковываются в AggregateException в соответствии с соглашением Task Parallel Library; это поведение отличается от того, как F# async обычно обрабатывает исключения.

Async.Catch

Создает асинхронное вычисление, выполняющее заданное Async<'T>, возвращающее значение Async<Choice<'T, exn>>. Если данный Async<'T> успешно завершается, Choice1Of2 возвращается с результирующим значением. Если исключение выбрасывается до завершения, возвращается Choice2of2 вместе с вызванным исключением. Если он используется для асинхронных вычислений, состоящих из множества вычислений, и одно из этих вычислений создает исключение, то комплексное вычисление будет остановлено полностью.

Подпись:

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

Когда следует использовать:

  • При выполнении асинхронной работы, которая может завершиться сбоем с исключением, и вы хотите обработать это исключение в вызывающем коде.

Что следить за:

  • При использовании объединенных или последовательных асинхронных вычислений комплексное вычисление будет полностью остановлено, если одно из его внутренних вычислений вызывает исключение.

Async.Ignore

Создает асинхронное вычисление, которое выполняет заданные вычисления, но удаляет его результат.

Подпись:

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

Когда следует использовать:

  • Если у вас есть асинхронное вычисление, результат которого не нужен. Это аналогично ignore функции для неасинхронного кода.

Что следить за:

  • Если необходимо использовать Async.Ignore, потому что вы хотите использовать Async.Start или другую функцию, которая требует Async<unit>, подумайте, допустимо ли игнорировать полученный результат. Избегайте отбрасывания результатов только чтобы соответствовать сигнатуре типа.

Async.RunSynchronously

Выполняет асинхронное вычисление и ожидает его результат в вызывающем потоке. Вызывает исключение, если вычисление приводит к его возникновению. Этот вызов блокируется.

Подпись:

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

Когда его следует использовать:

  • Если это необходимо, используйте его только один раз в приложении в точке входа для исполняемого файла.
  • Если вы не заботитесь о производительности и хотите одновременно выполнить набор других асинхронных операций.

Что следить за:

  • Вызов Async.RunSynchronously блокирует вызывающий поток до завершения выполнения.

Async.Start

Запускает асинхронное вычисление, которое возвращается unit в пуле потоков. Не дожидается завершения и/или не отслеживает результат исключения. Вложенные вычисления, начатые Async.Start, запускаются независимо от родительского вычисления, которое их вызвало; их время существования не привязано ни к какому конкретному родительскому вычислению. Если родительское вычисление отменено, дочерние вычисления не отменяются.

Подпись:

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

Используйте только в том случае, если:

  • У вас есть асинхронные вычисления, которые не дают результата или требуют его обработки.
  • Вам не нужно знать, когда выполняется асинхронное вычисление.
  • Вы не заботитесь о том, на каком потоке выполняется асинхронное вычисление.
  • У вас нет необходимости учитывать исключения или сообщать об исключениях, возникающих при выполнении.

Что следить за:

  • Исключения, возникающие при запуске Async.Start вычислений, не распространяются на вызывающий объект. Стек вызовов будет полностью раскручен.
  • Любая работа (например, вызов printfn), начатая с помощью Async.Start, не будет влиять на основной поток программы.

Взаимодействие с .NET

При использовании async { } программирования может потребоваться взаимодействие с библиотекой .NET или кодовой базой C#, которая использует асинхронное программирование в стиле async/await. Так как C# и большинство библиотек .NET используют Task<TResult> и Task типы в качестве основных абстракций, это может изменить способ написания асинхронного кода F#.

Одним из вариантов является переключение на запись задач .NET напрямую с помощью task { }. Кроме того, можно использовать функцию Async.AwaitTask для ожидания асинхронных вычислений .NET:

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

Функцию Async.StartAsTask можно использовать для передачи асинхронных вычислений вызывающей программе .NET:

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

Чтобы работать с API, которые используют Task (т. е. асинхронные вычисления .NET, которые не возвращают значение), может потребоваться добавить дополнительную функцию, которая преобразуется Async<'T> в Task:

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

Уже существует Async.AwaitTask, который принимает Task в качестве входных данных. С помощью этой и ранее определенной startTaskFromAsyncUnit функции можно запускать и работать с типами Task в асинхронном вычислении на F#.

Написание задач .NET непосредственно в F#

В F#можно создавать задачи напрямую с помощью task { }, например:

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

В примере printTotalFileBytesUsingTasks функция имеет тип string -> Task<unit>. Вызов функции начинает выполнять задачу. Вызов task.Wait() ожидает завершения задачи.

Взаимосвязь с многопоточностью

Несмотря на то, что потоки упоминаются в этой статье, важно помнить:

  1. Нет связи между асинхронными вычислениями и потоком, если они явно не запущены в текущем потоке.
  2. Асинхронное программирование в F# не является абстракцией для многопоточных операций.

Например, вычисления могут выполняться на потоке вызывающего объекта в зависимости от характера работы. Вычисление может также "переключаться" между потоками, заимствуя их на короткое время, чтобы выполнить полезную работу между периодами, когда идет "ожидание" (например, когда сетевой вызов находится в процессе передачи).

Хотя F# предоставляет некоторые возможности для запуска асинхронных вычислений в текущем потоке (или явно не в текущем потоке), асинхронность обычно не связана с определенной стратегией потоков.

См. также