cgo 和 Go 语言是两码事
cgo不是Go
借用 JWZ 的一句话
有些人,当他们面临一个问题时,认为“我知道,我会使用 cgo ”。那么现在,他们有了两个问题。
最近有人在 Gopher 的 Slack Channel 上使用 cgo,对此我感到十分担心,尤其是竟然有个组织内部打算用一个项目来展示 Go,那真是一个坏主意。对此,我曾说过很多次了,因此也许你们讨厌了我的游说,所以我想到了把它写下来并且去做。
cgo 是一个令人惊异的技术,它允许 Go 程序与 C 的类库交互操作。那是一个极其有用的特征,今天它达到了一个 Go 所无法企及的地位。cgo 是让 Go 程序在 Android 和 iOS 上运行的关键。
然而,这只是我的个人意见,我不为任何人说话,我认为 cgo 在 Go 项目中被过度使用了。我相信当面临需要重载一大段用 Go 编写的 C 代码时,程序员会更愿意选择用 cgo 去打包库而非 Go,因为他们认为那样更容易解决问题。我认为那是虚假经济。
显而易见的,cgo 也存在一些不可避免的问题,最明显的一个问题是作为一个二进制 blog,你不得不与显卡驱动或者窗口系统进行交互 。但是使用 cgo 所存在的问题,经过权衡后,大部分人认为还是比较少的。
当你在 Go 项目上建立一个 cgo 库的时候,你可能没有意识到你的这种权衡其实是不完整的。
构建时间变长
当你在包中引用 import "C",go build 就会做很多额外的工作来构建你的代码,构建就不会仅仅是向 go tool compile 传递一堆 .go 文件了,取而代之的是:
-
cgo 工具就会被调用,在 C 转换 Go、Go 转换C的之间生成各种文件。
-
你系统的 C 编译器会被调用来处理你包中所有的C文件。
-
所有独立的编译单元会被组合到一个 .o 文件。
-
生成的 .o 文件会在系统的连接器中对它的引用进行一次检查修复。
当你针对这个包编程的时候,所有上面的工作在你编译或者测试的过程中都会进行。Go 工具在可能的情况下,会并行的处理这些工作,但是这个包的编译时间会随着所有 C 代码的构建而增加。
你可以将这部分 C 代码重构出这个包来解决,但是如果你不用 cgo,自然也就不会遇到这个问题。
对了,你还必须调试 C 代码在不同平台的兼容性问题。
复杂的构造
Go的目标之一是生产一种语言,它的构造过程就是它自我描述的过程;你程序的资源包含了足够的信息来使用一个工具去创建这个项目。这不是说用 Makefile 来自动化构建工程是不好的,但是在cgo被引进项目之前,你创建并且测试不需要任何东西,除了go工具。后来cgo被引入,设置所有的环境变量、跟踪那些可能被安装在奇怪地方的共享对象和头文件,现在这些你都需要做。
记住,Go支持那些不装载开箱即用的平台,所以你不得不花一些时间去提出一个Windows用户的解决方案。
还有,现在你的用户还要安装C编译器,而不是只安装Go编译器。他们同时还需要安装你项目所依赖的C库,所以你同时还要承担这些支持的成本。
交叉编译不支持
Go对于交叉编译的支持是最好的。根据Go1.5交叉编译,你可以从任何支持的平台到其他任何平台,通过Go项目网站上的官方许可安装程序。
在默认情况下cgo是不允许交叉编译的。 通常这不构成问题,如果你的项目是纯Go的。 当你混合了C库的依赖, 你或者不得不放弃交叉编译你的产品,或者不得不花时间在寻找并且维护C的交叉编译工具链来达成你的目标。
或许你所做的产品仅通过TCP与客户端通信,并且你计划让它在SaaS(软件服务化 Software as a Service)的模型上运行,那么你根本不关心交叉编译就是合理的。然而,如果你在做一个其他人会用的产品,可能会整合到他们的产品中去,或许那是一个监控解决方案,或许那是一个你SaaS服务的客户端,那么你就把他们很容易交叉编译的特性给锁死了。
Go所支持的平台在持续地增长。Go 1.5增加了64位ARM和PowerPC的支持。Go 1.6增加了64位MIPS的支持, IBM的s390体系结构则在Go 1.7. RISC-V中提供。如果你的产品依赖C库,不仅你要面临以上描述的所有交叉编译问题,你还要确保你所依赖的C代码在Go所支持的新平台上能可靠运行——你必须要做这些运用C/Go混合提供的有限的调试。这又引出了下面的问题。
你失去了通向你所有工具的入口
Go有一些伟大的工具;有竞态分析、性能分析、覆盖率、模糊测试和其他源代码分析工具。这些中没有任何一个能够跨越cgo血液或是cgo大脑的屏障。
相反地,像Valgrind这样的优秀工具不理解Go的调用约定和堆栈布局。在这一点上,Ian Lance Taylor的在Go 1.6中将C的内存清理和空指针调试相结合的工作对于cgo的用户来说将会非常有益。
梳理Go的代码和C的代码结果到了两个世界的交汇处,不是结盟;C的内存安全,Go程序的可调试性。
性能永远是一个问题
C代码和Go代码生存在两个不同的宇宙中,cgo横贯了他们两个之间的分界线。这个过渡并不是自由的,它取决于它在你代码中的位置,花费可能是无足轻重的,也可能是巨额的。
C 并不了解 Go 的调用协定或堆栈,所以 Go 语言调用到 C 代码时必须首先记录 Go 函数入口堆栈的所有细节,然后切换到 C 的堆栈,运行 C 代码,这部分 C 代码并不知道它如何被调用,更不知道外部的 Go 语言运行时环境。
公平地说,Go 也不知道任何关于 C 的情况。这就是为什么随着编译器和垃圾收集器在定位无用栈帧和堆方面的发展,Go 和 C 两者之间传递数据的规则变得越来越复杂。
如果在 C 的运行过程中出现一个错误,Go 至少得能做到打印错误堆栈信息,然后退出程序,而不是把核心文件都暴露出来。
管理这种双方互相调用的堆栈,再加上在信号、线程和回调,真的很是不容易的。Ian Lance Taylor 在1.6版本的 Go 语言中做了大量的工作来改进与 C 语言信号处理方面的互操作性。
这里要说的是 C 与 Go 语言之间的的互相调用是比较繁琐的,而且永远是会有性能开销的。
C 是主导,而不是你的代码
你用那种语言捆绑或包装C代码都没关系;Python、有JNI的Java、一些使用了libFFI的语言,或者是有cgo的Go;这是C的世界,你只是在其上生存。
Go代码和C代码必须在资源共享方式上取得一致,如地址空间、信号处理和线程调度——而我所说的一致,是Go要围绕着C的假设。 C代码可以假设它一直在一个线程上运行,或者是无顾虑地在没有任何准备工作的情况下直接运行在有许多线程的环境中。
你不是在写一个Go程序使用C库中的一些逻辑,取而代之的是你在写一个Go程序,它必须与一段容易冲突的、很难被取代的、很难协商的、不顾虑你的问题的C代码共存。
部署变得更复杂
对于普通读者来说,任何关于Go的描述都要包含至少以下词汇中的一点:
简单,静态二进制
这是Go的法宝,它引领Go成为了远离虚拟机和运行管理的典型代表。使用cgo,你就要放弃这些。
根据你的环境,把你的Go项目打成一个deb或rpm包,并且假设你其他的依赖也包装好了,把它们加进安装依赖,然后把问题从操作系统的包管理中抛出,这也许是可行的。但是这几个构建和部署程序的重要改变就像 go build && scp一样仓促直白。
编译一个完全静态的Go程序是可行的,但是如果你的项目中包含了cgo那将是绝不简单的,其后果将影响一整个构建和部署的生命周期。
请明智地选择
要明确,我不是说你不应该使用cgo。但是在你做这笔交易之前,请仔细考虑你同时要放弃的Go的优点。