Rob Pike谈Google Go:并发,Type System,内存管理和GC
1. Rob,你创建了Google Go这门语言。什么是Google Go?能简明扼要的介绍一下Google Go吗?
我还是讲讲为什么要创建这门语言吧,和你的问题稍有些不同。我在Google做了一个有关编程语言的系列讲座,在Youtube上有,谈及了我早期所写的一个语言,叫做Newsqueak,那是八十年代的事,非常早。在做讲座期间,我开始思考为什么Newsqueak中的一些想法在我现在以C++为主的工作环境中无法使用。而且在Google我们经常要构建非常大的程序,光构建就要花很多时间,对依赖的管理也有问题,由于链接了本来并不需要的东西,二进制程序包变得很大,链接时间很长,编译时间也很长,而且C++的工作方式有点古老,其底层实际上C,C++已经有三十年的历史了,而C则更是有四十年了。用现今的硬件做计算,有很多新东西需要考虑:多核机器、网络化、分布式系统、云计算等等。
2. Go的主要特点是什么?有什么重要功能?
对于大多数人来说,他们对Go的第一印象是该语言将并发性作为语言原语,这对我们处理分布式计算和多核这类东西来说非常好、也非常重要。我猜许多人会认为Go是一门简单无趣的语言,没有什么特别的东西,因为其构想看起来一目了然。但实际上不能用第一印象来判断Go。很多用过Go的人会发现它是一门非常高产而且有表现力的语言,能够解决我们编写这门语言时期望其所能解决的所有问题。
Go的编译过程很快,二进制程序包又比较小,它管理依赖的方式如同管理语言本身的东西一样。这里还有一个故事呢,但是在这里就不再展开讨论了,但是这门语言的并发性使其能够以非常简单的模式来处理非常复杂的操作及分布式计算环境。我想最重要的功能可能就是并发性了,后面我们可以谈谈该语言的类型系统,其与C++、Java这类传统面向对象类型系统的差异很大。
3. 在我们继续话题之前,能否解释一下为什么Go编译器能达到那么快的编译速度呢?有什么法宝?
它之所以快,有两个原因。首先Go有两个编译器——两个单独的实现。一个是按照Plan 9(http://plan9.bell-labs.com/wiki/plan9/1/) 风格新写的编译器,它有自己独特的工作方式,是个全新的编译器。另一个编译器叫做GCC Go,它拥有GCC前端,这个编译器是Ian Taylor后来写的。所以Go有两个编译器,速度快是二者的共同特点,但是Plan 9风格编译器的速度是GCC Go的5倍,因为它从头到脚都是全新的,没有GCC后端,那些东西会花很多时间来产生真正的好代码。
GCC Go编译器要产生更好的代码,所以速度慢些。不过真正重要的一点是Go编译器的依赖管理特性才是其编译速度快的真正原因。如果你去看一个C或C++程序,便会发现其头文件描述了函数库、对象代码等等东西。语言本身并不强制检查依赖,每一次你都必须分析代码以便清楚你的函数是怎样的。如果你编译过程中想用另一个类的C++程序,你必须先编译它所依赖的类和头文件等等等等。如果你所编译的C++程序有许多类,并且内部相关,你可能会把同一个头文件编译数百次甚至上千次。当然,你可以用预编译头文件及其他技巧来回避之一问题。
但是语言本身并不能帮上你的忙,工具可能会让这一问题得到改善,可是最大的问题是并没有什么能保证你所编译的东西就是程序真正需要的东西。有可能你的程序包含了一个并不真正需要的头文件,但是你没办法知道,因为语言并没有强制检查。而Go有一个更加严格的依赖模型,它有一些叫做包(packages)的东西,你可以把它想象成Java类文件或着类似的东西,或者函数库什么的,虽然他们并不相同,但基本思路是一样的。关键问题是,如果这个东西依赖那个东西,而那个东西又依赖另外一个东西,比如A依赖于B,B又依赖于C,那么你必须首先编译最内层的依赖:即,你先编译C,然后编译B,最后编译A。
但是如果A依赖B,但是A并不直接依赖于C,而是存在依赖传递,那么该怎么办呢?这时所有B需要从C拿到的信息都会被放在B的对象代码里。这样,当我编译A的时候,我不需要再管C了。于是事情就非常简单了:在你编译程序时,你只需将类型信息沿着依赖关系树向上遍历即可,如果你到达树的顶端,则只需编译紧邻的依赖,而不用管其它层级的依赖了。如果你要做算术运算,你会发现在Objective-C或C++或类似的语言里,虽然只包含了一个简单的头文件,但由于依赖传递的存在,你可能会编译数十万行程序。然而在Go中,你打开一个文件,里面或许只有20行,因为其中只描述了公共接口。
如果一个依赖链里只有三个文件,Go的优势可能并不明显,但是如果你有成千上万个文件的时候,Go的速度优势会成指数增长。我们相信,如果用Go的话,我们应该能够在数秒内就编译完数百万行代码。然而如果是等量的用C++编写的程序,由于依赖管理问题,编译的开销会大得多,编译的时间将会长达若干分钟。因此,Go速度快的根源主要归功于对依赖的管理。
4. 让我们开始聊聊Go里的类型系统吧。Go里面有结构(struct)、有类型(type),那么Go里的类型是什么?
Go里的类型与其它传统编程语言里的类型是类似的。Go里的类型有整数、字符串、struct数据结构、以及数组(array),我们称之为切片(slice),它们类似于C的数组,但更易于使用,更加固定一些。你可以声明本地类型并予以命名,然后按照通常的方式来使用。Go和面向对象方式的不同之处在于,类型只是书写数据的一种方式,方法则是一个完全独立的概念。你可以把方法放在struct上,在Go里没有类的概念,取而代之的是结构,以及为此结构声明的一些方法。
结构不能与类混为一谈。但是你也可以把方法放在数组、整数、浮点数或字符串上,实际上任何类型都可以有方法。因此,这里方法的概念比Java的方法更加泛化,在Java里方法是类的一部分,仅此而已。例如,你的整数上可以有方法,听上去似乎没什么用,但是如果你想在一个叫做Tuesday的整数常量上附加上to_string方法来打印出漂亮的星期格式;或者,你想重新格式化字符串使其能够以不同的方式打印出自己,这时你就会意识到它的作用。为什么非要把所有方法或者其它好东西都塞进类里面呢,为什么不让它们提供更广泛的服务呢?
5. 那么这些方法只是在包内部可见喽?
非也,实际上是这样,Go只允许你在包内为你所实现的类型定义方法。我不能引入你的类型然后直接把我的方法增加进去,但是我可以使用匿名属性(anonymous field)将其包裹起来,方法可不是你想加到哪就加到哪的,你要定义类型,然后才能把方法放在上面。正因为如此,我们在包里提供了另一种封装——接口(interface),但是如果你不明白谁能为对象增加方法的严格界限,就很难理解接口。
6. 你的意思是,我可以给int增加方法,但是必须先使用typedef吗?
你要typedef一个整数类型,起个名字,如果你正在处理一星期中的七天,可以就叫它“Day”,你可以给你所声明的类型——Day增加方法,但是你不能直接给int增加方法。因为整数类型不是你定义的,不在你的包里,它是引入的但并不在你的包中定义,这就意味着你不能给其增加方法。你不能给不在你包里定义的类型增加方法。
7. 你们借鉴了Ruby里开放类的思想,这很有意思。Ruby的开放类实际上是可以修改类并增加新的方法,这是有破坏性的,但是你们的方法本质上是安全的,因为创建了新的东西。
它是安全可控的,而且很容易理解。最初我们觉得类型用起来可能不太方便,我们也希望像Ruby那样添加方法,但这又让接口比较难以理解。所以,我们只把方法取出来,而不是放进去,我们想不出有什么更好的办法,于是限制方法只能在本地类型上,不过这种思路确实很容易理解和使用。
8. 你还提到了typedef,是叫typedef吧?
应该叫“type”,你所说的类型——Day的定义方式是这样“type Day int”,这样你就有一个新类型了,你可以在其上增加方法、声明变量,但这个类型不同于int,不像C那样,只是同一事物另起了个名字而已,在Go里实际上你创建了一个不同于int的新类型,叫做“Day”,它拥有int的结构特性,但却有自己的方法集。
9. Typedef在C里是一种预处理指令吗?【编辑注/免责申明:C语言里的typedef与预处理无关】
那实际上就是个别名,但在Go里不是别名,是新类型。
10. 我们从底层说起吧,在Go里最小的类型是什么?
最小的类型应该是布尔类型(bool)吧。bool、int和float,然后是int32、float64之类有尺寸的类型、字符串、复杂类型,可能有遗漏,但这就是基本类型集了。你可以由这些类型构建结构、数组、映射(map),映射在Go里是内建类型不是函数库。然后我想就该是接口了,到了接口,有趣的东西才真正开始。
11. 但是,int这样的类型是值类型对吧.
Int是值类型。在Go里,任何类型都是值类型,和C一样,所有东西都是按值调用,但是你也可以用指针。如果你想引用某样东西,可以获取其地址,这样你就有了一个指针。Go也有指针但是比C指针有更多限制,Go里的指针是安全的,因为他们是类型安全的,所以你没法欺骗编译器,而且也没有指针运算,因此,如果你有个指向某物的指针,你无法将其移到对象外,也无法欺骗编译器。
12. 它们类似C++的引用吗?
是的,很像引用,但是你可以按照你预期的方式对它们进行写操作。而且你可以使用结构内部(如缓冲区)中间的某个地址,它和Java的引用不一样。在Java中,你必须在旁边分配一个缓冲区,这是额外的开销。在Go中,你实际上把该对象分配为结构的一部分,在同一内存块中,这对性能是非常重要的。
13. 它是结构内部一个复合对象。
是的,如果它是值而不是指针的话,是这样。当然你也可以把指针放在结构内部和外部,但是如果你有struct A,而把struct B放在struct A里,那么stuct B就是一块内存,而不像Java那样,这也是Java性能问题的原因之一。
14. 你提到过接口比较有趣,那下面咱们就谈谈这一部分。
Go里的接口真的非常、非常地简单。接口指明了两个不同事情:其一,它表明了类型的构思,接口类型是一个罗列了一组方法的类型,因此如果你要抽象一组方法来定义一个行为,那么就定义一个接口并声明这些方法。现在你就有了一个类型,我们就叫它接口类型吧,那么从现在起所有实现了接口中这些方法的类型——包括基本类型、结构、映射(map)或其它什么类型,都隐含符合该接口要求。其二,也是真正有意思的是,和大多数语言中的接口不同的是,Go里面没有“implements”声明。
你无须说明“我的对象实现了这个接口”,只要你定义了接口中的那些方法,它就自动实现了该接口。有些人对此感到非常担忧,依我看他们想说的是:知道自己实现(Implement)了什么接口真的很重要。如果你真想确定自己实现了什么接口,还是有技巧可以做到这一点的。但是我们的想法与此截然不同,我们的想法是你不应该考虑实现什么接口,而是应该写下要做的东西,因为你不必事前就决定要实现哪个接口。可能后来你实际上实现了某个现在你尚不知晓的接口,因为该接口还未设计出来,但是现在你已经在实现它。
后来你可能发现两个原先未曾考虑过相关性的类具有了相关性——我又用了类这个词,我思考Java太多了——两个structs都实现了一些非常有用的小子集中的相关方法,这时有办法能够操作这两个structs中的任意一个就显得非常有用了。这样你就可以声明一个接口,然后什么都不用管了,即使这些方法是在别人的代码中实现的也没问题,虽然你不能编辑这些代码。如果是Java,这些代码必须要声明实现你的接口,在某种意义上,实现是单向的。然而在Go里,实现是双向的。对于接口实际上有不少漂亮而简单的例子。
我最爱用的一个真实例子就是“Reader”,Go里有个包叫做IO,IO包里有个Reader接口,它只有一个方法,该方法是read方法的标准声明,比如从操作系统或文件中读取内容。这个接口可以被系统中任何做read系统调用的东西所实现。显然,文件、网络、缓存、解压器、解密机、管道,甚至任何想访问数据的东西,都可以给其数据提供一个Reader接口,然后想从这些资源中读取数据的任何程序都可以通过该接口达到目的。这有点像我们前面说过的Plan 9,但是用不同的方式泛化的。
与之类似,Writer也是比较好理解的另一个例子,Writer 由那些要做写操作的人来实现。那么在做格式化打印时,fpringf的第一参数不是file了,而是Writer。这样,fprintf可以给任何实现了write方法的东西做IO格式化的工作。有很多很好的例子:比如HTTP,如果你正在实现一个HTTP服务器,你仅须对connection做fprintf,便可将数据传递到客户端,不需要任何花哨的操作。你可以通过压缩器来进行写操作,你可以通过我所提到的任何东西来进行写操作:压缩器、加密机、缓存、网络连接、管道、文件,你都可以通过fprintf直接操作,因为它们都实现了write方法,因此,隐含都隐含符合writer接口要求。
15. 某种程度上有点类似结构化类型系统(structural typing)
不考虑它的行为的话,它是有点像结构化类型系统。不过它是完全抽象的,其意并不在拥有什么,而是能做什么。有了结构(struct)之后,就规定了其内存的样子,然后方法说明了结构的行为,再之后,接口则抽象了该结构及其它实现了相同方法的其他结构中的这些方法。这是一种鸭子类型系统(duck typing,一种动态类型系统,http://en.wikipedia.org/wiki/Duck_typing),而不是结构化类型系统。
16. 你提到过类,但Go没有类,对吧。
Go没有类。
17. 但是没有类怎么去写代码?
带方法的结构(stuct)很像是类。比较有意思的不同之处是,Go没有子类型继承,你必须学习Go的另类写法,Go有更强大、更有表现力的东西。不过Java程序员和C++程序员刚开始使用Go的时候会感到意外,因为他们实际上在用Go去编写Java程序或C++程序,这样的代码工作得并不好,你可以这样做,但这样就略显笨拙了。但是如果你退一步,对自己说“我该怎样用Go去编写这些东西呢?”,你会发现模式其实是不同的,用Go你可以用更短的程序来表达类似的想法,因为你不需要在所有子类里重复实现行为。这是个非常不同的环境,比你第一眼看上去的还要不同。
18. 如果我有一些行为要实现,而且想放在多个structs里,怎么去共享这些行为?
有一个叫做匿名域的概念,也就是所谓的嵌入。其工作方式是这样:如果你有一个结构(struct),而又有一些其它东西实现了你想要的行为,你可以把这些东西嵌入到你的结构(struct)里,这样,这个结构(struct)不仅仅可以获得被嵌入者的数据还可以获得它的方法。如果你有一些公共行为,比如某些类型里都有一个name方法,在Java里的话你会认为这是一组子类(继承来的方法),在Go里,你只需拿到一个拥有name方法的类型,放在所有你要实现这个方法的结构里,它们就会自动获得name方法,而不用在每个结构里都去写这个方法。这是个很简单的例子,但有不少有趣的结构化的东西使用到了嵌入。
而且,你还可以把多个东西嵌入到一个单一结构中,你可以把它想象成多重继承,不过这会让人更加迷惑,实际在Go里它是很简单的,它只是一个集合,你可以放任何东西在里面,基本上联合了所有的方法,对每个方法集合,你只需写一行代码就可以拥有其所有行为。
19. 如果有多重继承命名冲突的问题该怎么办?
命名冲突实际上并没什么,Go是静态处理这一问题的。其规则是,如果有多层嵌入,则最高层优先;如果同一层有两个相同的名字或相同的方法,Go会给出一个简单的静态错误。你不用自己检查,只需留意这个错误即可。命名冲突是静态检查的,而且规则非常简单,在实践中命名冲突发生的也并不多。
20. 因为系统中没有根对象或根类,如果我想得到一个拥有不同类型的结构的列表,应该怎么办?
接口一个有意思的地方是他们只是集合,方法的集合,那么就会有空集合,没有任何方法的接口,我们称之为空接口。系统中任何东西都符合空接口的要求。空接口有点类似于Java的Object,不同之处在于,int、float和string也符合空接口,Go并不需要一个实际的类,因为Go里没有类的概念,所有东西都是统一的,这有点像void*,只不过void*是针对指针而不是值。
但是一个空接口值可以代表系统中的任何东西,非常具有普遍性。所以,如果创建一个空接口数组,实际上你就有了一个多态性容器,如果你想再把它拿出来,Go里面有类型开关,你可以在解包的时候询问里面的类型,因此可以安全的进行解包操作。
21. Go里有叫做Goroutines的东西,它们和coroutines有什么区别?不一样么?
Coroutines和Goroutines是不同的,它们的名字反应了这一点。我们给它起了个新名,因为有太多术语了,进程(processes)、线程(threads)、轻量级线程、弦(chords),这些东西有数不清的名字,而Goroutines也并不新鲜,同样的概念在其它系统里已经都有了。但是这个概念和前面那些名字有很大不同,我希望我们自己起名字来命名它们。Goroutine背后的含义是:它是一个coroutine,但是它在阻塞之后会转移到其它coroutine,同一线程上的其它coroutines也会转移,因此它们不会阻塞。
因此,从根本上讲Goroutines是coroutines的一个分支,可在足够多的操作线程上获得多路特性,不会有Goroutines会被其他coroutine阻塞。如果它们只是协作的话,只需一个线程即可。但是如果有很多IO操作的话,就会有许多操作系统动作,也就会有许多许多线程。但是Goroutines还是非常廉价的,它们可以有数十万之众,总体运行良好并只占用合理数量的内存,它们创建起来很廉价并有垃圾回收功能,一切都非常简单。
22. 你提到你们使用了m:n线程模型,即m个coroutines映射到n个线程上?
对的,但是coroutines的数量和线程的数量是按照程序所做工作动态决定的。
23. Goroutines有用于通信的通道吗?
是的,一旦有两个独立执行的功能,如果Goroutine们要相互协作它们就需要相互对话。所以就有了通道这个概念,它实际上是一个类型消息队列,你可以用它来发送值,如果你在Goroutine中持有通道的一端,那么你可以发送类型值给另外一端,那一端则会得到想要的东西。通道有同步和异步之分,我们尽可能使用同步通道,因为同步通道的构思非常好,你可以同时进行同步和通信,所有东西运行起来都步调一致。
但是有时由于效率原因或调度原因,对消息进行缓存也是有意义的。你可以向通道发送整型消息、字符串、结构、指向结构的指针等任何东西,非常有意思的事,你可以在通道上发送另一个通道。这样,我就能够把与他人的通信发送给你,这是非常有意思的概念。
24. 你提到你们有缓存的同步通道和异步通道。
不对,同步是没有缓存的;异步和缓存是一个意思,因为有了缓存,我才能把值放在缓存的空间里进行保存。但是如果没有缓存,我必须等着别人把值拿走,因此无缓存和同步是一个意思。
25. 每个Goroutine就像是一个小的线程,可以这么给读者解释吧。
对,但是轻量级的。
26. 它们是轻量级的。但是每个线程同样都预分配栈空间,因而它们非常耗费资,Goroutines是怎么处理的呢?
没错,Goroutines在被创建的时候,只有非常小的一个栈——4K,可能有点小吧,这个栈是在堆中的,当然,你知道如果在C语言里有这么一个小栈会发生什么,当你调用函数或分配数组之类的东西时,程序会马上溢出。在Go里则不会发生这样的事情,每个函数的开头都会有若干指令以检查栈指针是否达到其界限,如果到达界限,它会链接到其它块上,这种连接的栈叫做分段栈,如果你使用了比刚开始启动时更多的栈,你就有了这种栈块链接串,我们称之为分段栈。
由于只有若干指令,这种机制非常廉价。当然,你可以分配多个栈块,但是Go编译器更倾向于将大的东西移到堆上,因此实际上典型的用法是,你必须在达到4K边界之前调用几个方法,虽然这并不经常发生。但是有一点很重要:它们创建起来很廉价,因为仅有一次内存分配,而且分配的内存非常小,在创建一个新的Goroutine时你不用指明栈的尺寸,这是很好的一种抽象,你根本不用担心栈的大小问题。之后,栈会随需求增长或缩小,你不用担心递归会有问题,你也不用担心大的缓存或任何对程序员完全不可见的东西,一切由Go语言来打理,这是一门语言的整体构思。
27. 我们再来谈谈自动化方面的东西,最初你们是将Go语言作为系统级语言来推广的,一个有趣的选择是使用了垃圾回收器,但是它速度并不快或者说有垃圾回收间歇问题,如果用它写一个操作系统的话,这是非常烦人的。你们是怎么看这一问题的?
我认为这是个非常难的问题,我们也还没有解决它,我们的垃圾回收器可以工作,但是有一些延迟问题,垃圾回收器可能会停顿,但是我们的看法是,我们相信尽管这是一个研究课题,虽还没解决但是我们正在努力。对于现今的并行机,通过把机器内核的一些碎片专门分给作为后台任务的垃圾回收来进行并行回收是可行的。在这一领域有很多工作要做,也取得了不少成功,但这是个很微妙的问题,我不认为而我们会把延迟降为0,但是我相信我们可以让延迟尽可能低,这样对于绝大多数系统软件来讲它不再是个问题。我不保证每个程序都不会有显著延迟,但是我想我们可以获得成功,而且这是Go语言中一个比较活跃的领域。
28. 有没有方法能够避免直面垃圾回收器,比如用一些大容量缓存,我们可以把数据扔进去。
Go可以让你深入到内存布局,你可以分配自己的空间,如果你想的话可以自己做内存管理。虽然没有alloc和free方法,但是你可以声明一个缓存把东西放进去,这个技巧可用来避免产生不必要的垃圾。就像在C语言一样,在C里,如果你老是malloc和free,代价很大。因此,你分配一个对象数组并把它们链接在一起,形成一个链表,管理你自己的空间,而且还不用malloc和free,那么速度会很快。你可以做与Go所做相同的事情,因为Go赋予你与底层事物安全打交道的能力,因此不用欺骗类型系统来达到目的,你实际上可以自己来做。
前面我表达了这样的观点,在Java里,无论何时你在结构里嵌入其它东西,都是通过指针来实现的,但在Go里你可以把它放在一个单一结构中。因此如果你有一些需要若干缓存的数据结构,你可以把缓存放在结构的内存里,这不仅意味着高效(因为你不用间接得到缓存),而且还意味着单一结构可以在一步之内进行内存分配与垃圾回收。这样开销就会减少。因此,如果你考虑一下垃圾回收的实际情况,当你正在设计性能要求不高的东西时,你不应该总是考虑这个问题。但如果是高性能要求的,考虑到内存布局,尽管Go是具有真正垃圾回收特性的语言,它还是给了你工具,让你自己来控制有多少内存和产生了的垃圾。我想这是很多人容易忽略的。
29. 最后一个问题:Go是系统级语言还是应用级语言?
我们是把他设计为一种系统级语言,因为我们在Google所做的工作是系统级的,对吧?Web服务器和数据库系统、以及存储系统等,这些都是系统。但不是操作系统,我不知道Go是否能成为一个好的操作系统语言,但是也不能说它不会成为这样的语言。有趣的是由于我们设计语言时所采用的方法,Go最终成为了一个非常好的通用语言,这有点出乎我们意料。我想大多数用户并没有实际从系统观点来考虑过它,尽管很多人做过一点Web服务器或类似东西。
Go用来做很多应用类的东西也非常不错,它将会有更好的函数库,越来越多的工具以及一些Go更有用的东西,Go是一个非常好的通用语言,它是我用过的最高产的语言。
有疑问加站长微信联系(非本文作者)