但后来我意识到 Go 在工程上仍然是有重大缺陷的。它今天之所以没有达到我预期的成就(我认为仅从它的语言设计来说,是有进入语言榜第一的潜力的),是因为它在一件至关重要的事情上选错了,那就是和旧世界的兼容性。这尤其体现在和 C 的兼容上。C 作为唯一流行了 50 多年的语言,它的生态系统之庞大,绝非 Go 语言短期可以拍马赶得上的。所以大部分新兴语言(以 Rust 语言为代表),都选择了兼容。而 Go 虽然提供了 cgo,但是实际大家用起来怨声载道。最近 Hacker News 上有一篇题为《SQLite in Go, with and Without Cgo》的帖子,引发了非常热烈的讨论(参阅 news.ycombinator.com/item?id=31364166),侧面验证了 cgo 的问题。而 Go team 的核心成员 Dave Cheney 为了 cgo 还专门写过一篇《cgo is not Go》(参阅 dave.cheney.net/2016/01/18/cgo-is-not-go),强调了 cgo 并不属于 Go 推荐的那部分。关于 cgo 的问题,上面的帖子基本上说全了。一开始大家关注的点还是比较表面的,比如 cgo 写起来比较丑陋,cgo 带来了跨平台的编译上的麻烦等等。后来终于有人说到了 cgo 很致命的一个问题:Go 在调用 C 函数时都会消耗一个线程。这里面有两个子问题:一方面 C 函数被无差别的对待,所有 C 函数调用被 Go 以处理类似系统调用的逻辑来做了。其实有时候我可能只是要调用 C 的某个算法,比如 C.strlen,这是很轻的操作,但是被 cgo 这么一搞这个调用就变得很重了。另一方面并发调用 C 函数可能会创建大量的线程,最后导致线程过度创建而崩溃。关于这个问题我认为有可能的解决方式可以是比较简单的,就是不知道为什么 Go 没有这样做:把 goroutine 底层用的线程池和 cgocall/syscall 用的线程池独立即可。这样 cgocall/syscall 之间相互等待,但不会影响 goroutine 的执行。但是这不能解决 C 函数无差别对待的问题。展开来说,Go 调用 C 实际上有两个成本:一是 entersyscall/exitsyscall 成本,它会从线程池选择一个线程来执行,如果没有空闲的线程则创建一个新的。二是从 Go 栈(可自动伸缩)切到 C 栈(不能自动收缩)再切回去的成本。如果我们选择二进制兼容 C 函数调用的话,省去 entersyscall/exitsyscall 意味着我们承担某种 unsafe 的风险:如果该 C 代码阻塞了就会阻塞调用它的那个 goroutine 背后的线程。这个风险很高,所以常规的 cgo 并不允许你这样做。Go 栈切 C 栈过程理论上也是可以省,前提是要先扩容 Go 栈,确保 C 函数调用不会发生栈溢出。但是这个节约是有隐含的代价的:虽然性能略好一些,但是所有调用过 C 函数的 goroutine 的栈变大了。由于 goroutine 的数量会很多,这一样会导致 Go 的内存使用暴涨而崩溃。所以比较理想的做法还是不去节约这一点的栈切换(这个成本非常低,只是几个寄存器赋值而已)的成本好一点。综合分析下来,我个人认为 Go 兼容 C 的各种可能路线,二进制兼容的负担其实是很高的,这也是 Go team 并不推荐 cgo 的原因。这也是最终 Go+ 选择 c2go 源代码兼容这条路线(更详细的背景,大家可以参阅《Go+ 下个里程碑:超越 cgo,无缝对接 C 语言》一文)的原因。c2go 最后也会支持二进制兼容,但是只会留给那些相对比较重的 C 函数的封装,而不是所有 C 代码被无差别对待。当然 Hacker News 上《SQLite in Go, with and Without Cgo》这个帖子有对比 cgo 版本和纯 Go 版本的 sqlite 的性能差异对比,结果是 C 版本的性能大幅胜出。这里有两个可能的原因:一个是 Go 代码翻译的问题(这个版本的 sqlite 基于 ccgo,我在《Go+ 探究:如何 10 天实现工业级的 C 编译器》一文中有对它做过简单对比,ccgo 并没有走完全自动化翻译的路线);一个是 Go 本身某些地方实现的性能的确比 C 差很多。这两个原因无论是哪一种,我觉得都可以交给时间来解决,并不存在固有的设计缺陷问题。实际上,我认为 c2go 是很好地让 Go team 去做极致的性能优化的动力来源。甩出代码及 C vs. Go 的 performance 对比,让他们去做 profiling 吧。哈哈。
Go+ 在工程化上的进展
那么,到今天为止,Go+ 在工程化上都有哪些重要的进展?其一,模块化。我们引入了 gop.mod,基本上把 go.mod 的机制都学了一遍。当然目前还有一些细节需要打磨。例如 gop mod tidy 这个指令还有待完善,它将是我们下周优化的重点。在这件事情上,整体就一句话,把 Go module 的能力无差别地搬到 Go+ 上来。其二,Go/Go+ 代码的混合工程。你可以在同一个工程中一部分代码用 Go 写,另一部分用 Go+ 写,两者相互可以自由调用对方。例如:这个例子中,我们在 Go 中调用了 Go+ 的 sayMix 函数,而 Go+ 则调用了 Go 的 p 函数。这种循环依赖是允许的,可以正常进行编译执行。其执行结果如下:我们将通过这个方式来实现对 Go 语言的 cgo 及泛型的支持。这一点我们会在下个里程碑 Go+ v1.2 中实现。其三,通过 c2go 实现对 C 的支持。这一点从能力上已经完全实现,只是 c2go 本身能力还会长期进行迭代。以下是 Go+ 调用 C 的简单例子:其中,这里 import "C" 和 Go 语言中是不同的。在 Go+ 中它是 import "C/github.com/goplus/libc" 的简写。引用的 C 包实际上已经通过 c2go 转为 Go 包,但是它比普通的 Go 包会多一个 c2go.a.pub 文件记录所有导出的 公开符号。而 import "C/xxx" 是告诉 Go+ 编译器去读取 c2go.a.pub 文件得到符号列表。这个例子中我们调用了 2 个 C 函数:printf 和 fprintf,使用了一个 C 变量 stderr,使用了 2 个 C 字符串。C 字符串是 Go+ 引入的新语法,通过在字符串前面加 C 前缀即 C"xxx" 来表示 C 风格的字符串。这个例子执行结果如下:这样,我们 Go+ v1.1 版本期望的目标基本上实现了。所以过去的这个周末我发布了 Go+ v1.1.0-rc1 版本。在未来几周内,我们将推出 Go+ v1.1 的正式版本。