Fork me on GitHub
用.NET MVC实现长轮询,与jQuery.AJAX即时双向通信

用.NET MVC实现长轮询,与jQuery.AJAX即时双向通信

两周前用长轮询做了一个Chat,并移植到了Azure,还写了篇博客http://www.cnblogs.com/indream/p/3187540.html,让大家帮忙测试。

首先感谢300位注册用户,让我有充足的数据进行重构和优化。所以这两周都在进行大重构。

其中最大的一个问题就是数据流量过大,原先已有更新,还会有Web传统“刷新”的形式把数据重新拿一次,然后再替换掉本地数据。

但这一拿问题就来了,在10个Chat*300个用户的情况下,这一拿产生了一次8M多的流量,这是十分严重的事情,特别是其中绝大部分数据都是浪费掉了的。

那么解决方案就很简单了,把“全量”改成“增量”,只传输修改的部分,同时大量增加往返次数,把每次往返量压缩。

 

当然,这篇文章主要讲长轮询,也是之后被问得比较多的方面,所以就单独写篇文章出来了。

这次比单纯的轮询多了一个缓存行为,以解决每次“心跳”中所产生的断线间隔数据丢失的问题。

 

首先列举一下所使用到的技术点:

  • jQuery.Ajax
  • .NET同步(lock)与异步(async await Task)
  • MVC异步页面

 

长轮询的简介

长轮询是一种类似于JSONP一样畸形的Web通信技术,用以实现Web与服务端之间的实时双向通信。

在有人实现JSONP之前,单纯的JS或者说Web是无法实现原生地有效地实现跨域通信的;而在有了JSONP之后,这项工作就变得简单了,虽然实现方法很“畸形(或者说有创意吧)”。

同样,在有长轮询之前,还没出现HTML5 Web Socket的时代,单纯的Web无法与服务器进行实时通信,HTTP限制了通信行为只能是有客户端发起请求,然后服务端针对该请求进行回应。

长轮询所做的就是把原有的协议“漏洞”利用起来,使得客户端和服务端之间在HTML 4.1(部分更低版本应该也可以兼容)下可以实时通信。

 

长轮询的原理

HTTP协议本身有两个“漏洞”,也是现在网络通信中无法避免的。

一个是请求(Request)和答复(Response)之间无法确认其连接状况,可就无法确定其所用的时限了。

判断客户端与服务端是否相连的一个标准就是客户端的请求是否能收到服务端的答复,如果收得到,就说明连接上了,即时收到的是服务端错误的通知(比如404 not found)。

第二漏洞就是在获取到答复(Response)前,都无法知道所需要的数据内容是怎么样的(如果有还跟人家要啥)。

长轮询就是利用了这两个“漏洞”:服务端收到请求(Request)后,将该请求Hold住不马上答复,而是一直等,等到服务端有信息需要发送给客户端的时候,通过将刚才Hold住的那条请求(Request)的答复(Response)发回给客户端,让客户端作出反应。而返回的内容,呵呵呵呵呵,那就随便服务端了。

然后,客户端收到答复(Response)后,马上再重新发送一次请求(Request)给服务端,让服务端再Hold住这条连接。周而复始,就实现了从服务端向客户端发送消息的实时通信,客户端向服务端发送消息则依旧利用传统的Post和Get进行。

受Web通信现实情况限制,如果服务端长时间没有消息需要推送到客户端的时候,也不能一直Hold住那条链接,因为很有可能被判定为网关超时等超时情况。所以即使没有消息,每间隔一段时间,服务端也要返回一个答复(Response),让客户端重新请求一个链接。

见过一些人喜欢把每次轮询的断开到下次轮询开始客户端的接收->再请求的行为称之为一次“心跳(Beat)”,也挺贴切的。

要实现真正的实时通信,长轮询的实现并不那么简单,因为每次“心跳”时会产生一个小间隙,这个间隙的时候服务端已经将上一个答复(Response)返回,但还没有接收到客户端的下一次请求(Request)。那么这时候,服务端如果有最新消息,就无法推送给客户端了,所以需要将这些消息缓存起来,等到下一次机会到来的时候再XXOO。

 

jQuery.AJAX

如果是AJAX的话,一般都是用jQuery进行实现。况且,毕竟还用了JSONP,手动写起来在工作中实在不划算。

到了Web端的代码,就变得很容易了,以下内容直接从项目中节选,只是作了一些山间

 JS部分代码


 

.NET MVC中的异步

一开始我花了比较长时间寻找服务端Hold住请求的方法。

普通情况下,一个Web的请求是同步执行的,如果需要转成异步的话,需要对线程进行操作。比如一开始我最白痴的想法是用自旋锁,或者用Thread相关的方法,然后在需要的时候采用一些Interup方法进行中断等等,都不容易写。

后来发现MVC中提供了比较合理的一种原生的异步页面方式,可以简单地实现同步转异步。

首先是Controller要由默认的Controller改为继承自AsyncController。该基类有一个私有成员AsyncManager,利用该对象可以简单地将同步转换成异步。

而原本有的方法,要拆分成两个方法来写,分别在两个方法用原名加上Async和Completed。

比如我的ListenController,里面有一个User方法,用以监听用户的数据。经过实现之后,就变成了ListenController : AsyncController,同时拥有一对User方法:UserAsync和UserCompleted。

那么,在页面请求Listen/User的时候,就会自动调用名称匹配的UserAsync方法。

在这之后,我们就需要利用AsyncManager执行以下语句,将线程“挂起”(Hold住,这样懂了吧):

asyncManager.OutstandingOperations.Increment();

直到我们有消息需要发送给用户的时候,通过以下方式对UserCompleted进行传参:

asyncManager.Parameters["listeningCode"] = Code;

然后再触发UserCompleted:

asyncManager.OutstandingOperations.Decrement();

再整体地看一次,ListenController就是长这个样子的:

 ListenController

CometManager就是我用来处理轮询的对象。

注意到在UserCompleted是通过了一个ICometManager.TakeAllUserCallbacks来获取用户的所有回调数据,而不是直接通过AsyncManager.Parameters发送。原因是实现过程中我发现无法通过AsyncManager.Parameters将自定义对象传参,所以采取了这种方式。或许,实现序列化后或者引用相关序列化方法,能实现如此传参。

在CometManager : ICometManager中,相关实现如此:

 CometManager节选

userListenerQuery是一个单例(Singleton)的监听队列;而UserListenManager是往上一层的监听管理对象,毕竟Chat本身不单止支持轮询,还需要支持其他通信方式,所以往上有一个公共层管理着所有消息。

 

.NET中的异步

除了MVC本身提供的特有方法外,还需要一些传统的行为才能实现完整的长轮询。

接着上面,参照ListenQuery的实现:

 ListenerQuery

这里用了一个字典来记录每个ListeningCode以及相关的Listener。

注意Add方法内有一个Timer。就像注释上所说的,定期检查用户是否在监听。我在这里设置了每30秒有一次“心跳”(Beat),而每次监听后的第60秒会来检查45秒内(暂时这么设置的,有待时间考验是不是个合适值)用户是否再来监听,如果没有则停止监听。

这么做的原因是防止客户端单方面离婚毁约,然后服务端的Comet傻傻地在这里痴情地帮客户端继续保留缓存消息。这种情况时有出现,比如客户端还没等到答复(Response)就私奔关掉了页面,留下服务单在那边Hold住连接傻傻地等待。

注意凡是处理队列类的地方都有锁,以防止并发问题。

那么最后,CometListener的实现就如下:

 CometListener


 

 

 

总结

两周前单次通信的往返大约在200ms~300ms之间,这次重构后,将Chat内核中大量同步行为改成了异步并发,已经将单次通信往返压缩在了30ms~50ms之间。当然最希望是能压缩在10ms~20ms,那样就可以用长轮询进行高同步性的游戏应用了,比如射击、即时战略。但是,到时候就没那么简单了吧,毕竟心跳(Beat)的时候是会有两次往返,也就是必须将单次往返压缩在10ms以内才有可能实现,页面的数据支撑也是个问题,需要大量套用字页面来存放数据,Balabalabalabala.......

和JSONP一样,长轮询是一个畸形的技术,也更加是开发人员在备受显示情况限制下智慧的结晶。当然,从通信上来讲,它不是一项“优秀”的技术或者协议,它浪费了太多“不必要”的资源在不必要的事情上了。就像期待IE6今早从市场上消失一样,我也期待大家普遍早日统一用上诸如Web Socket一般更好的通信技术。但现时来说,我们不得不以类似于长轮训、Hack的一些方式向底端的用户妥协,毕竟用户才是产品的最终使用者。

最后,再次感谢各位当时在Chat贡献的测试数据,特别感谢诸位在上面约架(pao)、求关(zhong)注(子)和发#ffd800网地址的几位同胞。Azure账号已经到期,所以已经上不去了。大家对数据感兴趣吗?(呵呵呵呵呵呵呵呵呵呵~)

异步编程和CPS变换

关于这个话题,其实在(六)里面已经讨论了一半了。学过Haskell的都知道,这个世界上很多东西都可以用monad和comonad来把一些复杂的代码给抽象成简单的、一看就懂的形式。他们的区别,就像用js做一个复杂的带着几层循环的动画,直接写出来和用jquery的“回调”写出来的代码一样。前者能看不能用,后者能用不能看。那有没有什么又能用又能看的呢?我目前只能在Haskell、C#和F#里面看到。至于说为什么,当然是因为他们都支持了monad和comonad。只不过C#作为一门不把“用库来改造语言”作为重要特征的语言,并没打算让你们能跟haskell和F#一样,把东西抽象成monad,然后轻松的写出来。C#只内置了yield return和async await这样的东西。

把“用库来改造语言”作为重要特征的语言其实也不多,大家熟悉的也就只有lisp和C++,不熟悉的有F#。F#除了computation expression以外,还有一个type provider的功能。就是你可以在你的当前的程序里面,写一小段代码,通知编译器在编译你的代码的时候执行以下(有点类似鸡生蛋的问题但其实不是)。这段代码可以生成新的代码(而不是跟lisp一样修改已有的代码),然后给你剩下的那部分程序使用。例子我就不举了,有兴趣的大家看这里:http://msdn.microsoft.com/en-us/library/vstudio/hh361034.aspx。里面有一个例子讲的是如何在F#里面创造一个强类型的正则表达式库,而且并不像boost的spirit或者xpress那样,正则表达式仍然使用字符串来写的。这个正则表达式在编译的时候就可以知道你有没有弄错东西了,不需要等到运行才知道。

Haskell和F#分别尝试了monad/comonad和computation expression,为的就是能用一种不会失控(lisp的macro就属于会失控的那种)方法来让用户自己表达属于自己的可以天然被continuation passing style变换处理的东西。在介绍C#的async await的强大能力之前,先来讲一下Haskell和F#的做法。为什么按照这个程序呢,因为Haskell的monad表达能力最低,其次是F#,最后是C#的那个。当然C#并不打算让你自己写一个支持CPS变换的类型。作为补充,我将在这篇文章的最后,讲一下我最近正在设计的一门语言,是如何把C#的yield return和async await都变成库,而不是编译器的功能的

下面我将抛弃所有跟学术有关的内容,只会留下跟实际开发有关系的东西。

一、Haskell和Monad

Haskell面临的问题其实比较简单,第一是因为Haskell的程序都不能有隐式状态,第二是因为Haskell没有语句只有表达式。这意味着你所有的控制流都必须用递归或者CPS来做。从这个角度上来讲,Monad也算是CPS的一种应用了。于是我为了给大家解释一下Monad是怎么运作的,决定来炒炒冷饭,说error code的故事。这个故事已经在(七)里面讲了,但是今天用的是Haskell,别有一番异域风情。

大家用C/C++的时候都觉得处理起error code是个很烦人的事情吧。我也不知道为什么那些人放着exception不用,对error code那么喜欢,直到有一天,我听到有一个傻逼在微博上讲:“error code的意思就是我可以不理他”。我终于明白了,这个人是一个真正的傻逼。不过Haskell还是很体恤这些人的,就跟耶稣一样,凡是信他就可以的永生,傻逼也可以。可惜的是,傻逼是学不会Monad的,所以耶稣只是个传说。

由于Haskell没有“引用参数”,所以所有的结果都必须出现在返回值里面。因此,倘若要在Haskell里面做error code,就得返回一个data。data就跟C语言的union一样,区别是data是强类型的,而C的union一不小心就会傻逼了:

data Unsure a = Sure a | Error string

然后给一些必要的实现,首先是Functor:

instance Functor Unsure where
    fmap f (Sure x) = Sure (f x)
    fmap f (Error e) = Error e

剩下的就是Monad了:

instance Monad Unsure where
    return = Sure
    fail = Error
    (Sure s) >>= f = f s
    (Error e) >>= f = Error e

看起来也不多,加起来才八行,就完成了error code的声明了。当然就这么看是看不出Monad的强大威力的,所以我们还需要一个代码。譬如说,给一个数组包含了分数,然后把所有的分数都转换成“牛逼”、“一般”和“傻逼”,重新构造成一个数组。一个真正的Haskell程序员,会把这个程序分解成两半,第一半当然是一个把分数转成数字的东西:

复制代码
// Tag :: integer -> Unsure string
Tag f = 
    if f < 0 then Error "分数必须在0-100之间" else
    if f<60 then Sure "傻逼" else
    if f<90 then Sure "一般" else
    if f<=100 then Sure "牛逼" else
    Error "分数必须在0-100之间"
复制代码

后面就是一个循环了:

复制代码
// TagAll :: [integer] -> Unsure [string]

TagAll [] = []
TagAll (x:xs) = do
    first <- Tag x
    remains <- TagAll xs
    return first:remains
复制代码

TagAll是一个循环,把输入的东西每一个都用Tag过一遍。如果有一次Tag返回失败了,整个TagAll函数都会失败,然后返回错误。如果全部成功了,那么TagAll函数会返回整个处理后的数组。

当然一个循环写成了非尾递归不是一个真正的Haskell程序员会做的事情,真正的Haskell程序员会把事情做成这样(把>>=展开之后你们可能会觉得这个函数不是尾递归,但是因为Haskell是call by need的,所以实际上会成为一个尾递归的函数):

// TagAll :: [integer] -> Unsure [string]
TagAll xs = reverse $ TagAll_ xs [] where
    TagAll [] ys = Sure ys
    TagAll (x:xs) ys = do
        y <- Tag x
        TagAll xs (y:ys)

为什么代码里面一句“检查Tag函数的返回值”的代码都没有呢?这就是Haskell的Monad的表达能力的威力所在了。Monad的使用由do关键字开始,然后这个表达式可以被这么定义:

复制代码
MonadExp
    ::= "do" FragmentNotNull

FragmentNotNull
    ::= [Pattern "<-"] Expression EOL FragmentNull

FragmentNull
    ::= FragmentNotNull
    ::= ε
复制代码

意思就是说,do后面一定要有“东西”,然后这个“东西”是这么组成的: 
1、第一样要是一个a<-e这样的东西。如果你不想给返回值命名,就省略“a<-”这部分 
2、然后重复

这表达的是这样的一个意思: 
1、先做e,然后把结果保存进a 
2、然后做下面的事情

看到了没有,“然后做下面的事情”是一个典型的continuation passing style的表达方法。但是我们可以看到,在例子里面所有的e都是Unsure T类型的,而a相应的必须为T。那到底是谁做了这个转化呢?

聪明的,哦不,正常的读者一眼就能看出来,“<-”就是调用了我们之前在上面实现的一个叫做“>>=”的函数了。我们首先把“e”和“然后要做的事情”这两个参数传进了>>=,然后>>=去解读e,得到a,把a当成“然后要做的事情”的参数调用了一下。如果e解读失败的到了错误,“然后要做的事情”自然就不做了,于是整个函数就返回错误了。

Haskell一下就来尾递归还是略微复杂了点,我们来写一个简单点的例子,写一个函数判断一个人的三科成绩里面,有多少科是牛逼的:

复制代码
// Count牛逼 :: integer -> integer -> integer –> Unsure integer

Count牛逼 chinese math english = do
    a <- Tag chinese
    b <- Tag math
    c <- Tag english
    return length [x | x <- [a, b, c], x == "牛逼"]
复制代码

根据上文的描述,我们已经知道,这个函数实际上会被处理成:

复制代码
// Count牛逼 :: integer -> integer -> integer –> Unsure integer

Count牛逼 chinese math english
    Tag chinese >>= \a->
    Tag math >>= \b->
    Tag english >>= \c->
    return length [x | x <- [a, b, c], x == "牛逼"]
复制代码

>>=函数的定义是

instance Monad Unsure where
    return = Sure
    fail = Error
(Sure s) >>= f = f s
(Error e)>>= f = Error e

这是一个运行时的pattern matching。一个对参数带pattern matching的函数用Haskell的case of写出来是很难看的,所以Haskell给了这么个语法糖。但这个时候我们要把>>=函数展开在我们的“Count牛逼”函数里面,就得老老实实地用case of了:

复制代码
// Count牛逼 :: integer -> integer -> integer –> Unsure integer

Count牛逼 chinese math english
    case Tag chinese of {
        Sure a -> case Tag math of {
            Sure b -> case Tag english of {
                Sure c -> Sure $ length [x | x <- [a, b, c], x == "牛逼"]
                Error e -> Error e
            }
            Error e -> Error e
        }
        Error e -> Error e
    }
复制代码

是不是又回到了我们在C语言里面被迫做的,还有C++不喜欢用exception的人(包含一些觉得error code可以忽略的傻逼)做的,到处检查函数返回值的事情了?我觉得只要是一个正常人,都会选择这种写法的:

复制代码
// Count牛逼 :: integer -> integer -> integer –> Unsure integer

Count牛逼 chinese math english
    Tag chinese >>= \a->
    Tag math >>= \b->
    Tag english >>= \c->
    return length [x | x <- [a, b, c], x == "牛逼"]
复制代码

于是我们用Haskell的Monad,活生生的把“每次都检查函数返回值”的代码压缩到了Monad里面,然后就可以把代码写成try-catch那样的东西了。error code跟exception本来就是一样的嘛,只是一个写起来复杂所以培养了很多觉得错误可以忽略的傻逼,而一个只需要稍微训练一下就可以把代码写的很简单罢了。

不过Haskell没有变量,那些傻逼们可能会反驳:C/C++比Haskell复杂多了,你怎么知道exception就一定没问题呢?这个时候,我们就可以看F#的computation expression了。

二、F#和computation expression

F#虽然被设计成了一门函数式语言,但是其骨子里还是跟C#一样带状态的,而且编译成MSIL代码之后,可以直接让F#和C#互相调用。一个真正的Windows程序员,从来不会拘泥于让一个工程只用一个语言来写,而是不同的大模块,用其适合的最好的语言。微软把所有的东西都设计成可以强类型地互操作的,所以在Windows上面从来不存在什么“如果我用A语言写了,B就用不了”的这些事情。这是跟Linux的一个巨大的区别。Linux是没有强类型的互操作的(字符串信仰者们再见),而Windows有。什么,Windows不能用来做Server?那Windows Azure怎么做的,bing怎么做的。什么,只有微软才知道怎么正确使用Windows Server?你们喜欢玩的EVE游戏的服务器是怎么做的呢?

在这里顺便黑一下gcc。钱(区别于财产)对于一个程序员是很重要的。VC++和clang/LLVM都是领着工资写的,gcc不知道是谁投资的(这也就意味着写得好也涨不了工资)。而且我们也都知道,gcc在windows上编译的慢出来的代码还不如VC++,gcc在linux上编译的慢还不如clang,在mac/ios上就不说了,下一个版本的xcode根本没有什么gcc了。理想主义者们醒醒,gcc再见。

为什么F#有循环?答案当然是因为F#有变量了。一个没有变量的语言是写不出循环退出条件的,只能写出递归退出条件。有了循环的话,就会有各种各样的东西,那Monad这个东西就不能很好地给“东西”建模了。于是F#本着友好的精神,既然大家都那么喜欢Monad,那他做出一个computation expression,学起来肯定就很容易了。

于是在F#下面,那个TagAll终于可以读入一个真正的列表,写出一个真正的循环了:

复制代码
let TagAll xs = unsure
{
    let r = Array.create xs.length ""
    for i in 0 .. xs.length-1 do
        let! tag = Tag xs.[i]
        r.[i]<-tag
    return r
}
复制代码

注意那个let!,其实就是Haskell里面的<-。只是因为这些东西放在了循环里,那么那个“Monad”表达出来就没有Haskell的Monad那么纯粹了。为了解决这个问题,F#引入了computation expression。所以为了让那个unsure和let!起作用,就得有下面的代码,做一个名字叫做unsure的computation expression:

复制代码
type UnsureBuilder() =
    member this.Bind(m, f) = match m with
        | Sure a -> f a
        | Error s -> Error s
    member this.For(xs, body) =unsure
    {
         match xs with
        | [] -> Sure ()
        | x::xs -> 
            let! r = Tag x
            body r
            return this.For xs body
    }
    .... // 还有很多别的东西
复制代码
let unsure = new UnsureBuilder()

所以说带有副作用的语言写出来的代码又长,不带副作用的语言写出来的代码又难懂,这之间很难取得一个平衡。

如果输入的分数数组里面有一个不在0到100的范围内,那么for循环里面的“let! tag = Tag xs.[i]”这句话就会引发一个错误,导致TagAll函数失败。这是怎么做到的?

首先,Tag引发的错误是在for循环里面,也就是说,实际运行的时候是调用UnsuerBuilder类型的unsure.For函数来执行这个循环的。For函数内部使用“let! r = Tag x”,这个时候如果失败,那么let!调用的Bind函数就会返回Error s。于是unsure.Combine函数判断第一个语句失败了,那么接下来的语句“body r ; return this.For xs body”也就不执行了,直接返回错误。这个时候For函数的递归终止条件就产生作用了,由一层层的return(F#自带尾递归优化,所以那个For函数最终会被编译成一个循环)往外传递,导致最外层的For循环以Error返回值结束。TagAll里面的unsure,Combine函数看到for循环完蛋了,于是return r也不执行了,返回错误。

这个过程跟Haskell的那个版本做的事情完全是一样的,只是由于F#多了很多语句,所以Monad展开成computation expression之后,表面上看起来就会复杂很多。如果明白Haskell的Monad在干什么事情的话,F#的computation expression也是很容易就学会的。

当然,觉得“error code可以忽略”的傻逼是没有可能的。

三、C#的yield return和async await

如果大家已经明白了Haskell的>>=和F#的Bind(其实也是let!)就是一回事的话,而且也明白了我上面讲的如何把do和<-变成>>=的方法的话,大家应该对CPS在实际应用的样子心里有数了。不过,这种理解的方法实际上是相当有限的。为什么呢?让我们来看C#的两个函数:

复制代码
IEnumerable<T> Concat(this IEnumerable<T> a, IEnumerable<T> b)
{
    foreach(var x in a)
        yield return x;
    foreach(var x in b)
        yield return x;
}
复制代码

上面那个是关于yield return和IEnumerable<T>的例子,讲的是Linq的Concat函数是怎么实现的。下面还有一个async await和Task<T>的例子:

复制代码
async Task<T[]> SequencialExecute(this Task<T>[] tasks)
{
    var ts = new T[tasks.Length];
    for(int i=0;i<tasks.Length;i++)
        ts[i]=await tasks[i];
    return ts;
}
复制代码

这个函数讲的是,如果你有一堆Task<T>,如何构造出一个内容来自于异步地挨个执行tasks里面的每个Task<T>的Task<T[]>的方法。

大家可能会注意到,C#的yield return和await的“味道”,就跟Haskell的<-和>>=、F#的Bind和let!一样。在处理这种语言级别的事情的时候,千万不要去管代码它实际上在干什么,这其实是次要的。最重要的是形式。什么是形式呢?也就是说,同样一个任务,是如何被不同的方法表达出来的。上面说的“味道”就都在“表达”的这个事情上面了。

这里我就要提一个问题了。

  1. Haskell有Monad,所以我们可以给自己定义的类型实现一个Monad,从而让我们的类型可以用do和<-来操作。
  2. F#有computation expression,所以我们可以给自己定义的类型实现一个computation expression,从而让我们的类型可以用let!来操作。
  3. C#有【什么】,所以我们可以给自己定义的类型实现一个【什么】,从而让我们的类型可以用【什么】来操作?

熟悉C#的人可能很快就说出来了,答案是Linq、Linq Provider和from in了。这篇《Monadic Parser Combinator using C# 3.0》http://blogs.msdn.com/b/lukeh/archive/2007/08/19/monadic-parser-combinators-using-c-3-0.aspx 介绍了一个如何把语法分析器(也就是parser)给写成monad,并且用Linq的from in来表达的方法。

大家可能一下子不明白什么意思。Linq Provider和Monad是这么对应的:

  1. fmap对应于Select
  2. >>=对应于SelectMany
  3. >>= + return也对应与Select(回忆一下Monad这个代数结构的几个定理,就有这么一条)

然后诸如这样的Haskell代码:

复制代码
// Count牛逼 :: integer -> integer -> integer –> Unsure integer

Count牛逼 chinese math english = do
    a <- Tag chinese
    b <- Tag math
    c <- Tag english
    return length [x | x <- [a, b, c], x == "牛逼"]
复制代码

就可以表达成:

复制代码
Unsure<int> Count牛逼(int chinese, int math, int english)
{
    return
        from a in Tag(chinese)
        from b in Tag(math)
        from c in Tag(english)
        return new int[]{a, b, c}.Where(x=>x=="牛逼").Count();
}
复制代码

不过Linq的这个表达方法跟yield return和async await一比,就有一种Monad和computation expression的感觉了。Monad只能一味的递归一个一个往下写,而computation expression则还能加上分支循环异常处理什么的。C#的from in也是一样,没办法表达循环异常处理等内容。

于是上面提到的那个问题

C#有【什么】,所以我们可以给自己定义的类型实现一个【什么】,从而让我们的类型可以用【什么】来操作?

其实并没有回答完整。我们可以换一个角度来体味。假设IEnumerable<T>和Task<T>都是我们自己写的,而不是.net framework里面的内容,那么C#究竟要加上一个什么样的(类似于Linq Provider的)功能,从而让我们可以写出接近yield return和async await的效果的代码呢?如果大家对我的那篇《时隔多年我又再一次体验了一把跟大神聊天的感觉》还有点印象的话,其实我当时也对我自己提出了这么个问题。

我那个时候一直觉得,F#的computation expression才是正确的方向,但是我怎么搞都搞不出来,所以我自己就有点动摇了。于是我跑去问了Don Syme,他很斩钉截铁的告诉我说,computation expression是做不到那个事情的,但是需要怎么做他也没想过,让我自己research。后来我就得到了一个结论。

四、Koncept(我正在设计的语言)的yield return和async await(问题)

Koncept主要的特征是concept mapping和interface。这两种东西的关系就像函数和lambda表达式、instance和class一样,是定义和闭包的关系,所以相处起来特别自然。首先我让函数只能输入一个参数,不过这个参数可以是一个tuple,于是f(a, b, c)实际上是f.Invoke(Tuple.Create(a, b, c))的语法糖。然后所有的overloading都用类似C++的偏特化来做,于是C++11的不定模板参数(variadic template argument)在我这里就成为一个“推论”了,根本不是什么需要特殊支持就自然拥有的东西。这也是concept mapping的常用手法。最后一个跟普通语言巨大的变化是我删掉了class,只留下interface。反正你们写lambda表达时也不会给每个闭包命名字(没有C++11的C++除外),那为什么写interface就得给每一个闭包(class)命名字呢?所以我给删去了。剩下的就是我用类似mixin的机制可以把函数和interface什么的给mixin到普通的类型里面去,这样你也可以实现class的东西,就是写起特别来麻烦,于是我在语法上就鼓励你不要暴露class,改为全部暴露function、concept和interface。

不过这些都不是重点,因为除了这些差异以外,其他的还是有浓郁的C#精神在里面的,所以下面在讲Koncept的CPS变换的时候,我还是把它写成C#的样子,Koncept长什么样子以后我再告诉你们,因为Koncept的大部分设计都跟CPS变换是没关系的。

回归正题。之前我考虑了许久,觉得F#的computation expression又特别像是一个正确的解答,但是我怎么样都找不到一个可以把它加入Koncept地方法。这个问题我从NativeX(这里这里这里这里)的时候就一直在想了,中间兜了一个大圈,整个就是试图山寨F#结果失败的过程。为什么F#的computation expression模型不能用呢,归根结底是因为,F#的循环没有break和continue。C#的跳转是自由的,不仅有break和continue,你还可以从循环里面return,甚至goto。因此一个for循环无论如何都表达不成F#的那个函数:M<U> For(IEnumerable<T> container, Func<T, M<U>> body);。break、continue、return和goto没办法表达在类型上。

伟大的先知Eric Meijer告诉我们:“一个函数的类型表达了关于函数的业务的一切”。为什么我们还要写函数体,是因为编译器还没有聪明到看着那个类型就可以帮我们把代码填充完整。所以其实当初看着F#的computation expression的For的定义的时候,是因为我脑筋短路,没有想起Eric Meijer的这句话,导致我浪费了几个月时间。当然我到了后面也渐渐察觉到了这个事情,产生了动摇,自己却无法确定,所以去问了Don Syme。于是,我就得到了关于这个问题的结论的一半:在C#(其实Koncept也是)支持用户可以自由添加的CPS变换(譬如说用户添加IEnumerable<T>的时候添加yield return和yield break,用户添加Task<T>的时候添加await和return)的话,使用CPS变换的那段代码,必须用控制流图(control flow graph)处理完之后生成一个状态机来做,而不能跟Haskell和F#一样拆成一个一个的小lambda表达式。

其实C#的yield return和async await,从一开始就是编译成状态机的。只是C#没有开放那个功能,所以我一直以为这并不是必须的。想来微软里面做语言的那帮牛逼的人还是有牛逼的道理的,一下子就可以找到问题的正确方向,跟搞go的二流语言专家(尽管他也牛逼但是跟语言一点关系也没有)是完全不同的。连Mozilla的Rust的设计都比go强一百倍。

那另一半的问题是什么呢?为了把问题看得更加清楚,我们来看两个长得很像的yield return和async await的例子。为了把本质的问题暴露出来,我决定修改yield return的语法:

  1. 首先把yield return修改成yield
  2. 其次吧yield break修改成return
  3. 然后再给函数打上一个叫做seq的东西,跟async对称,就当他是个关键字
  4. 给所有CPS operator加上一个感叹号,让他变得更清楚(这里有yield、await和return)。为什么return也要加上感叹号呢?因为如果我们吧seq和aysnc摘掉的话,我们会发现return的类型是不匹配的。所以这不是一个真的return。

然后就可以来描述一个类似Linq的TakeWhile的事情了:

复制代码
seq IEnumerable<T> TakeWhile(this IEnumerable<T> source, Predicate<T> predicate)
{
    foreach(var x in source)
    {
        if(!predicate(x))
            return!;
        yield! x
    }
}

async Task<T[]> TakeWhile(this Task<T>[] source, Predicate<T> predicate)
{
    List<T> result=new List<T>();
    foreach(var t in source)
    {
        var x = await! t;
        if(!predicate(x))
            return! result.ToArray();
        result.Add(x);
    }
    return! result.ToArray();
}
复制代码
于是问题就很清楚了。如果我们想让用户自己通过类库的方法来实现这些东西,那么yield和await肯定是两个函数,因为这是C#里面唯一可以用来写代码的东西,就算看起来再奇怪,也不可能是别的。
  1. seq和async到底是什么?
  2. seq下面的yield和return的类型分别是什么?
  3. async下面的await和return的类型分别是什么?

其实这里还有一个谜团。其实seq返回的东西应该是一个IEnumerator<T>,只是因为C#觉得IEnumerable<T>是更好地,所以你两个都可以返回。那么,是什么机制使得,函数可以构造出一个IEnumerable<T>,而整个状态机是在IEnumerator<T>的MoveNext函数里面驱动的呢?而async和Task<T>就没有这种情况了。

首先解答第一个问题。因为yield、return和await都是函数,是函数就得有个namespace,那我们可以拿seq和async做namespace。所以seq和async,设计成两个static class也是没有问题的

其次,seq的yield和return修改了某个IEnumerator<T>的状态,而async的await和return修改了某个Task<T>的状态。而seq和async的返回值分别是IEnumerable<T>和Task<T>。因此对于一个CPS变换来说,一共需要两个类型,第一个是返回值,第二个是实际运行状态机的类。

第三,CPS变换还需要有一个启动函数。IEnumerator<T>的第一次MoveNext调用了那个启动函数。而Task<T>的Start调用了那个启动函数。启动函数自己维护着所有状态机的内容,而状态机本身是CPS operator们看不见的。为什么呢?因为一个状态机也是一个类,这些状态机类是没有任何公共的contract的,也就是说无法抽象他们。因此CPS operator必须不能知道状态机类

而且yield、return和await都叫CPS operator,那么他们不管是什么类型,本身肯定看起来像一个CPS的函数。之前已经讲过了,CPS函数就是把普通函数的返回值去掉,转而添加一个lambda表达式,用来代表“拿到返回之后的下一步计算”。

因此总的来说,我们拿到了这四个方程,就可以得出一个解了。解可以有很多,我们选择最简单的部分。

那现在就开始来解答上面两个TakeWhile最终会被编译成什么东西了。

五、Koncept(我正在设计的语言)的yield return和async await(seq答案)

首先来看seq和yield的部分。上面讲到了,yield和return都是在修改某个IEnumerator<T>的状态,但是编译器自己肯定不能知道一个合适的IEnumerator<T>是如何被创建出来的。所以这个类型必须由用户来创建。而为了第一次调用yield的时候就已经有IEnumerator<T>可以用,所以CPS的启动函数就必须看得到那个IEnumerator<T>。但是CPS的启动函数又不可能去创建他,所以,这个IEnumerator<T>对象肯定是一个continuation的参数了。

看,其实写程序都是在做推理的。尽管我们现在还不知道整个CPS要怎么运作,但是随着这些线索,我们就可以先把类型搞出来。搞出了类型之后,就可以来填代码了。

  1. 对于yield,yield接受了一个T,没有返回值。一个没有返回值的函数的continuation是什么呢?当然就是一个没有参数的函数了。
  2. return则连输入都没有。
  3. 而且yield和return都需要看到IEnumerator<T>。所以他们肯定有一个参数包含这个东西。

那么这三个函数的类型就都确定下来了:

public static class seq
{
    public static IEnumerator<T> CreateCps<T>(Action<seq_Enumerator<T>>);
    public static void yield<T>(seq_Enumerator<T> state, T value, Action continuation);
    public static void exit<T>(seq_Enumerator<T> state /*没有输入*/ /*exit代表return,函数结束的意思就是不会有一个continuation*/);
}

什么是seq_Enumerator<T>呢?当然是我们那个“某个IEnumerator<T>”的真是类型了。

于是看着类型,唯一可能的有意义又简单的实现如下:

复制代码
public class seq_Enumerable<T> : IEnumerable<T>
{
    public Action<seq_Enumerator<T>> startContinuation;

    public IEnumerator<T> CreateEnumerator()
    {
        return new seq_Enumerator<T>
        {
            startContinuation=this.startContinuation)
        };
    }
}

public class seq_Enumerator<T> : IEnumerator<T>
{
    public T current;
    bool available;
    Action<seq_Enumerator<T>> startContinuation;
    Action continuation;

    public T Current
    {
        get
        {
            return this.current;
        }
    }

    public bool MoveNext()
    {
        this.available=false;
        if(this.continuation==null)
        {
            this.startContinuation(this);
        }
        else
        {
            this.continuation();
        }
        return this.available;
    }
}

public static class seq
{
    public static IEnumerable<T> CreateCps<T>(Action<seq_Enumerator<T>> startContinuation)
    {
        return new seq_Enumerable
        {
            startContinuation=startContinuation
        };
    }

    public static void yield<T>(seq_Enumeartor<T> state, T value, Action continuation)
    {
        state.current=value;
        state.available=true;
        state.continuation=continuation;
    }

    public static void exit<T>(seq_Enumeartor<T> state)
    {
    }
}
复制代码

那么那个TakeWhile函数最终会变成:

复制代码
public class _TakeWhile<T>
{
    seq_Enumerator<T> _controller;
    Action _output_continuation_0= this.RunStateMachine;
    int _state;
    IEnumerable<T> _source;

    IEnumerator<T> _source_enumerator;
    Predicate<T> _predicate;
    T x;

    public void RunStateMachine()
    {while(true)
        {
            switch(this.state)
            {
            case 0:
                {
                    this._source_enumerator = this._source.CreateEnumerator();
                    this._state=1;
                }
                break;
            case 1:
                {
                    if(this._state_enumerator.MoveNext())
                    {
                        this.x=this._state_enumerator.Current;
                        if(this._predicate(this.x))
                        {
                            this._state=2;
                            var input=this.x;
                            seq.yield(this._controller. input, this._output_continuation_0);
                            return;
                        }
                        else
                        {
                            seq.exit(this._controller);
                        }
                    }
                    else
                    {
                        state._state=3;
                    }
                }
                break;
            case 2:
                {
                    this.state=1;
                }
                break;
            case 3:
                {
                    seq.exit(this._controller);
                }
                break;
            }
        }
    }
}
复制代码

但是TakeWhile这个函数是真实存在的,所以他也要被改写:

复制代码
IEnumerable<T> TakeWhile(this IEnumerable<T> source, Predicate<T> predicate)
{
    return seq.CreateCps(controller=>
    {
        var sm = new _Where<T>
        {
            _controller=controller,
            _source=source,
            _predicate=predicate,
        };

        sm.RunStateMachine();
    });
}
复制代码

最终生成的TakeWhile会调用哪个CreateCps函数,然后把原来的函数体经过CFG的处理之后,得到一个状态机。在状态机内所有调用CPS operator的地方(就是yield!和return!),都把“接下来的事情”当成一个参数,连同那个原本写上去的CPS operator的参数,还有controller(在这里是seq_Enumeartor<T>)一起传递过去。而return是带有特殊的寓意的,所以它调用一次exit之后,就没有“然后——也就是continuation”了。

现在回过头来看seq类型的声明

public static class seq
{
    public static IEnumerator<T> CreateCps<T>(Action<seq_Enumerator<T>>);
    public static void yield<T>(seq_Enumerator<T> state, T value, Action continuation);
    public static void exit<T>(seq_Enumerator<T> state /*没有输入*/ /*exit代表return,函数结束的意思就是不会有一个continuation*/);
}

其实想一想,CPS的自然属性决定了,基本上就只能这么定义它们的类型。而他们的类型唯一定义了一个最简单有效的函数体。再次感叹一下,写程序就跟在做推理完全是一摸一样的

六、Koncept(我正在设计的语言)的yield return和async await(async答案)

因为CPS operator都是一样的,所以在这里我给出async类型的声明,然后假设Task<T>的样子长的就跟C#的System.Tasks.Task<T>一摸一样,看看大家能不能得到async下面的几个函数的实现,以及上面那个针对Task<T>的TakeWhile函数最终会被编译成什么:

复制代码
public static class async
{
    public static Task<T> CreateCps<T>(Action<FuturePromiseTask<T>> startContinuation);
    {
        /*请自行填补*/
    }

    public static void await<T>(FuturePromiseTask<T> task, Task<T> source, Action<T> continuation);
    {
        /*请自行填补*/
    }

    public static void exit<T>(FuturePromiseTask<T> task, T source); /*在这里async的return是有参数的,所以跟seq的exit不一样*/
    {
        /*请自行填补*/
    }
}

public class FuturePromiseTask<T> : Task<T>
{
    /*请自行填补*/
}
复制代码
 
 
posted on 2013-07-29 00:04  HackerVirus  阅读(1964)  评论(0编辑  收藏  举报