F 中的异步编程#

异步编程是一种机制,对于新式应用程序来说至关重要,原因多种多样。 大多数开发人员会遇到两个主要用例:

  • 呈现可以处理大量并发传入请求的服务器进程,同时尽量减少在请求处理期间占用的系统资源等待来自进程外部的系统或服务的输入
  • 在并发执行后台工作的同时维护响应迅速的 UI 或主线程

尽管后台工作通常涉及使用多个线程,但请务必单独考虑异步和多线程的概念。 事实上,它们是单独的关切,一个并不暗示另一个。 本文更详细地介绍了单独的概念。

异步定义

上一点 - 异步与多个线程的利用率无关 - 值得进一步解释。 有时有三个概念是相关的,但严格独立于彼此:

  • 并发;当多个计算在重叠时间段内执行时。
  • 并行性:多个计算或单个计算的多个部分完全在同一时间运行。
  • 异步;当一个或多个计算可以独立于主程序流执行时。

这三个概念都是正交概念,但很容易混淆,尤其是在一起使用时。 例如,可能需要并行执行多个异步计算。 这种关系并不意味着并行性或异步性暗示彼此。

如果考虑“异步”一词的词源学,则涉及两个部分:

  • 字母 “a”,表示“不是”。
  • “同步”,表示“同时”。

当将这两个术语结合在一起时,你会发现“异步”表示“不同时”。 就是这样! 此定义中没有并发或并行性的含义。 在实践中也是如此。

实际上,F#中的异步计算被调度为独立于主程序流执行。 这种独立执行并不意味着并发或并行,也不意味着计算始终在后台发生。 事实上,异步计算甚至可以同步执行,具体取决于计算的性质以及计算正在执行的环境。

你应该拥有的主要要点是,异步计算独立于主程序流。 尽管何时或如何执行异步计算几乎没有保证,但仍有一些方法可以对它们进行协调和调度。 本文的其余部分探讨了 F# 异步的核心概念,以及如何使用内置于 F# 中的类型、函数和表达式。

核心概念

在 F# 中,异步编程以两个核心概念为中心:异步计算和任务。

  • 具有 async { } 类型,表示可以启动以构成任务的可组合异步计算。
  • 具有 task { } 类型,表示正在执行的 .NET 任务。

通常,如果与使用任务的 .NET 库进行互操作,并且不依赖于异步代码尾调用或隐式取消令牌传播,则应考虑在新代码中使用task {…}而不是async {…}

异步的核心概念

可以在以下示例中看到“async”编程的基本概念:

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。 这是异步模块的启动函数之一,如果您想真正执行 F# 的异步计算,您将需要调用它。

这是与 C#/Visual Basic 编程样式 async 的基本区别。 在 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> 是工作规范,而不是已运行的任务,因此可以轻松执行更复杂的转换。 下面的示例对一组异步计算进行排序,以便它们逐个执行。

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 执行,而不是并行调度它们。 由于前面的计算完成执行之后,每个连续操作都不会被调度,因此这些计算依次单独进行,以确保执行过程中没有重叠。

重要异步模块函数

在 F# 中编写异步代码时,通常会与处理计算计划的框架进行交互。 但是,这种情况并非总是如此,因此最好了解可用于计划异步工作的各种函数。

由于 F# 异步计算是 工作的规范 ,而不是已执行的工作表示形式,因此必须使用起始函数显式启动它们。 有许多 异步起始方法 在不同上下文中有所帮助。 以下部分介绍一些更常见的起始函数。

Async.StartChild

在异步计算中启动子计算。 这允许并发执行多个异步计算。 子计算与父计算共享取消标记。 如果取消父计算,则子计算也会被取消。

签名:

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

何时使用:

  • 当你想要并发执行多个异步计算而不是一次执行一个,但又不想将其计划为并行执行时。
  • 当你希望将子计算的生命周期与父计算的生命周期关联起来时。

要注意什么:

  • 使用 Async.StartChild 启动多个计算与将它们并行调度并不相同。 如果要并行计划计算,请使用 Async.Parallel
  • 取消父计算将导致它启动的所有子计算被取消。

Async.StartImmediate

运行异步计算,立即在当前操作系统线程上启动。 如果需要在计算期间更新调用线程上的内容,这非常有用。 例如,如果异步计算必须更新 UI(如更新进度栏), Async.StartImmediate 则应使用。

签名:

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

何时使用:

  • 需要在异步计算过程中更新调用线程上的内容时。

要注意什么:

  • 异步计算中的代码将在所计划的任何线程上运行。 如果线程在某些方面很敏感,例如 UI 线程,则可能会出现问题。 在这种情况下, Async.StartImmediate 可能不适合使用。

Async.StartAsTask

在线程池中执行计算。 返回一个 Task<TResult>,在计算终止(产生结果、引发异常或被取消)后,将在对应状态中完成。 如果未提供取消令牌,则使用默认取消令牌。

签名:

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

何时使用:

  • 当需要调用一个生成Task<TResult>的 .NET API 以表示异步计算结果时。

要注意什么:

  • 此调用将分配一个附加 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>

何时使用:

  • 当你使用在 F# 异步计算中返回 Task<TResult> 的 .NET API 时。

要注意什么:

  • 按照任务并行库的约定,异常被封装在 AggregateException 中;此行为与 F# 异步通常处理异常的方式不同。

Async.Catch

创建一个异步计算,用于执行给定的Async<'T>,并返回Async<Choice<'T, exn>>。 如果给定 Async<'T> 成功完成,则返回结果值为 a 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 { } 编程,可能需要与使用 async/await 样式异步编程的 .NET 库或 C# 代码库进行互作。 由于 C# 和大多数 .NET 库使用 Task<TResult>Task 类型作为其核心抽象,因此可能会更改编写 F# 异步代码的方式。

一种选择是切换为直接使用 task { } 编写 .NET 任务。 或者,可以使用函数 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

若要处理使用Task的API(即不返回值的 .NET 异步计算),您可能需要添加一个额外的函数,以便将Async<'T>转换为Task

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

已有一个接受 Async.AwaitTask 作为输入的 Task。 借助此函数和之前定义的 startTaskFromAsyncUnit 函数,可以启动并等待 F# 异步计算中的 Task 类型。

直接在 F# 中编写 .NET 任务

在 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# 提供了在当前线程上启动异步计算(或显式不在当前线程上)的一些功能,但异步通常与特定的线程处理策略无关。

另请参阅