SuperAPI AI 工具集

fsharp-async-composition

编程与开发

查看项目

更新于 10-16 06:57

技能介绍

用途

该技能用于指导 F# 中 Task、Async 和 ValueTask 代码的组合,确保语义正确、构建器选择恰当。它针对的是一类“能编译但行为错误”的缺陷:取消信号没有到达实际工作、Async 从未启动、ValueTask 被读取两次、异常类型在传递中改变。核心原则是:根据边界类型选择构建器,在边界处只转换一次,并显式传递取消令牌。

主要能力

构建器选择

根据返回类型和场景选择构建器:

  • 向 .NET 调用方(ASP.NET、接口、库)返回 Task 或 Task<'T> 时,使用 task { }。
  • 接口或热路径返回 ValueTask<'T> 时,使用 IcedTasks 的 valueTask { },或用 task { } 后在边界处转换为 ValueTask<'T>(t)。
  • 工作需要可重跑或重试,或令牌需隐式流动时,使用 async { }。
  • 基于 Task 的代码需要向 Async 和 CancellableTask 值传递令牌时,使用 IcedTasks 的 cancellableTask { }。
  • 返回 Task<Result<,>> 时,使用 FsToolkit.ErrorHandling 的 taskResult { }。

FSharp.Core 提供 task 和 async,但没有 valueTask 构建器。若代码未打开 IcedTasks,valueTask { } 会报 FS0039。ValueTask<'T> 与 Task<'T> 之间、或 Task<'T> 与 Async<'T> 之间出现 FS0193,说明构建器与声明的返回类型不匹配,应更换构建器或只转换一次,不要在每处调用点都加转换。

热任务与冷任务

task 和 valueTask 是热的:调用函数时,函数体会运行到第一个未完成的 await。Task 值是一个正在运行的操作,不是一份配方。要重试,应接收 unit -> Task<'T>,而不是 Task<'T>。

async 是冷的:在 Async.Start、Async.StartAsTask、Async.RunSynchronously 或 let! 之前不会运行。对 Async 执行 work |> ignore 会丢弃工作,且编译器不会警告。

Async.AwaitTask(api.Get x) 会在构建 Async 值时调用 api.Get,而不是在运行时调用。重试该值只会再次等待同一个失败的任务。应在函数体内构建任务:async { return! Async.AwaitTask(api.Get x) }。

即发即弃:对 Async 使用 Async.Start(work, ct),对热 Task 使用 work () |> ignore。应在工作内部放置异常处理器。Async.Start 的工作中未处理的异常会在线程池线程上重新抛出并终止进程。绝不要忽略 Async 值。

边界处的转换

  • Async<'T> 转 Task<'T>:使用 Async.StartAsTask(work, cancellationToken = ct),始终传递令牌。
  • Async<'T> 在调用方线程上转 Task<'T>:使用 Async.StartImmediateAsTask(work, ct),同样传递令牌。
  • Task<'T> 转 Async<'T>:使用 Async.AwaitTask t,故障以 AggregateException 形式到达。
  • ValueTask<'T> 转 Task<'T>:使用 vt.AsTask() 一次,之后只 await 该 Task。
  • Task<'T> 转 ValueTask<'T>:使用 ValueTask<'T>(t)。
  • 在 task 内使用 Async<'T>:let! x = work 运行在 Async.DefaultCancellationToken 上,而非调用方的令牌。

不要阻塞:.Result、.Wait()、GetAwaiter().GetResult() 和 Async.RunSynchronously 会占用线程,在调用处抛出而非在返回的任务中抛出,且 .Result 和 .Wait() 会把异常包装为 AggregateException。在 F# 中不要使用 ContinueWith(...).Unwrap(),直接用 task { let! x = t ... } 组合。

取消

函数体未传递的 CancellationToken 参数即为缺陷,即使测试仍然通过。

task { let! x = someAsync } 会忽略函数的 ct。应使用 Async.StartAsTask(work, cancellationToken = ct) 启动 Async,或将函数体写成 async 并在边界处转换,或使用 cancellableTask 并在其中绑定 Async。

向每个接受令牌的 .NET 调用传递 ct:Task.Delay(ms, ct)、stream.ReadAsync(buffer, ct)。

在 async 内部,Async.CancellationToken 读取令牌,Async.Sleep 和 let! 会检查它。

Async.StartChild 在另一个子任务失败时不会取消同级任务,Async.Parallel 会。同级任务只有在它的 .NET 调用收到来自 Async.CancellationToken 的令牌时才会停止。子任务中的 CancellationToken.None 意味着失败无法停止它。

示例:

let handle (lib: Library) (id: int) (ct: CancellationToken) : Task<Report> =
    let work =
        async {
            let! report = buildReport lib id
            let audit =
                async {
                    try
                        do! lib.Audit $"report {id} served"
                    with ex ->
                        eprintfn "audit failed: %s" ex.Message
                }
            Async.Start(audit, ct)
            return report
        }
    Async.StartAsTask(work, cancellationToken = ct)

异常

task { } 内部的异常会使返回的任务出错。task { } 之前的代码会在调用处抛出。应将验证放在构建器内部,让调用方看到单一失败路径。

let! 和 GetAwaiter().GetResult() 给出原始异常。.Result、.Wait() 和 Async.AwaitTask 给出 AggregateException。在 async 内部,围绕 Async.AwaitTask 的 try ... with 也会看到 AggregateException,因此 with :? MyException 和 F# 异常模式无法匹配。应在边界处解包一层:匹配 :? AggregateException as a when a.InnerExceptions.Count = 1 并 raise a.InnerExceptions.[0]。Task.WhenAll 会以所有内部异常出错,而 let! 只重新抛出第一个。

取消表现为 OperationCanceledException 或 TaskCanceledException。不要用通用的 with ex -> 捕获后记录并继续。

ValueTask

ValueTask<'T> 只能消费一次。池化源(如套接字)在读取结果时会回收其存储,第二次读取会抛出 InvalidOperationException。Task 源不会抛出,因此该缺陷只在源被池化时出现。

不要对同一个 ValueTask 执行两次 let!。不要在 let! 之后读取 .Result。

不要把 vt.AsTask() 传给 Task.WhenAny 后再读取 vt.Result。保留 Task:let t = vt.AsTask(),然后按需多次 await t。

不要把 ValueTask 存入字段或列表供以后使用,应先用 AsTask() 转换。

示例:

let read = source.ReadAsync().AsTask()
let! winner = Task.WhenAny(read, Task.Delay timeoutMs)
if obj.ReferenceEquals(winner, read) then
    let! frame = read
    return Some frame
else
    return None

状态机诊断

FS3511:编译器无法静态编译 task 状态机,转而使用较慢的动态状态机。构建器内的 let rec 是原因之一,应将 let rec 移到构建器外。其他原因包括持有可恢复代码的非内联 lambda 以及某些泛型约束情况。热路径上的 FS3511 应视为缺陷。

FS3501 及相关的可恢复代码错误来自自定义构建器。接受或返回可恢复代码的构建器方法必须是 inline 的。在编写自己的构建器之前,优先使用 IcedTasks 或 FsToolkit 中的现有构建器。

使用方式

该技能以文档和可运行验证脚本的形式提供。要验证技能中的事实,可运行捆绑的证明脚本:

pwsh ./scripts/run.ps1

运行器会执行 scripts/Proofs.fsx 并以 Release 模式构建 scripts/StateMachine。当某个事实不同,或 FS3511 未仅在 let rec 情况下报告时,它会失败。每条 PASS 行打印一个事实及其观察值。IcedTasks 版本在脚本中固定。

适用场景

  • 编写或审查涉及 Task、Async、ValueTask 边界的 F# 代码。
  • 排查取消信号未生效、Async 未启动、ValueTask 重复读取、异常类型改变等问题。
  • 在热路径上优化状态机编译(FS3511)。
  • 为接受 CancellationToken 的函数编写测试。

限制

  • 该技能不提供 FSharp.Core 之外的 valueTask 构建器,需依赖 IcedTasks。
  • 部分诊断(FS3511、FS3501)与编译器行为和自定义构建器相关,需结合具体代码判断。
  • 验证脚本依赖 PowerShell 和固定的 IcedTasks 版本。
  • 关于收费、价格、平台支持等具体信息,资料中暂未提供。