为什么我认为goroutine和channel是把别的平台上类库的功能内置在语言里
2013-04-09 13:52 by 老赵, 12494 visits这几天看了《Go语言编程》这本书,感觉一般,具体可见这篇书评。书评里面我提到“Go语言的goroutine和channel其实是把别的语言/平台上类库的功能内置到语言里”,这句话当然单单这么说出来是没什么价值的,于是我也就趁热把它说得再详细一些。我的看法简而言之是:由goroutine和channel所带来的主要编程范式、设计思路等等,其实基本都可以在其他一些平台中配合特定的类库来实现。
我们知道,操作系统的最小调度单元是“线程”,要执行任何一段代码,都必须落实到“线程”上。可惜线程太重,资源占用太高,频繁创建销毁会带来比较严重的性能问题,于是又诞生出线程池之类的常见使用模式。也是类似的原因,“阻塞”一个线程往往不是一个好主意,因为线程虽然暂停了,但是它所占用的资源还在。线程的暂停和继续对于调度器都会带来压力,而且线程越多,调度时的开销便越大,这其中的平衡很难把握。
正因为如此,也有人提出并实现了fiber或coroutine这样的东西,所谓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类库,则可以认为它是一个实现了ITargetBlock
和ISourceBlock
的对象(例如一个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支持。不过假如我们把需求分析到最基础的部分,它其实仅仅是:
- 可以创建大量队列,每个队列可以保存大量任务。
- 单个队列中的任务严格串行。
- 尽可能高效地(自然可以并行)处理系统中所有队列里的任务。
这就完全是类库能实现的功能了,各个平台上的此类成熟类库并不少见:
- iOS上的GCD,或者说libdispatch。
- Java平台上与GCD理念相同的HawtDispatch类库。
- 与Scala语言关系更为密切的Akka类库。
- .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的例子实在太简单了,没法体现出一个这个特性所带来“架构设计”。所以,示例什么的找机会再说吧。
其实任何语言都可以实现类似goroutine和channel的方式,因为这仅仅是CSP的一种理念,所以你会觉得其实OC/Java/C#等都实现了类似的类库,但是go里面的这个操作你会觉得编写并发相当的简单,只需要go 函数就可以并发了,然后不同的通道通过channel来交互数据,至于你说的channel只能获取相同的元素,这个我觉得可以使用interface来实现动态的元素传递。
这里有一篇文章介绍的Go的并发之美我觉得很好的描述了Go的goroutine带来架构设计:http://www.yankay.com/go-clear-concurreny/