Асинхронное программирование на 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[]>

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

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

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

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

Async.Sequential

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

Подпись:

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

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

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

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

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

Async.AwaitTask

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

Подпись:

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

Сценарии использования.

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

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

  • Исключения упаковываются в AggregateException соответствии с соглашением о параллельной библиотеке задач. Это поведение отличается от того, как Async F# обычно создает исключения.

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.IgnoreAsync.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#, которая использует асинхронное асинхронное программирование в стиле 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# предоставляет некоторые возможности для запуска асинхронных вычислений в текущем потоке (или явно не в текущем потоке), асинхронность обычно не связана с определенной стратегией потоков.

См. также