这几天看了《Go语言编程》这本书,感觉一般,具体可见这篇书评。书评里面我提到“Go语言的goroutine和channel其实是把别的语言/平台上类库的功能内置到语言里”,这句话当然单单这么说出来是没什么价值的,于是我也就趁热把它说得再详细一些。我的看法简而言之是:由goroutine和channel所带来的主要编程范式、设计思路等等,其实基本都可以在其他一些平台中配合特定的类库来实现。

我们知道,操作系统的最小调度单元是“线程”,要执行任何一段代码,都必须落实到“线程”上。可惜线程太重,资源占用太高,频繁创建销毁会带来比较严重的性能问题,于是又诞生出线程池之类的常见使用模式。也是类似的原因,“阻塞”一个线程往往不是一个好主意,因为线程虽然暂停了,但是它所占用的资源还在。线程的暂停和继续对于调度器都会带来压力,而且线程越多,调度时的开销便越大,这其中的平衡很难把握。

正因为如此,也有人提出并实现了fibercoroutine这样的东西,所谓fiber便是一个比线程更小的代码执行单位,假如说“线程”是用来计算的“物理”资源,那么fiber就可以认为是计算的“逻辑”资源了。从理念上说,goroutine和WebWorker都是类似fiber或coroutine这样的概念(所以叫做goroutine):它们都是执行逻辑的计算单元,我们可以创建大量此类单元而不用担心占用过多资源,自有调度器来使用一个或多个线程来执行它们的逻辑。

Go语言使用go关键字来将任意一条语句放到一个coroutine上去运行。假如只是简单地执行一段逻辑,那么这和丢一段代码去线程池里执行可以说没有任何区别。但关键就在于,由于一个coroutine几乎就是个普通的对象,因此我们往往可以放心地阻塞它的逻辑,一旦阻塞调度器可以让当前线程立即去执行其他fiber上的代码。这里的阻塞往往就是通过Go语言中的channel带来的,一般来说会发生在“读”和“写”的时候:

func DoSomething(ch chan int) {
    ch <- 1
    var i = <-ch
}

上面代码中的ch就是一个用来保存int类型数据的channel。第一行代码是向其写入数据,可能在channel写满的时候阻塞。第二行则是从中获取数据,在channel为空的时候阻塞。可以看出,所谓channel其实就是一个再简单不过的容器而已。假如要类比.NET类库,则可以认为它是一个实现了ITargetBlockISourceBlock的对象(例如一个BufferBlock):

static async void DoSomething<T>(T block)
    where T : ISourceBlock<int>, ITargetBlock<int> {

    await block.SendAsync(1);
    var i = await block.ReceiveAsync();
}

类似Go语言中的超时等特性自然也一应俱全。当然,这里还并不能完全说是“类库”,毕竟还用到了C# 5里的async/await特性。我相信假如您对async/await有所了解的话,肯定也会听到一些它跟coroutine相关或类比的声音。它们在概念和效果上的确十分相似,当然背后的实现是有很大不同的。假如你一定要用coroutine,那还是免不了由语言或运行时提供支持。不过基于goroutine和channel的编程模式几乎完全可以由类库来实现。

在Go语言中,基于goroutine和channel的编程模式往往是这样的:

func (ch chan int) {
    for { // 死循环
        var msg = <-ch

        Process(msg)
    }
}

这样的“代码编写模式”是基于阻塞的,这需要coroutine支持。不过假如我们把需求分析到最基础的部分,它其实仅仅是:

  1. 可以创建大量队列,每个队列可以保存大量任务。
  2. 单个队列中的任务严格串行。
  3. 尽可能高效地(自然可以并行)处理系统中所有队列里的任务。

这就完全是类库能实现的功能了,各个平台上的此类成熟类库并不少见:

  1. iOS上的GCD,或者说libdispatch。
  2. Java平台上与GCD理念相同的HawtDispatch类库。
  3. 与Scala语言关系更为密切的Akka类库。
  4. .NET中的TPL Dataflow(之前提到的BufferBlock的出处)。

这些类库与Go语言中基于goroutine和channel的开发方式有着相似的基础,也完全有能力使用同样的方式来架构系统。基于这些类库,我们只需要提交大量的任务,至于这些任务什么时候被执行则是内部实现所关心的问题,类库自身将会把这些任务调度到物理线程上执行,用一种最高效,代价最低的方式。当然,我们也可以对这些队列进行一些配置,这甚至比Go或Erlang中直接由语言运行时来提供的调度支持有更细致的控制粒度。

我在工作中用过HawtDispatch和TPL Dataflow,也深刻体会到它们的价值。尤其是后者,我用TPL Dataflow实现的业务更为复杂,简直可以说大大改善了我的工作品质,拿它来模仿之前的编程模式则可以是这样的:

var block = new ActionBlock<int>(Process);

往这个block对象里塞入的任何对象都会使用Process方法进行处理。当然TPL Dataflow的功能不止如此,它有着大量的高级功能,例如TransformBlock可以在保证顺序的情况下进行一对多的数据转换,十分好用。具体内容可以参考这篇说明

当然,像Go与Erlang这种对coroutine和并发直接提供支持的语言还可以有其他一些做法,例如Go可以做到先从channel A中获取数据,然后在一个逻辑分支中再从channel B中获取数据。这对于只提供任务队列的类库来说做起来就麻烦一些了(对于C#和Scala这类语言来说依然不成问题),不过在我的经验里这个限制似乎并不会成为严重的阻碍,我们依然可以实现相同消息架构。

说起Erlang,其实在我看来它比Go的channel要好用不少。原因在于Erlang是动态类型语言,它的receive操作可以用来匹配当前队列(在Erlang里叫做mailbox)中不同模式的元组,筛选出符合特定模式的消息。与此相反,Go是静态类型语言,它总是从一个channel中依次获取类型相同的元素,这就完全类似于Java或C#中的泛型集合了。当然,这也不会是个影响系统设计的大问题。

说实话,我觉得这篇文章描述过多,但缺乏案例。其实我本来想通过改写《Go语言编程》中的范例来说明问题,但后来发现书中关于channel和goroutine的例子实在太简单了,没法体现出一个这个特性所带来“架构设计”。所以,示例什么的找机会再说吧。