C#async / await如何与更一般的结构相关,例如F#工作流程还是单子?

时间:2013-03-25 09:52:58

标签: c# f# monads async-await

C#语言设计始终(历史上)一直致力于解决特定问题,而不是寻找解决潜在的一般问题:例如,参见“{nnrable vs. coroutines”的http://blogs.msdn.com/b/ericlippert/archive/2009/07/09/iterator-blocks-part-one.aspx

  

我们本可以使它更加通用。我们的迭代器块可以看作是一种弱的协程。我们本可以选择实现完整的协同程序,并且只是使迭代器块成为协程的特例。当然,协同程序反过来不如一流的延续;我们可以实现continuation,在continuation方面实现协程,并在协程方面实现迭代器。

http://blogs.msdn.com/b/wesdyer/archive/2008/01/11/the-marvels-of-monads.aspx将SelectMany作为(某种)Monads的代理人:

  

C#类型系统不够强大,无法为monad创建通用抽象,这是创建扩展方法和“查询模式”的主要动力

我不想问为什么会这样(已经给出了许多好的答案,特别是在Eric的博客中,这可能适用于所有这些设计决策:从性能到复杂性的增加,无论是编译器还是程序员)

我想要了解的是async / await关键字所涉及的“一般构造”(我最好的猜测是延续monad - 毕竟,F#async是使用工作流实现的,据我所知,这是一个延续monad ),以及它们如何与它相关(它们如何不同?,缺少什么?,为什么存在差距,如果有的话?)

我正在寻找类似于我链接的Eric Lippert文章的答案,但与async / await相关而不是IEnumerable / yield。

  

修改:除了很棒的答案外,还有一些有用的链接指向相关问题和建议的博客文章,我正在编辑我的问题以列出它们:

     

2 个答案:

答案 0 :(得分:38)

C#中的异步编程模型与F#中的异步工作流非常相似,它是一般 monad 模式的实例。实际上,C#迭代器语法也是这种模式的一个实例,虽然它需要一些额外的结构,所以它不仅仅是 simple monad。

解释这个问题远远超出了单个SO答案的范围,但让我解释一下关键思想。

Monadic操作。 C#异步基本上由两个基本操作组成。你可以await进行异步计算,你可以return来自异步计算的结果(在第一种情况下,这是使用新关键字完成的,而在第二种情况下,我们重新使用已经在语言中的关键字。)

如果您遵循一般模式( monad ),那么您将异步代码转换为对以下两个操作的调用:

Task<R> Bind<T, R>(Task<T> computation, Func<T, Task<R>> continuation);
Task<T> Return<T>(T value);

使用标准任务API可以很容易地实现它们 - 第一个基本上是ContinueWithUnwrap的组合,第二个只是创建一个立即返回值的任务。我将使用上述两个操作,因为它们更能捕捉到这个想法。

翻译。关键是将异步代码转换为使用上述操作的普通代码。

让我们看一下我们使用表达式e然后将结果赋值给变量x并计算表达式(或语句块)body的情况(在C#中,你可以等待内部表达式,但您总是可以将其转换为首先将结果分配给变量的代码:

[| var x = await e; body |] 
   = Bind(e, x => [| body |])

我正在使用在编程语言中很常见的符号。 [| e |] = (...)的含义是我们将表达式e(在“语义括号中”)翻译成其他表达式(...)

在上面的例子中,当你有一个await e的表达式时,它被转换为Bind操作,并且正文(等待之后的其余代码)被推送到lambda函数中作为第二个参数传递给Bind

这是有趣的事情发生的地方! Bind操作可以运行异步操作(由类型e表示,而不是立即评估其余代码 (或在等待时阻塞线程) {1}}),当操作完成时,它最终可以调用lambda函数(continuation)来运行身体的其余部分。

转换的想法是它将普通代码转换为返回某个类型Task<T>的任务,该任务以异步方式返回值 - 即R。在上面的等式中,Task<R>的返回类型确实是一项任务。这也是我们需要翻译Bind

的原因
return

这很简单 - 当你有一个结果值而你想要返回它时,你只需将它包装在一个立即完成的任务中。这可能听起来毫无用处,但请记住,我们需要返回[| return e |] = Return(e) ,因为Task操作(以及我们的整个翻译)需要这样做。

更大的例子。如果你看一个包含多个Bind s的更大的例子:

await

代码将被翻译成类似的东西:

var x = await AsyncOperation();
return await x.AnotherAsyncOperation();

关键技巧是每个Bind(AsyncOperation(), x => Bind(x.AnotherAsyncOperation(), temp => Return(temp)); 将其余代码转换为延续(意味着可以在异步操作完成时对其进行评估)。

Continuation monad。在C#中,使用上述转换实际上并未实现异步机制。原因是如果你只关注异步,你可以进行更高效的编译(这就是C#所做的)并直接生成状态机。但是,上面几乎是异步工作流在F#中的工作方式。这也是F#中额外灵活性的来源 - 您可以定义自己的BindBind来表示其他内容 - 例如处理序列的操作,跟踪日志记录,创建可恢复的计算甚至组合异步带序列的计算(异步序列可以产生多个结果,但也可以等待)。

F#实现基于 continuation monad ,这意味着F#中的Return(实际上,Task<T>)大致定义如下:

Async<T>

也就是说,异步计算是一些动作。当你将Async<T> = Action<Action<T>> (一个延续)作为参数给它时,它将开始做一些工作,然后,当它最终完成时,它会调用你指定的这个动作。如果你搜索continuation monads,那么我相信你可以在C#和F#中找到更好的解释,所以我会在这里停止......

答案 1 :(得分:32)

托马斯的回答非常好。添加更多内容:

  

C#语言设计一直(历史上)一直致力于解决具体问题,而不是寻找解决潜在的一般问题

虽然有某些的真相,但我认为这不是一个完全公平或准确的描述,所以我将通过否认你的问题的前提来开始我的答案。

确实存在频谱在一端具有“非常具体”而在另一端具有“非常一般”,并且特定问题的解决方案落在该频谱上。 C#是一个整体设计,是解决许多特定问题的高度通用解决方案;这就是通用编程语言。您可以使用C#编写从Web服务到XBOX 360游戏的所有内容。

由于C#被设计为通用编程语言,当设计团队识别出特定的用户问题时,他们总是考虑更一般的情况。 LINQ就是一个很好的例子。在LINQ设计的早期阶段,它只不过是一种将SQL语句放入C#程序的方法,因为这是确定的问题空间。但在设计过程中很快,团队意识到排序,过滤,分组和连接数据的概念不仅适用于关系数据库中的表格数据,还适用于XML中的分层数据,以及内存中的特殊对象。因此,他们决定采用我们今天提供的更为通用的解决方案。

设计的诀窍是弄清楚在光谱上停下来的意义。设计团队本来可以说,查询理解问题实际上只是绑定monad更一般问题的一个特例。绑定monad问题实际上只是在更高类型的类型上定义操作的更一般问题的特定情况。当然,对类型系统有一些抽象......足够了。当我们解决bind-an-arbitrary-monad问题时,解决方案现在如此普遍,以至于首先成为该功能动机的业务线程序员完全丢失了,我们避开了实际上解决了他们的问题。

自C#1.0以来添加的真正主要功能 - 泛型类型,匿名函数,迭代器块,LINQ,动态,异步 - 都具有以下特性:它们是在许多不同域中有用的高度通用功能。它们都可以被视为更一般问题的具体示例,但对于任何问题的任何解决方案都是如此。你可以随时使它变得更加通用。设计这些功能的想法是找到不能让用户感到更加一般的点

现在我已经否定了你的问题的前提,让我们看看实际的问题:

  

我想要了解的是async / await关键字与

相关的“常规构造”

这取决于你如何看待它。

async-await功能是围绕Task<T>类型构建的,正如您所说,这是一个monad。当然,如果你和Erik Meijer谈到这一点,他会立即指出Task<T>实际上是 comonad ;你可以从另一端获得T值。

查看该功能的另一种方法是使用您引用的有关迭代器块的段落,并将“async”替换为“iterator”。异步方法与迭代器方法一样,是一种协同程序。如果您愿意,可以将Task<T>视为协程机制的实现细节。

查看该功能的第三种方式是说它是一种带电流延续的呼叫(通常缩写为call / cc)。它不是call / cc的完整实现,因为它在注册延续时没有采用调用堆栈的状态。有关详细信息,请参阅此问题:

How could the new async feature in c# 5.0 be implemented with call/cc?

  

我会等着看是否有人(Eric?Jon?也许你?)可以填写更多有关C#实际生成代码以实现等待的细节,

重写基本上只是重写迭代器块的变化。 Mads在他的MSDN杂志文章中详细介绍了所有细节:

http://msdn.microsoft.com/en-us/magazine/hh456403.aspx