许式伟

Go+ 两周年:我们的目标及当前进展

一个多月前,我写了一篇《Go+ 两周年:我们遇到过的那些坑》,大体回顾了 Go+ 这两年的历程。两年前的今天(2020年 5 月 23 日),Go+ 第一个体验版 Go+ v0.6.01 发布,可以说是 Go+ 最正宗的生日。平常也经常会有人问:为什么要做 Go+,它存在的意义是什么?今天我想借这个特殊的时间,展开来谈谈这个话题。

Go+ 目标:工程与低代码的自然融合

如果用一句话来说 Go+ 的目标,我会倾向于:工程与低代码的自然融合。今天工程能力强的语言有很多,基本语言榜长期在榜单前列很久的如 Java、C、C++ 都是典型的工程语言。但今天在榜首的 Python 语言却比较难称之为工程语言。更多人把它看做快速原型(prototype)的生成工具。
今天 Python 语言的发展之所以非常迅猛,背后有两大基本的推动因素:一个是数据智能,一个是低代码。我们我们看 Github 的 Python 趋势榜,可以看到 Python 的热门项目也无非两类,一类是 AI 类,一类是效率类的脚手架(工具)。
Python 语言出现非常早(1991 年),但是它的设计思想还是比较超前的,是迄今少有的面向非专业人士的编程语言。语言看起来比较简洁的也有不少,比如 Ruby 和 CoffeeScript,但是它们的共同特点是语言魔法多,以至于给人的感觉是功能灵活强大,但并非易于掌握,所以并不能算面向非专业人士的。如果非要让我举几个其他面向非专业人士的编程语言,我会倾向于提 Scratch 和 Basic。
低代码本质上是面向非专业人士。今天的主流编程语言设计者仍然把它看作是小众人群。但是 Go+ 无比看重这个群体,认为是今天编程语言革命至关重要的突破口。
将工程能力的门槛降到极低,并且以低代码的形式呈现,让两者自然融合,有没有可能?Go+ 想在这一点上探索出一条新路来。
这也是 Go+ 选择 Go 作为兼容基础的根本原因。在我看来,Go 在工程能力的构建上无比亮眼。最近 Go team 集体出演推荐 Go 时,大家能够深刻感受到他们关注的核心焦点就一个词:scale。这里的 scale 和服务端开发中大家耳熟能详的 scale 不是一回事(虽然这个 scale 在 Go 语言中也很关注),他们最为关注的是如何构建高品质的、鲁棒的大型软件工程。
Image

Go+ 在低代码上的进展
在此基础上,一开始我认为 Go+ 的工程能力已经非常充分了,所以我们的着力点一直在低代码上。让小白可以实现大型复杂工程,不是说说而已,我们思考了很多。

在低代码这个方向上,大部分人选择的路径是 DSL(领域专用语言)。但我个人非常认同有人在 Twitter 上的一句话:为什么大部分 DSL 都没法流行起来?因为它们看起来太像魔法了。
Image
我越来越坚定地认为,在通用语言上实现领域专用知识的表达,远比 DSL 要好很多。原因在于:
开放性 >> 便捷性
大部分人都不自觉地在为了便捷性而放弃了开放性(可连接性),但这其实是非常不划算的一件事情。
在低代码上,我们做了哪些工作?
其一,类脚本化的文法。以 Python、CoffeeScript 为蓝本,我们力求每个引入的语法都通俗易懂,没有那么大的理解负担。用 Go team 喜欢说的一个词:nature。我们希望大家一看这个语法就知道它是干什么的,而不用再额外去查文档。我们在语言文法的特性引入非常克制:我不希望像 Ruby 和 CoffeeScript,会有大家觉得强大但陌生的语法;而希望像 Python 和 Go,只提供大众最容易理解和熟悉的那部分。
其二,引入类文件(class file),降低初学者对面向对象的理解和使用门槛。我们希望非专业开发人员只需用最基础的文法,包括命令(函数的简化理解)与函数、变量、流程控制等,就可以完成一个复杂的工程项目。
而 class file 机制也被我认为是取代 DSL 进行领域知识封装最好的路径。关于这一点,我们后面再另外进行展开。
其三,我们提供了 spx 这个低门槛的 2D 游戏创作引擎,实现和 Scratch 一致的创作体验。它可以被看做 Go+ 实现低门槛创作的范本。这块未来我们还会进一步迭代,增加可视化的编程体验。

Go 在工程化上的缺陷

但后来我意识到 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+ 写,两者相互可以自由调用对方。例如:
Image
这个例子中,我们在 Go 中调用了 Go+ 的 sayMix 函数,而 Go+ 则调用了 Go 的 p 函数。这种循环依赖是允许的,可以正常进行编译执行。其执行结果如下:
Image
我们将通过这个方式来实现对 Go 语言的 cgo 及泛型的支持。这一点我们会在下个里程碑 Go+ v1.2 中实现。
其三,通过 c2go 实现对 C 的支持。这一点从能力上已经完全实现,只是 c2go 本身能力还会长期进行迭代。以下是 Go+ 调用 C 的简单例子:
Image
其中,这里 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 风格的字符串。这个例子执行结果如下:
Image
这样,我们 Go+ v1.1 版本期望的目标基本上实现了。所以过去的这个周末我发布了 Go+ v1.1.0-rc1 版本。在未来几周内,我们将推出 Go+ v1.1 的正式版本。