F# 中的异步编程

由于各种各样的原因,异步编程成了新式应用程序必不可少的一种机制。 大多数开发人员会遇到以下两个主要用例:

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

尽管后台工作通常涉及多个线程的使用,但请务必分别考虑异步和多线程的概念。 事实上,它们是两个不同的关注点。异步并不意味着多线程,反之亦然。 本文更详细地描述了这两个不同的概念。

异步定义

前一点(异步与多个线程的使用无关)值得进一步说明。 以下三个概念有时是相关的,但彼此完全独立:

  • 并发;当多个计算在重叠的时间段内执行时。
  • 并行;当多个计算或单个计算的多个部分同时运行时。
  • 异步;当一个或多个计算可以与主程序流分开执行时。

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

以“异步”(asynchronous) 一词的词源为例,它涉及两个部分:

  • “a”,意思是“不”。
  • “synchronous”,意思是“同时”。

将这两个术语组合在一起后,你会发现“异步”的意思是“不同时”。 就这么简单! 这个定义并未隐含并发或并行。 在实践中也是如此。

实际上,F# 中的异步计算计划为独立于主程序流执行。 这种独立执行并不意味着并发或并行,也不意味着计算总是在后台进行。 事实上,异步计算甚至可以同步执行,这取决于计算的性质和计算的执行环境。

你应该了解的要点在于异步计算独立于主程序流。 虽然对异步计算的执行时间或方式没有什么保证,但有一些协调和计划异步计算的方法。 本文的其余部分将探讨 F# 异步的核心概念,以及如何使用 F# 内置的类型、函数和表达式。

核心概念

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

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

一般情况下,如果正在与使用任务的 .NET 库交互,并且不依赖于异步代码尾调用或隐式取消令牌传播,则应考虑在新代码中使用 task {…} 而不是 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. 使用 Seq.map 将命令行参数转换为一系列 Async<unit> 计算。
  2. 创建一个在运行时计划和并行运行 printTotalFileBytes 计算的 Async<'T[]>
  3. 创建一个将运行并行计算并忽略其结果(即 unit[])的 Async<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 通常显示异常的方式不同。

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.Start 或其他需要 Async<unit> 的函数而必须使用 Async.Ignore,请考虑是否可以放弃结果。 避免仅仅为了符合类型签名而放弃结果。

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 启动的计算引发的异常不会传播给调用方。 调用堆栈将完全展开。
  • 使用 Async.Start 启动的任何工作(例如调用 printfn)都不会导致在程序执行的主线程上产生效果。

与 .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(即,不返回值的 .NET 异步计算)的 API 协同工作,你可能需要添加一个额外的函数,将 Async<'T> 转换为 Task

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

已有一个接受 Task 作为输入的 Async.AwaitTask。 借助此函数和之前定义的 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# 提供了一些在当前线程上(或明确地不在当前线程上)启动异步计算的功能,但异步通常与特定的线程策略无关。

另请参阅