考不上三本也能给自己心爱的语言加上Coroutine(一)

考不上三本也能给自己心爱的语言加上Coroutine(一)

你现在所阅读的并不是第一篇文章,你可能想看目录和前言
我发现发给了R菊苣的专栏之后再投稿给自己就好了,真机智!
终于要开始进入正题了。昨天在前言下面就看到有人问Coroutine是什么,其实这也是正常的,虽然Coroutine很常见,但你不一定能直接用得上。像古时候Windows 3.x系列的协作式多线程,其实就是Coroutine的一种表达形式。你需要主动放弃对CPU的占用,然后CPU就可以让别人进来。所以你会发现很多古老的API都有提到这一点,譬如GetMessage函数。GetMessage在这里就变成了Coroutine的一个operator。以前不能主动中断的时候,如果API还不引诱你去调用这些函数,那整个系统就只有你一个程序可以运行了。

所以Coroutine满大街都是,核心的想法就是你写一个函数,然后函数自己决定什么时候中断自己的执行,等待别人唤醒。所以我们可以看到很多语言很直接粗暴地就这样实现Coroutine了,譬如VC++早期的__await关键字,就是通过跑了一半把堆栈(其实就是一组寄存器)换掉来实现的。既然Coroutine本来就是要让几个函数交替执行,那我直接交替执行他们不就行了嘛?你们还可以从很多脚本语言里面看到类似的东西。

这种做法有一个好处,就是Coroutine跟其他所有的feature都是正交的,你什么代码都不用改,直接在编译器上做点手脚,然后改虚拟机就好了。但是如果你不是这一系列工具程序的owner,那你就会很蛋疼。万一我hack了半天,结果人家上游下来一个改动,造成了一万个conflict怎么办?或者说,我根本就不能决定语言用的是哪个runtime怎么办?所以只有当你真的拥有这门语言的时候,你才能这样做。

既然不改虚拟机,那只能改编译器了。通常来讲还有另一种办法,就是要做全文CPS变换。当然这听起来好像不知所云,其实核心思想很简单,在我自己以前的vczh/tinymoe项目里面就实现过。这是一门把自己编译成C#的语言,语言本身暴露了continuation。所谓的continuation的意思就是说,你可以在任何地方下个“断点”,然后编译器会把“剩下的部分”包装成一个闭包(或者通俗一点叫lambda表达式)给你。如果你直接问,那包装到底是怎么做的呢?很多人可能会让你去看论文。但是如果你不在乎优雅的话,其实不看也罢,我也是做出来了之后才发现原来连这种东西也可以发论文的,真是大开眼界(逃

举个简单的例子,我有一个这样的函数:

int Fuck(IEnumerable<int> xs)
{
    int sum = 0;
    foreach(var x in xs)
    {
        SHIT!
        sum += x;
    }
    return sum;
}

现在执行到SHIT!这里,我打算做一个断点,让编译器把剩下的部分包装成一个闭包给你。那么这个闭包长啥样子呢?首先你要把foreach这个语法糖解开:

int Fuck(IEnumerable<int> xs)
{
    int sum = 0;
    var _xs = xs.CreateEnumerator();
    while (_xs.Next())
    {
        var x = xs.Current;
        SHIT!
        sum += x;
    }
    return sum;
}

那么在执行到了SHIT!之后,如果你把剩下的部分写成一段代码(注意这个函数重新执行到SHIT!之后仍然要停止),自然就变成了:

片段1:(这是第一个SHIT!前面的部分)

int sum = 0;
int x;
var _xs = xs.CreateEnumerator();
if (_xs.Next())
{
    x = xs.Current;
    /* SHIT! 向片段2 */
}
else
{
    return sum;
}

片段2(这就是剩下的部分):

sum += x;
if (_xs.Next())
{
    x = xs.Current;
    /* SHIT! 向片段2 */
}
else
{
    return sum;
}

那么你从片段1开始,每次遇到SHIT!的时候就停下来,等到恢复的时候,就执行当初SHIT!的目标。譬如说第一个片段,如果你狗屎运_xs.Next()返回false,直接return了,那也就没有什么SHIT!了。但是万一你执行到了SHIT!,那么函数到这里也就结束了,等别人唤醒你的时候,你就从片段2开始执行,一直到什么时候遇到return为止。这是不是很像给程序打了个断点

CPS变换的意思就是说,随便给你一个SHIT!,然后你要照着原来的程序,把剩下的部分写成上面那样。当然实际情况比这个更复杂,因为你要考虑到这个SHIT!可能会出现在你要调用的函数的里面,那事情就没这么好办了。

所以vczh/tinymoe暴露continuation的意思也就是说,你可以在语言任意的地方写上SHIT!,然后编译器就想办法把“剩下的部分”,通过全文CPS变换,编译成一个闭包(也就是lambda表达式、函数对象、托管函数指针,etc),直接给你,然后你自己想办法去调度。当初我也是为了练习编程才做成这样的。直接的结果是什么呢?看看这个文件就知道,语言只需要提供递归跟分支结构就可以了,剩下的部分全部都可以写成库,哪怕是循环和异常处理都能做出来。

注:SHIT!在某些Lisp语言里面叫call-cc。

所以大家就会在项目的一开始看到,这个语言的其中一个例子就是如何几十行就地做出一个yield return。这也是如何添加Coroutine的一个例子。如果语言本身提供continuation,那实现Coroutine根本不是事。

只不过这个文章的标题是给自己心爱的语言加上Coroutine,而且除了Lisp以外,估计不会有任何一门提供continuation的语言会成为谁心目中心爱的语言,那么这个方法也就行不通了。

那最后剩下什么呢?这也是除了修改虚拟机以外,大部分语言的做法,特别是自从C#做出了await之后,被各种语言广泛抄走,用的也是我现在要讲的最后一种办法,这要求你规定SHIT!不能默默地在你调用的函数里面出现,如果他一定要出现,那么你要用特殊的语法来调用这个函数(譬如说使用await关键字)

这是什么意思呢?考虑一下下面这个程序:

int Shit(int x, int sum)
{
    SHIT!
    return sum + x;
}

int Fuck(IEnumerable<int> xs)
{
    int sum = 0;
    var _xs = xs.CreateEnumerator();
    while (_xs.Next())
    {
        var x = xs.Current;
        sum = Shit(x, sum);
    }
    return sum;
}

这里的SHIT!就出现在了Fuck调用的Shit函数里面。那么你说这样的函数我要怎么解continuation呢?我在看Fuck的时候我怎么会知道Shit里面会有SHIT!?万一Shit函数是个虚函数怎么办?万一这个虚函数还是另外的dll提供的怎么办?是吧,这就是语言不提供continuation,你也不能修改虚拟机(其实修改虚拟机也就等于提供continuation)的时候,你要给调用Shit函数的地方打一个标记的原因。那么我们可以把代码改成这样:

SHIT_CALLABLE! int Shit(int x, int sum)
{
    SHIT!
    return sum + x;
}

SHIT_CALLABLE! int Fuck(IEnumerable<int> xs)
{
    int sum = 0;
    var _xs = xs.CreateEnumerator();
    while (_xs.Next())
    {
        var x = xs.Current;
        sum = SHIT_CALL! Shit(x, sum);
    }
    return sum;
}

就可以了!其实想想很容易明白,如果一个返回int的函数执行到SHIT!的时候就会停下来等我再次唤醒它,那么它怎么可以返回int呢,返回成int我要上哪唤醒?这就像yield return要求你返回IEnumerable<T>,await要求你返回Task<T>一样,我们也可以要求包含SHIT!的函数返回一个我们定义的接口:

interface IShitCallable<T>
{
    T Result {get;}
    bool ShitCall();
}

SHIT_CALLABLE! IShitCallable<int> Shit(int x, int sum)
{
    SHIT!
    return sum + x;
}

SHIT_CALLABLE! IShitCallable<int> Fuck(IEnumerable<int> xs)
{
    int sum = 0;
    var _xs = xs.CreateEnumerator();
    while (_xs.Next())
    {
        var x = xs.Current;
        sum = SHIT_CALL! Shit(x, sum);
    }
    return sum;
}

这样我们调用它Fuck,他就返回一个IShitCallable<int>。我们拿到这个IShitCallbale<int>,就不断地ShitCall它,直到返回true为止,然后去取Result属性。当我们解开Fuck函数的时候,由于Shit函数返回的也是一个IShitCallable<int>,我们也就可以完美地做出continuation了。

现在我们就来尝试一下解语法糖,把SHIT!、SHIT_CALL!和SHIT_CALLABLE!从代码里面拿掉,变成普通的C#代码!

首先我们要对付的是Shit函数,Shit函数其实比较简单,因为没有分支也没有循环,那么我们粗暴的拆成两半就可以了。根据之前提到的做法,SHIT!前和SHIT!后是两段不同的代码,中间的SHIT!会告诉你下一段代码是什么,所以我们用一个int给他们编号就好了。然后就变成这样:

class Shit_IShitCallable : IShitCallable<int>
{
    public int state = 0;
    public int x;
    public int sum;

    public int Result {get; set;}

    bool ShitCall()
    {
        while (true) // 其实每一个分支都会退出所以这个while (true)等于没写
        {
            switch (state)
            {
            case 0:
                {
                    /* SHIT! 就编译成下面两行 */
                    state = 1;
                    return false;
                }
                break;
            case 1:
                {
                    /* return 就编译成下面三行 */
                    Result = sum + x;
                    state = -1;
                    return true;
                }
                break;
            default:
                throw EatShitException();
            }
        }
    }
}

IShitCallable<int> Shit(int x, int sum)
{
    return new Shit_IShitCallable() { x=x, sum=sum };
}

然后我们要对所有的SHIT_CALL!都解开变成普通的函数调用,其实就是把它弄成一个带SHIT!的循环:

SHIT_CALLABLE! IShitCallable<int> Fuck(IEnumerable<int> xs)
{
    int sum = 0;
    var _xs = xs.CreateEnumerator();
    while (_xs.Next())
    {
        var x = xs.Current;
        var _Shit = Shit(x, sum);
        while (!_Shit.ShitCall())
        {
            SHIT!
        }
        sum = _Shit.Result;
    }
    return sum;
}

你们看,只要加上了特殊的语法(SHIT_CALL!),那么其实我们根本就不关心Shit里面到底长什么样子,因为所有的SHIT!都只会直接出现在函数里面,出现在被调用的函数里面的SHIT!我们都可以置之不理。

因此剩下来就很简单了,基本上就是每个while变成两个片段。这里要注意的是,由于我们有多个互相嵌套的while,所以不能直接展开,需要添加一些“软SHIT!”,可以大大降低算法的脑力负担:

while (CONDITION)
{
    STATEMENTS;
}
==>
while (true)
{
    SHIT! // 也就是虽然打了个断点,但是如果命中了它,就立刻继续执行
    if (!CONDITION) break;
    STATEMENTS;
}

展开完成后就变成这样:

class Fuck_IShitCallable : IShitCallable<int>
{
    public int state = 0;
    public IEnumerable<int> xs;
    public int sum;
    public IEnumerator<int> _xs;
    public int x;
    public IShitCallable<int> _Shit;

    public int Result {get; set;}

    void ShitCall()
    {
        while (true)
        {
            switch (state)
            {
            case 0:
                {
                    sum = 0;
                    _xs = xs.CreateEnumerator();
                    /* 其实这相当于我们认为在每一个while的最前面插入SHIT!,然后不中断,不然直接展开会陷入死循环。SHIT!只出现在嵌套的while里面就会这样。这种软SHIT!就是跟着continue语句的,代表我们并不想中断,这也是最外面while (true)的作用 */
                    state = 1;
                    continue;
                }
                break;
            case 1:
                {
                    if (_xs.Next())
                    {
                        x = xs.Current;
                        _Shit = Shit(x, sum);
                        state = 2;
                        continue;
                    }
                    else
                    {
                        Result = sum;
                        /* 这是硬的return */
                        state = -1;
                        return true;
                    }
                }
                break;
            case 2:
                {
                    if (!_Shit.ShitCall())
                    {
                        /* 这是硬的SHIT! */
                        state = 3;
                        return false;
                    }
                    else
                    {
                        sum = _Shit.Result;
                        state = 1;
                        continue;
                    }
                }
                break;
            case 3:
                {
                    state = 2;
                    continue;
                }
                break;
            default:
                throw EatShitException();
            }
        }
    }
}

IShitCallable<int> Fuck(IEnumerable<int> xs)
{
    return new Fuck_IShitCallable{ xs=xs };
}

到了这里,相比大家已经明白了SHIT!、SHIT_CALL!和SHIT_CALLABLE!是怎么回事了,应该很快就可以把它们对应到各种语言的牛逼的功能里面去了(譬如await)。

总结一下,实现Coroutine主要有三种方法:

  1. 改虚拟机
    1. 好处:实现简单,跟语言的其他功能是正交的
    2. 坏处:只要你的改动不能merge回主分支,你就会一辈子蛋疼地conflict下去
  2. 语言直接提供continuation
    1. 好处:有continuation可以实现非常强大的控制流语句,Coroutine也只是其中的一个作用而已,你不需要专门为Coroutine做什么
    2. 坏处:这样的语言并不常见
  3. 要求SHIT!只能出现在SHIT_CALLABLE!函数里面,并且调用SHIT_CALLABLE!函数要用特殊的语法SHIT_CALL!,然后解开成一个大switch
    1. 好处:continuation毕竟是闭包,各种闭包群P容易给GC造成压力(这是Don Syme告诉我的,当初我发邮件问他为什么F#的computation expression的循环不支持break,他说这样就不能编译成状态机了),而直接改状态机并没有这个问题,甚至是像C++这样没有GC只有shared_ptr的语言也可以完美支持
    2. 坏处:需要改语法

第一篇就讲到这里了,现在你们应该学会人肉实现Coroutine了吧?这也是考不上三本的人所应该具有的基本能力。人肉实现总归是比较简单的,但是你们能不能写一个程序来代替自己人肉实现Coroutine呢?这就是接下来的几篇要讲的内容。

除此之外,我还将介绍GacUI的Workflow脚本语言是如何在提供基本的Coroutine语法结构的情况下,可以让你自己来实现yield return和async await的。这样你就可以创造自己无穷多的Coroutine类型,而不用等语言来帮你做。

最后大家可能会有个疑问,如果我要实现方法2,那这到底有多难?这当然是非常难,要考上三本才会做!

编辑于 2017-03-26

文章被以下专栏收录