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
函数还有更多元素。 从概念上讲,它执行以下操作:
- 使用
Seq.map
将命令行参数转换为一系列Async<unit>
计算。 - 创建一个在运行时计划和并行运行
printTotalFileBytes
计算的Async<'T[]>
。 - 创建一个将运行并行计算并忽略其结果(即
unit[]
)的Async<unit>
。 - 使用
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()
会等待任务完成。
与多线程的关系
尽管整篇文章都提到了线程,但有两件重要的事情需要记住:
- 除非在当前线程上显式启动,否则异步计算和线程之间没有任何相关性。
- F# 中的异步编程不是多线程的抽象。
例如,计算实际上可能在其调用方的线程上运行,这取决于工作的性质。 计算也可以在线程之间“跳转”,在“等待”期间(例如传输网络调用时)临时借用它们执行有用的工作。
尽管 F# 提供了一些在当前线程上(或明确地不在当前线程上)启动异步计算的功能,但异步通常与特定的线程策略无关。