D语言的协程——Fiber

假期里,我草草地啃了一遍《D程序设计语言》这本书,基本掌握了D的语法,期间也发现了一点问题,这里就当是废话简单提及一下。

首先是这本书,它绝对是本入门的好书,但总感觉比较啰嗦,可能是由于没有再版,有部分内容是有问题的,比如NVI,或许这也从侧面说明了D语法变动过于频繁。其次,关于标准库的介绍不足,并且对于GC语法也没有介绍,尤其是nogc,根本没有提及,或许作者就是希望我们用GC吧。

相比于C++的语法,D的语法中有更多的语法糖,尤其是数组部分,操作符重载部分。Actor并发模型的加入,也使并发编程变得更加令人心旷神怡,似乎有种Erlang的感觉。我个人认为D是一门“更像Java的C++”语言。

接下来是吐槽,那绝对是必须的。

  • dmd的语法检查似乎有点问题,很多应该提前到编译时的错误提示,延迟到了运行时,而且是莫名其妙的提示

  • 一直提到用AST来代替mixin,但到现在似乎都没什么动静

  • GC算法太老旧,这个结论是根据“stop the world”得出的,既然D和GC结合得这么紧密,就应该向Java学习下

  • 继承时的契约规则,似乎有漏洞,语法的设计者应该尽量考虑全面,而这里似乎包含了作者一定的臆测成分

什么语言都有优点和缺点,重要的是开发者能够清楚地认识到这些,并合理地利用和适当地规避。好,吐槽结束,来看正文。

Changelog

  • [2015-03-12] 添加云风大大的协程库地址

提出问题

在封装socket的时候,我遇到了一个问题,如何封装非阻塞式IO(不懂什么是非阻塞式IO?先看看IO杂谈吧),才能让编程简化,将细节部分隐藏。这着实是一个需要考虑的问题,毕竟,要是errno仅仅是错误还能为开发者所接受,但EAGAIN能算是错误吗?我们应该怎么让非阻塞式IO的编程变得自然点呢?

就我所知,目前有两种主流做法:

  1. 使用callback,也就是回调
  2. 使用coroutine,也就是协程

D语言使用回调显得有点别扭,原因有二:

  1. D是静态类型语言,而函数类型和参数类型的数量和类型有关联,无法简单地统一
  2. D是OO语言,我个人人为最好是以类为单位,而不是函数(当然你也可以采用类似Java的那种listener)

那么,似乎就剩下协程了。。。

协程概念

老实说,我自己根本写不出协程的定义,如下是从wiki上抄过来的:

Coroutines are computer program components that generalize subroutines for nonpreemptive multitasking, by allowing multiple entry points for suspending and resuming execution at certain locations.

上面的定义,简而言之就是:协程适用于非抢占式多任务环境,允许多入口点的挂起和再执行。

让我们用一个例子来说明,假设有一个函数:

int test() {
    ...
    entry
    ...
    return 0;
}

大家都知道,正常情况下,这个函数只有一个入口,一个出口。那么,协程如何在这个函数上体现呢?想必,各位都看到了其中的entry这个位置,协程能够让我们从这个位置挂起函数,所谓挂起,它和退出函数不是一个概念,因为在entry这个位置的状态已经被保留了。为何保留这些状态呢?因为我们还要从这个位置重新进入函数继续执行。

现在你想到协程能做些什么了吗?有很多语言是原生支持协程的,如python和lua。D语言的标准库中就有实现一个名为Fiber的类,它为D提供了协程实现(不能算是原生),内部是基于ucontext的,细节部分超出了本文的范围,在此不做介绍。大家如果感兴趣,可以参考下云风大大实现的协程库

用法解析

在D中使用协程,主要有两种方式:

  1. 直接用函数指针作为参数构建Fiber实例,以下简称构建法
  2. 继承Fiber类,以下简称继承法

Fiber类位于core.thread模块中,我们来一一介绍下两种使用方式。

构建法

import core.thread;
import std.stdio;
void test() {
    writeln("before yield");
    Fiber.yield();
    writeln("after yield");
}
void main() {
    Fiber testFiber = new Fiber(&test);
    testFiber.call();
    assert(testFiber.state == Fiber.State.HOLD);
    testFiber.call();
    assert(testFiber.state == Fiber.State.TERM);
}

继承法

import core.thread;
import std.stdio;
class DerivedFiber : Fiber {
    this() {
        super(&run);
    }
private:
    void run() {
        writeln("before yield");
        yield();
        writeln("after yield");
    }
}
void main() {
    Fiber testFiber = new DerivedFiber();
    testFiber.call();
    assert(testFiber.state == Fiber.State.HOLD);
    testFiber.call();
    assert(testFiber.state == Fiber.State.TERM);
}

用法补充

关于构建法和继承法,本质上是同一种方法,为什么能够演化成两种使用方式呢?原因很简单,因为Fiber类的构造函数支持两种类型的函数指针:

  1. void function()
  2. void delegate()

构建法使用的就是function,而继承法中则为delegate,两者的区别为是否带上下文环境的指针。

另外,Fiber中还有个enum,表示当前执行函数的3种状态,分别为:

  1. HOLD: 挂起状态
  2. EXEC: 执行状态
  3. TERM: 结束状态

当一个函数执行结束后继续调用call(),则会抛出异常,这个时候,如果需要重新使用这个函数,请先调用reset(),并且reset()也能用于重新设置执行体(即为函数)。

问题回顾

让我们回到刚开始的关于非阻塞式IO的问题,如何用协程解决?首先,我们要理解EAGAIN这个errno,它表示当前缓冲区无内容可读,但并不表示读取结束,只不过是数据还在“途中”。

这个时候,我们当然可以结束read或者write函数,但是我们需要一个状态,来记录当前的状态,其实write函数还好,因为你知道要写多少数据,只有两个状态,写入结束和EAGAIN状态。但是对于read,除非我们已经知道有多少内容,并且准备了足够大的buffer,否则就会多一种状态,就是buffer满。

处理这些细节,很是烦人,而协程的出现,为我们带来了新的解决方案,就是在EAGAIN发生时,yield一下,执行一些其他函数,然后再重新进入执行,这样既不用保存状态,又不用使用回调,一切都很和谐。

说两句: