任务表达式

本文介绍 F# 中对任务表达式的支持,这些表达式类似于异步表达式,但允许直接创作 .NET 任务。 与异步表达式一样,任务表达式以异步方式执行代码,即不阻止其他工作的执行。

异步代码通常使用异步表达式创作。 在与创建或使用 .NET 任务的 .NET 库进行广泛互操作时,首选任务表达式。 任务表达式还可以增强性能和调试体验。 但是,任务表达式存在一些限制,本文稍后将对此进行介绍。

语法

task { expression }

在以前的语法中,由 expression 表示的计算设置为作为 .NET 任务运行。 任务在执行此代码后立即启动,并在当前线程上运行,直到执行其第一个异步操作(例如异步睡眠、异步 I/O 或其他基元异步操作)。 表达式的类型为 Task<'T>,其中 'T 是使用 return 关键字时表达式返回的类型。

使用 let! 绑定

在任务表达式中,有些表达式和操作是同步的,有些是异步的。 等待异步操作的结果时,请使用 let!,而不是普通的 let 绑定。 let! 的作用是在计算进行的过程中,使其他计算或线程上的执行得以继续。 在 let! 绑定的右侧返回后,任务的其余部分恢复执行。

以下代码显示了 letlet! 之间的区别。 使用 let 的代码行将任务创建为对象,稍后可以使用 task.Wait()task.Result代码等待该对象。 使用 let! 的代码行将启动任务并等待其结果。

// let just stores the result as a task.
let (result1 : Task<int>) = stream.ReadAsync(buffer, offset, count, cancellationToken)
// let! completes the asynchronous operation and returns the data.
let! (result2 : int)  = stream.ReadAsync(buffer, offset, count, cancellationToken)

F# task { } 表达式可以等待以下类型的异步操作:

return 表达式

在任务表达式中,return expr 用于返回一个任务的结果。

return! 表达式

在任务表达式中,return! expr 用于返回另一个任务的结果。 它等效于使用 let!,然后立即返回结果。

控制流

任务表达式可以包含控制流构造 for .. in .. dowhile .. dotry .. with ..try .. finally ..if .. then .. elseif .. then ..。 这些构造又可能包括其他任务构造,withfinally 例外,这些构造同步执行。 如果需要异步 try .. finally ..,请结合 IAsyncDisposable 类型的对象使用 use 绑定。

useuse! 绑定

在任务表达式中,use 绑定可以绑定到 IDisposableIAsyncDisposable 类型的值。 对于后者,处置清理操作是异步执行的。

除了 let!,还可以使用 use! 执行异步绑定。 let!use! 之间的区别与 letuse 之间的区别相同。 对于 use!,在当前范围结束时释放对象。 请注意,在 F# 6 中,use! 不允许将值初始化为 null,即使 use 允许。

值任务

值任务是用于避免在基于任务的编程中分配的结构。 值任务是一个临时值,可使用 .AsTask() 转换为实际任务。

若要通过任务表达式创建值任务,请使用 |> ValueTask<ReturnType>|> ValueTask。 例如:

let makeTask() =
    task { return 1 }

makeTask() |> ValueTask<int>

添加取消标记和取消检查

与 F# 异步表达式不同,任务表达式不会隐式传递取消标记,也不隐式执行取消检查。 如果代码需要取消标记,应将取消标记指定为参数。 例如:

open System.Threading

let someTaskCode (cancellationToken: CancellationToken) =
    task {
        cancellationToken.ThrowIfCancellationRequested()
        printfn $"continuing..."
    }

若要正确地将代码设置为可取消,请仔细检查是否将取消令牌传递给支持取消的所有 .NET 库操作。 例如,Stream.ReadAsync 具有多个重载,其中一个接受取消标记。 如果不使用此重载,该特定异步读取操作将不可取消。

后台任务

默认情况下,使用 SynchronizationContext.Current(如果存在)计划 .NET 任务。 这允许任务充当在用户界面线程上执行的协作式交错代理,而不会阻止 UI。 如果不存在,任务延续将计划到 .NET 线程池。

在实践中,生成任务的库代码通常需要忽略同步上下文,并在必要时始终切换到 .NET 线程池。 可以使用 backgroundTask { } 实现:

backgroundTask { expression }

后台任务会忽略任何 SynchronizationContext.Current用意如下:如果后台任务使用非 null SynchronizationContext.Current 在线程上启动,则它会使用 Task.Run 切换到线程池中的后台线程。 如果使用 null SynchronizationContext.Current 在线程上启动,它将在同一线程上执行。

注意

实际上,这意味着 F# 任务代码中通常不需要调用 ConfigureAwait(false)。 相反,应该使用 backgroundTask { ... } 创作旨在在后台运行的任务。 后台任务的任何外部任务绑定将在后台任务完成后重新同步到 SynchronizationContext.Current

与尾调用有关的任务的限制

与 F# 异步表达式不同,任务表达式不支持尾调用。 也就是说,执行 return! 时,当前任务注册为等待返回结果的任务。 这意味着使用任务表达式实现的递归函数和方法可能会创建未绑定的任务链,这些函数和方法可能会使用未绑定的堆栈或堆。 例如,考虑以下代码:

let rec taskLoopBad (count: int) : Task<string> =
    task {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! taskLoopBad (count-1)
    }

let t = taskLoopBad 10000000
t.Wait()

此编码样式不应与任务表达式一起使用 - 它将创建包含 10000000 个任务的链,并引发 StackOverflowException。 如果在每次循环调用时添加异步操作,则代码将使用实质上没有绑定的堆。 请考虑切换此代码以使用显式循环,例如:

let taskLoopGood (count: int) : Task<string> =
    task {
        for i in count .. 1 do
            printfn $"looping... count = {count}"
        return "done!"
    }

let t = taskLoopGood 10000000
t.Wait()

如果需要异步尾调用,请使用支持尾调用的 F# 异步表达式。 例如:

let rec asyncLoopGood (count: int) =
    async {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! asyncLoopGood (count-1)
    }

let t = asyncLoopGood 1000000 |> Async.StartAsTask
t.Wait()

任务实现

任务是使用可恢复代码(F# 6 中的新功能)实现的。 F# 编译器将任务编译为“可恢复状态机”。 可恢复代码 RFC F# 编译器社区会话中详细介绍这些内容。

另请参阅