我是如何把5万行C++代码移植到Go的?(22)

发布于2019-04-20 11:26:45

Go语言的创始人之一Rob Pike曾表示,他希望Go能够被C++程序员所接受,但结果差强人意。最近,在作者就职的HFT公司里,一个团队成功地把一些对速度不太敏感的基础设施代码从Python移植到了Go,这也促使他们决定尝试用Go对复杂冗余的C++服务端程序进行重构,这些代码有5W行之多,并且对吞吐量有一定的要求。

这个服务端程序使用了跟公司核心交易软件相同的技术和库,不同地是交易软件对系统的延迟更敏感,几乎每一微秒都很重要,而C++服务端并不需要这种程度的性能。

因此,使用Go自带的调度程序完全可以满足要求,没有必要使用交易系统实现的超优化C++框架,虽然损失了一些性能但获得了更好的可维护性。需要一提的是,本文作者负责了整个代码的重写工作。

前言

从商业角度来看,这个项目是成功的:重写工作提前完成;性能在可接受的范围之内;并且整体代码量不超过1W行(代码量的剧减主要是因为重写团队删除了一些过时的或者不需要的特性)。但从开发者的角度来看,作者认为结果并不是最优的。Go并不支持参数多态,作者因此使用了两到三倍的代码来实现类似功能。其中一部分是为了保障类型安全:Go强制开发者在类型修饰和类型安全之间做出取舍,作者选择了一个比较均衡的实现。总的来说,如果需要一般的类型安全,那么相对少的代码就可以实现,而如果需要更好的类型安全,则需要更多的代码。

接下来让我们对比一下Go语言的优缺点。

优点

Emacs开发平台

借助自动完成、跳转到定义、保存时的错误检查、智能重构和GoTest集成等插件,Emacs成为了Go语言环境下最好的IDE工具。另外,它也可以很方便地通过Elisp进行定制和扩展。如果你本人恰好是Emacs的爱好者,这绝对是一个大大的加分项。

Goroutines(协程)

Go实现了基于消息传递的并发,作者认为这是最简单的并发形式,使用也超级方便。通过将GOMAXPROCS设置为1,Go还允许开发者通过使用与并发代码完全相同的方式来编写并行/异步代码。与其它提供内置轻量级线程调度器的语言Erlang/Elixir和Haskell相比:前者缺乏静态类型,后者在实际开发中很少被管理人员采用。

没有继承

在很多情况下,基于继承的OO(面向对象)是一种反模式,这些冗余和模糊的代码几乎没有什么好处,Go则直接取消了这类代码。这有可能也是Rob Pike等人设计Go的初衷:谷歌内部有一大堆类似于企业版本Fizzbuzz的Java/C++代码,他们希望能从这些代码中彻底解放出来。也就是说,尽管在旧的C++服务端遗留代码中使用继承是合理的,但最好还是使用更现代的风格来重写代码,而且重写过程也并不复杂。

更好的可读性

Go代码更易于阅读和理解。相比之下,很多C++代码需要几个小时才能完全理解。Go本身也促使开发者编写可读的代码:这种语言完全避免了下面这种自做聪明的情形

“嘿,这篇论文(基本上没人读得懂)中的>8=3运算符可以让我节省10行代码,我最好把它写进代码里,我的同事也不难理解这行代码,因为它的意思已经在类型签名中很清楚地表达出来了(反正我是没看懂):(PrimMonad W, PoshFunctor Y, ReichsLens S) => W Y S((I -> W) -> Y) -> G -> Bool "。

简单而规范的语法

当我们需要将一个封闭函数的名称添加到每个日志字符串的开头时,如果使用Emacs,一个简单的regexp find-replace(正则表达式)命令就可以实现,而对于更复杂的语言则需要使用解析器。不论是通过Emacs宏或者是Go模板,简单的语法可以更容易地生成代码。

Emacs+Go==参数多态:我们可以使用Emacs宏来加速生成Go所需要的"复制粘贴",而且,如果函数编写正确,那我们也可以用regex命令来更新所有的"复制粘贴"函数。这样,我们就可以很容易地更新fooInt、fooFloat和fooDouble等函数,对比支持参数多态的语言对foo函数的更新,整个过程没有什么太大区别。这样做的缺点是,虽然Emacs宏和regex命令可以编写和修改Go代码,但它仍然不如真正的多态实现那样简洁和易读;而且对于不熟悉regex以及可扩展编辑器(Emacs)的人来说,维护同样也不容易。

有效的内置模板

通过Go的文本/模板包,我们可以很容易地生成新代码。它还允许开发者在生成代码时使用IO:例如,有一个同某些特定服务交互的库,它通过XML Schema生成。如果能够用不同的函数来生成不同的数据类型,那么就可以保证代码的类型安全。

在C++中,IO不能在编译时执行,因此不能使用上述模式来生成代码。允许编译时使用IO的语言有:

缺点

斯德哥尔摩综合征

前面已经提到,在允许使用IO的特性上,使用模板生成Go代码要比用C++元编程好得多,而C++元编程在这里显然是多余的,因为完全可以用另外一种可以支持IO的程序语言来生成代码。

没有实现参数多态

尽管很多人认为这在实践中并不是一个问题,但在这里,它是一个很严重的问题。如果把新的Go代码再移植回C++的话,考虑到C++的函数多态和类型多态,代码量可能会减少到目前的一半,并且具有更好的类型安全。如果用Haskell重写的话,代码量会更少,而使用Clojure的话,代码量有可能控制到1000行以内,当然这些代码可能很难被调试或维护。

牺牲了类型安全

针对服务器处理的各种protobuffer messages(协议缓冲消息),我们使用了扩展属性的方式,作者最初打算为每一种消息设置一种扩展属性,这样FooExtensionAttribute就不能用在Bar函数上。Go并没有实现参数多态和泛型,这意味着将会产生大量的重复代码,所以最终只使用了一种ExtensionAttribute,并且类型系统也没有检查它是否用于扩展合适的消息。

二进制文件太大

如果使用代码来生成类型安全的API,并确保每种数据类型都有明确的类型访问器和诸如此类的东西,则很容易生成超过10W行的Go代码以及30MB以上的二进制文件,编译时间也会更长。在这种情况下,一般会超过10秒。当然,这不是一个很严重的问题,因为我们可以把代码编译成静态库,这只需要一次,之后就可以通过静态链接来访问了。

内核兼容性有待提高

很多时候由于各种无奈的原因,需要把代码部署到一个旧内核上。而且,如果这个内核不支持最新的Go版本,就不得不换到一个旧的、很慢的Go版本,这多少有些令人沮丧。

结语

Go语言是一把双刃剑:它禁止一切复杂的抽象,不管是优秀的抽象亦或是很差的抽象。如果你和你的同事正在使用很糟糕的抽象,那切换到不能使用抽象的Go语言自然很好,反之亦然。当然这也要取决于判断抽象好坏的标准。

查看英文原文
https://togototo.wordpress.com/2015/03/07/fulfilling-a-pikedream-the-ups-of-downs-of-porting-50k-lines-of-c-to-go/

image