再说Go语言

  当前如火如荼的Raft最成功得意之作莫属基于Go语言实现的etcd了,以目前形式看来大有挑战ZooKeeper作为企业级通用分布式协调组件之势。从社区状态看来etcd的更新也十分活跃,而且不少公司的分布式组件也是基于etcd中实现的Raft的基础之上再开发,所以说应该算是久经磨练考验了吧。etcd项目在其README.md中还描述到其Raft实现除了微小的变更优化之外,基本是完全按照原作者的论文设计来实现的,所以除了logcabin之外,从etcd的实现开始接触企业级的Raft实现也是一个门路(主要是logcabin好久没更新了,折腾的人少让人感觉好孤单……)
  背这次算是真刀实枪、认真系统性地学习了一下Go语言,同之前蜻蜓点水不同,随着对这个语言了解的更加深入,结合同C++相对比还是有一些心得和想法的,下面就简单聊聊自己的感受吧。

a. 数据类型表面形式上宽松

  Go语言提供了短变量声明语法,形式上看就感觉跟Python这类脚本语言一样弱化了数据类型的概念,但同时它确实是个静态编译语言,会有严格的检查机制确保强类型安全性。大家可能会说这同C++的auto自动类型推导没啥区别嘛!其实我觉得也是这样的,简短变量句法也就是个语法糖而已。
  另一方面Go语言的多重赋值确实是一个很好用的特性,可以使函数同时返回多个值,也从语法上让Go有使用error代替exception成为了可能。在做C++开发的时候,我们经常需要使用一个int或bool返回类型表明函数调用是否成功,确保调用处理成功后再通过引用或者指针来获取有意义的数据,这种写法相当的蹩脚,即便后来在开发的时候使用boost::optional部分缓解上述问题,但还是限制在只能返回一个值的情况。相比之下Go语言这种多重赋值的功能,才是真正满足编程习惯的设计。至于程序出错是使用错误标识还是使用异常机制就可谓仁者见仁了,没有绝对的好与坏,不然的话也不会出现很多boost库同时提供跑出异常和返回错误码两种API了,也有可能我个人对异常了解的还不够深刻,在习惯上基本倾向于使用错误码返回,所以Go的错误标识机制还是挺喜欢用的,只不过Go对于某些比较严重的错误直接宕机的做法表示惊讶。
  默认行为下Go语言为各种类型的变量都提供了定义良好的初始化值,防止变量随机初始化给程序带来不确定的行为。当初C语言设计出来的时候,为了性能考虑某些情况下定义的变量是未初始化的,C++也延续了这个传统,不过当前的计算能力下这种省略初始化带来的性能增益可能微乎其微,所以个人对这些语言的新标准中迟迟不提供变量的默认初始化表示比较费解。对于这个问题,目前我写C++的习惯是将所有的变量定义都附加一个统一初始化语法{},以确保变量是良好初始化的。

b. 面向过程式的编程语言

  在学习Go语言的时候也写了一些程序,使用Go语言的感觉和C语言非常的像(也可能是因为我只使用了一些基本特性),看到Go的爸爸(创始人)之一的Ken Thompson就不足为奇了,他可是和Dennis一起最早设计C语言写Unix操作系统的,所以其脑海中C语言的观念应该还是很深的吧。其实C语言也是我比较钟爱的语言之一,因为简单明了语言的细节可以完全把控住,只不过因为简单所以表现力较弱,同时语言级别的公共组件也很缺乏,连基础的容器结构都要自己去实现,用起来需要稍显麻烦些。
  Go语言没有C++的class概念,其struct只是用来单纯定义数据类型的(实际上这也不太好界定,因为Go语言中函数也是一等公民身份)。Go语言符号的可访问性是通过首字母是否大写来保证的,所以这个语言更像是通过文件而不是类型来提供封装特性。而Go语言同时又引入了面向对象概念中接口和方法的概念,方法在使用的时候需要同某个接受者显式地相绑定。在Go语言中没有继承的概念,Go语言的设计者提倡使用组合而非继承的方式来使类型增加新的功能。
  对于传统面向对象程序设计语言中,接口都是通过继承来在派生类中进行实现的,继承是一种很强的耦合关系,所以导致传统程序的维护性和扩展性很差。上面说到Go语言没有继承的机制,所以类型的声明中没有强制表明自己实现了哪些接口,而如果需要可以在任何地方实现特定接口方法就可以了,这种无契约的设计方法可以让程序在后续的演化迭代中保持很好的弹性,任何类型都可以通过实现接口增加功能,是一种完全组合式的操作,要知道在C++中我们用很多方式(比如委托)来解除这种强耦合带来的扩展性制约。不过这种设计带来的缺点就是我们无法从声明中一眼看出该类型实现了哪些接口,除了对这种类型比较的熟悉,否则使用起来会比较的麻烦。
  在Go语言中函数是第一类值,设计的时候(应该)借鉴了很多函数式编程语言的思想。函数式编程结构化编程面向对象编程合称为编程三大范式,虽然函数式编程概念很早就被提出来了,不过近些年特别的红火以至于很多地方都能看到这个字眼,不过我个人对这块还不是很熟悉,暂且不深入讨论下去了。

c. 垃圾回收好,但是其他资源管理堪忧

  如果是在上个世纪的旧C++环境下,因为内存管理容易泄漏的原因把大家搞的焦头烂额的时候,内存的自动回收确实可以作为一大卖点拿出来显摆,不过自从Modern C++引入了智能指针机制之后,C++的内存管理就已经不成问题了。相反垃圾回收语言的语言内存不是实时释放的,所以运行时候普遍对内存需求大很多,而且内存回收时候导致Stop the world在高频交易以及核心组件应用上可能会导致严重的问题而饱受诟病,即使垃圾回收器再怎么优化也不能做出绝对性的保证。
  同时,我们可以使用C++智能指针定制Deleter以及析构函数的特性,对非内存类资源做安全管理和确定性回收,要知道内存管理只是资源管理中的一部分而已,通过这种机制几乎可以安全管理所有的资源。此时相比而言,Go语言缺乏析构函数的设计反而处于劣势了:GC只回收内存而不管理任何其他资源;defer机制只能保证在函数调用返回时或异常发生时按照一定的顺序被调用,而无法像C++那样将析构精确的限定在某个作用域中完成,所以我认为Go语言的资源管理将是一件非常头疼的事情。
  Go的Goroutine和Channel使用不当也容易招致泄露,比如不当的使用Channel可能导致一个Goroutine永久的阻塞住而不被释放。假设在Go被用在高并发的场景下,资源的泄漏将会是一件极为恐怖的事情,很可能会拖垮整个主机或者其他主机上相关联的服务!

d. 语法上宽松,但是用起来显得更别扭

  如果只是使用Go的基础功能做一些工具类的应用,那么可以说Go和Python一样的好用,而性能上Go要比普通脚本语言优越好几个数量级,曾经参加峰会时某条公司说他们的服务使用Go改写调优后,性能基本能达到原先C++实现的80%,所以还是相当可观的。
  但是如果将Go用在重要的服务上,就必须要对整个语言有精致细微的了解才行。作为一个C++程序员,因为很多的资源需要程序员手动去管理,这也养成了鄙人在开发程序的时候极为小心谨慎的性格,如果对一个语言或者一个库的特性、功能没有较为清楚的认识和把握,是断然不会将其用于生产环境中去的。而恰好,当前Go语言的有些特性让我感觉很没底。
  比如数据类型,Go语言分为基本数据类型引用数据类型,前者包含数字类、字符串类和bool类型,其特点是各个变量是独立存储的简单类型;而引用类型包括了指针、slice、map、函数、Channel类型,他们的特点是间接指向程序变量或者状态,操作所引用的数据会遍及该数据的所有引用。在C/C++语言中只为数组做了一定的优化,其提现在某些场景下(比如函数传参)会执行向指针的退化,其他任何类型的优化都需要手动使用指针或者引用进行优化,这样的好处是使用者明确知道自己在干什么才做这类优化,而任何默认的行为都不会造成严重的后果;相比而言,Go语言使用了这么多类型的引用,使用者必须小心翼翼的设计自己的程序,防止任何不小心导致的底层数据的破坏,尤其是字符串类型和slice之间的关系一定要好好理理清楚,否则不小心就容易掉坑里面了。
  然后还想说一下指针,Java中极力避免指针以免导致恐怖的行为,而Go语言设计中却保留了指针。当然指针初始化或赋值时候使用&取地址,解引用使用*符号那倒也没什么好说的,一旦涉及到作为方法接受者的时候,就不得不吐槽一下了。我们知道方法在声明的时候是通过类型或者类型的指针来指明接受者类型的,比如:

1
2
func (t  T) ActionOne()
func (t *T) ActionTwo()

  针对ActionOne,其方法的接受者是对象的拷贝副本,即任何修改的副作用在调用后都会被丢失,这可能会让很多C++程序员感到诧异。而对于ActionTwo调用,因为接受者是指针类型,调用时候可以避免对象的拷贝,同时调用的副作用可以被保留,但个人觉得理应在调用的时候使用正确的指针类型作为接受者,但Go语言也允许直接使用可取地址的变量,甚至该类型指针的指针来直接调用该方法,编译器会自动插入需要的取地址符&或者解引用符*,因此在所有的Go代码中调用方法的语法都是一样的,在C++中完全可以根据调用语句判别出对应符号是直接变量还是对应的指针,但在Go语言中你只能通过省视函数声明来找到答案,很容易造成使用混乱的感觉。

e. Goroutine和Channel是个宝

  如果说Go语言真正杀手锏级别的特性,那么绝对是Goroutine和Channel了,这是解决互联网环境下高并发难题的关键之所在,只不过其实协程和Channel都不是新概念了,有些语言内置了这些特性,而有些语言也可以通过外置库提供类似的支持,只不过Go语言中这两个特性协作起来被重度使用,也就引起了更多人的注意了。题外话是我司现在交易部门的并发性成为了整个业务的瓶颈所在,一笔交易的处理过程需要和外部多个合作伙伴进行交互,如果使用移步框架开发难度较大,所以目前还是用多机器上开多线程的方式扛着,而架构部的一些同时在开发C++的协程库来解决这个问题。相比于其他互联网公司直接用Go来改写业务代码,这条路是否行得通,就拭目以待吧!
  深入了解一下,从本质上说与其说是语言特性成就了高并发,倒不如说是开发者的观念需要作出对应的改变。使用Goroutine和Channel就是要求开发者将工作拆分成小模块分别放置到Goroutine中去执行,然后这些Goroutine需要传递数据或者同步的时候,通过Channel来通信就可以了。当然如果你愿意,或者程序设计的不理想,关键数据还是需要使用Mutex保护的方式进行保护,那么程序的吞吐量还是上不去。

f. 程序开发的周边配套做的确实完善

  要知道互联网开发者都是比较挑剔难以伺候的,一个个都是毫不留情的键盘侠,但不得不说Go语言在开发体验上做的非常出色,在开发过程中很多细节都用go工具都包干了。
  go get:指定引用库地址信息后就可以自动解析依赖下载安装所需库,让这种编译语言可以像其他脚本语言一样有着类似于包管理特性,而且可以随意引用任意第三方库,相比于那些集中式的包管理机制更加的开放自由。不过缺陷也很明显,虽然首次使用时会自动下载保存引用库,但是GitHub上下载的软件包都是master的,怎么保证使用稳定的或者特定发行版本?万一某个库删除或者不再维护了,那么迁移开发机的时候不就会编译失败?反正我是维护了一个自用的库,用以收藏稳定的库代码,我需要保证自己任何开发机上Go程序的行为都是一致的。
  go fmt:编程样式风格一致是无聊,但却又是被讨论的非常多的问题,用go fmt就不用再争吵了吧!
  go test:工作这么久现实中有写单元测试习惯的程序员基本没有,很多都是程序写完后进行冒烟测试,然后就丢给测试部去把关了。主要是C++标准没内置一个单元测试框架,而现有的第三方测试框架也需要程序员手动和项目管理工具集成起来,所以造成大家很少愿意去写单元测试的(不过相反开源软件使用单元测试的覆盖率却相当的高)。Go语言本身集成了单元测试的功能,其易用程度之强以至于会让程序员爱上写单元测试了。
  go -trace:临界区的发现和保护是并发编程中最核心的问题,尤其对于那些隐藏较深的问题难以发现,像是一个个埋在系统中的定时炸弹,使得我们对于生产系统的稳定性难以有充足的信心。Go提供了检测工具,可以报告系统中隐藏的竞争问题,虽不清楚这个工具到底效果如何,但是终归是帮忙多把了一道关了。
  Go语言编译出来的程序都是二进制的,也方便任何机器上都可以独立部署,现在我们C++的程序也都尽量静态链接了;Go语言可以使用GNU gdb进行调试,所以之前的调试技巧完全可以此处复用了;Go工具还可能内置了很多其他的特性等待被发掘……

g.良好活跃的生态圈

  语言就和操作系统一样,只有丰富成熟的生态圈才能使语言保持旺盛的生命力,否则再优雅的设计和实现也只能像似一个花瓶一样被把玩把玩而已。Go语言的生态圈自然不必我多说了,无论是久经考验的开源组件,还是各大互联网的开放接口,几乎都提供了Go语言的绑定和支持,GitHub上基于Go语言的工具和轮子多如牛毛,所以说基于Go开发一套互联网服务或者工具就像搭积木一样的方便自由。
  其实常常和Go一同讨论的还有新兴语言Rust,虽然看上去Rust比Go在设计和实现上要强大、安全的多,而且还能写操作系统,不过现在为止玩的人仍然寥寥,很多人没有时间造轮子的情况下,可能还是会选择Go语言的成熟实现,而在这种激励下Go又可以不断的迭代更新自己的弱势,从而得到更良性的循环。

  关于Go语言的教程现在也是琳琅满目,个人觉得《Go程序设计语言》和《Go语言实战》两本教参还是不错的,前者可以同《C++程序设计语言》类似作为一个语言圣经级别的参考书了,全书行文简洁干练但又内容全面,并且配以微小的例子帮助消化理解;后者延续了Manning图书的一贯风格,全书实践性较强,推荐在了解Go语言之后再通过这本书掌握深层次的用法。
  
  点题:设计Go语言的都是计算机的泰斗,上面说出的问题他们肯定在设计之初早就考虑到了,只是会在各个方面做出妥协和取舍吧。只是从一个普通开发者的角度看来,相比而言我还是更喜欢C++,这才是重点 ;-)

本文完!