C# 中的异步编程模型与 F# 中的异步工作流非常相似,后者是通用monad模式的一个实例。事实上,C# 迭代器语法也是这种模式的一个实例,虽然它需要一些额外的结构,所以它不仅仅是简单的monad。
解释这一点远远超出了单个 SO 答案的范围,但让我解释一下关键思想。
一元操作。
C# async 本质上由两个原始操作组成。您可以await
进行异步计算,也可以return
从异步计算中获得结果(在第一种情况下,这是使用 new 关键字完成的,而在第二种情况下,我们正在重新使用该语言中已经存在的关键字)。
如果您遵循一般模式(monad),那么您会将异步代码转换为对以下两个操作的调用:
Task<R> Bind<T, R>(Task<T> computation, Func<T, Task<R>> continuation);
Task<T> Return<T>(T value);
它们都可以很容易地使用标准任务 API 实现——第一个本质上是ContinueWith
和的组合,Unwrap
第二个只是创建一个立即返回值的任务。我将使用上述两个操作,因为它们更好地抓住了这个想法。
翻译。关键是将异步代码转换为使用上述操作的普通代码。
让我们看一个例子,当我们等待一个表达式e
,然后将结果分配给一个变量x
并评估表达式(或语句块)body
(在 C# 中,您可以在表达式内部等待,但您总是可以将其转换为首先将结果分配给的代码一个变量):
[| var x = await e; body |]
= Bind(e, x => [| body |])
我使用的符号在编程语言中很常见。的意思[| e |] = (...)
是我们将表达式e
(在“语义括号”中)翻译成其他表达式(...)
。
在上述情况下,当您有一个带有 的表达式时await e
,它会被转换为Bind
操作,并且主体(await 之后的其余代码)被推入一个 lambda 函数,该函数作为第二个参数传递给Bind
.
这就是有趣的事情发生的地方!该操作可以运行异步操作(由其类型表示),而不是立即评估其余代码(或在等待时阻塞线程),并且当操作完成时,它最终可以调用 lambda 函数(继续)运行身体的其余部分。Bind
e
Task<T>
翻译的想法是,它将返回某种类型的普通代码转换为R
异步返回值的任务——即Task<R>
. 在上面的等式中,返回类型Bind
确实是一个任务。这也是我们需要翻译的原因return
:
[| return e |]
= Return(e)
这很简单——当你有一个结果值并且你想返回它时,你只需将它包装在一个立即完成的任务中。这听起来可能没用,但请记住,我们需要返回 aTask
因为Bind
操作(以及我们的整个翻译)需要它。
更大的例子。如果您查看包含多个await
s 的更大示例:
var x = await AsyncOperation();
return await x.AnotherAsyncOperation();
代码将被翻译成这样的:
Bind(AsyncOperation(), x =>
Bind(x.AnotherAsyncOperation(), temp =>
Return(temp));
关键技巧是 everyBind
将其余代码变成一个延续(这意味着它可以在异步操作完成时进行评估)。
继续单子。在 C# 中,异步机制实际上并没有使用上述翻译实现。原因是如果只关注异步,可以进行更高效的编译(C# 就是这样做的)并直接生成状态机。但是,以上几乎就是异步工作流在 F# 中的工作方式。这也是 F# 中额外灵活性的来源——您可以定义自己的内容Bind
并Return
表示其他内容——例如处理序列的操作、跟踪日志记录、创建可恢复的计算,甚至将异步计算与序列结合起来(异步序列可以产生多个结果, 但也可以等待)。
F# 实现基于continuation monad,这意味着Task<T>
(实际上,Async<T>
)在 F# 中的定义大致如下:
Async<T> = Action<Action<T>>
也就是说,异步计算是一些动作。当你给它Action<T>
(一个延续)作为参数时,它会开始做一些工作,然后,当它最终完成时,它会调用你指定的这个动作。如果您搜索 continuation monads,那么我相信您可以在 C# 和 F# 中找到更好的解释,所以我会在这里停下来......