Go+ c2go: 与 gc 共舞
两周前我们写下了《Go+ c2go: 近2个月 C 标准库的迁移进展》,这段时间里当前 c2go 项目最核心的事项 —— libc 的迁移又有了可喜的进展。
如上图,当前 libc 仓库中 Go 代码的比例首次超过了 C,达到了 58.6%(之前大概只有 10% 左右)。与此同时,单元测试覆盖率也提升到了 26%(之前是 13%)。
一周前,我们发布了 c2go v0.7.10 及 libc v0.3.13。今天我们先大致介绍一下本次发布都更新了什么,然后重点聊一下 c2go 怎么与 Go 的 gc 和谐共存的问题。
c2go v0.7.10:语言更新
我们先来看看 c2go 对语言特性相关的更新。它主要包括:
1、支持导出 struct/union 类型及其成员变量。从语言更新的角度,c2go v0.7.10 的重大更新并不多,最大的变化就是支持了 struct/union 类型及其成员变量的导出。这个功能虽然语义看似简单,但牵涉面较广,本次更新多数变更都与此有关。
2、模块化。和其他的高级语言不同,在 C 语言中所有符号都是平面的,从语法上并没有明显的模块边界。基于这一点,C 这种基于 include 这种以源代码方式的代码引用,和其他语言基于 import 实现模块引用在机理上存在不一致。为此 c2go 需要实现对 C 源代码文件进行模块的自动切分。
前面在《Go+ c2go: 如何实现自动模块切分及品质保障》中,我们已经介绍过自动模块切分的原理。本次更新我们更进一步,被依赖的模块可以被更智能地引用。这个智能体现在我们除了本工程自身源代码的 include 目录外,所有依赖库的 include 目录不再需要去指定。我们唯一需要做的只是在 c2go.cfg 中的 deps 表中导入(类似其他高级语言的 import,只不过 C 语言中我们不是在源代码中 import,而是在工程文件中)。这让经 c2go 转换的 C 模块彻底实现了模块化,成为了地地道道的模块,引用方只需要知晓模块的 package path,其他细节全部都被隐藏了起来。
libc v0.3.13:标准库增强
本次 C 标准库的更新最主要的仍然是进行了大规模的翻译。前面开头我们就已经提及:当前 libc 仓库中 Go 代码的比例首次超过了 C,达到了 58.6%(之前大概只有 10% 左右)。与此同时,单元测试覆盖率也提升到了 26%(之前是 13%)。
从源代码来说,本次主要迁移的有:
./src/stdio(标准输入输出,基本完成)
./src/unistd(UNIX 系统调用封装,还有少量未完成)
./src/misc
这并不算多。所以本次更新更多的代码来自于测试案例。包括:
test/common(测试案例依赖的通用库)
test/acos,acosf,acosl
test/asin,asinf,asinl
test/atan,atanf,atanl
test/atanh,atanhf,atanhl
test/cbrt,cbrtf,cbrtl
test/ceil,ceilf,ceill
test/copysign,copysignf,copysignl
test/erf,erff,erfl
test/expm1,expm1f,expm1l
test/fabs,fabsf,fabsl
test/fdim,fdimf,fdiml
test/floor,floorf,floorl
test/fma,fmal
test/fmax,fmaxf,fmaxl
test/fmin,fminf,fminl
test/hypot,hypotf,hypotl
test/ldexp,ldexpf,ldexpl
test/log10,log10f,log10l
test/log1p,log1pf,log1pl
test/logb,logbf,logbl
test/nearbyint,nearbyintf,nearbyintl
test/nextafter,nextafterf,nextafterl
test/nexttoward,nexttowardf,nexttowardl
test/rint,rintf,rintl
test/scalbln,scalblnf,scalblnl
test/scalbn,scalbnf,scalbnl
test/tanh,tanhf,tanhl
与 gc 共舞
由于测试案例规模的显著增加,c2go 迁移中终于第一次遇到与 gc 相关的问题:代码执行一切正常,但是在程序退出过程中遇到了 gc 出错 —— 非预期的指针。这发生在 go1.16 版本。我本地是 go1.18 版本一切正常,但 libc 的 Github Action 采用的 go1.16.x 版本触发了该错误,暂时我们通过调整 Github Action 使用的 Go 版本为 go1.18.x 来解决。
这个问题是预期中的:C 指针可以移动,而 Go 指针不能(这意味着 C 指针移动到过尾值可能会导致 Go 对指针指向理解的歧义)。C 指针指向的内存可以主动提前释放,而 Go 指针不能。这些因素都可能导致 Go 的 gc 对内存实际情况的理解出现偏差,并因此导致最终 gc 的崩溃。
解决这个问题有以下几种不同的思路。
方案一是让 Go 不要去管 C 内存。简单说 C malloc 的内存统一在确定的虚拟地址空间范围内分配,在这个范围内的指针值都不参与 gc。
这个方案好处是尽可能保留了 C 代码在语义上的完整性,代价是需要 gc 算法做极小的改动。当然还有一个注意点,就是不要让 C 指针指向 Go 内存。
方案二是将 C 指针(T*、T[]、void* 等)转译为 uintptr,只在读写内存的时候临时将其转换为指针类型。这样,C 转换后的 Go 代码可以认为不再存在指针,自然也就不需要参与 gc 了。
这个方法缺点是生成的 Go 代码很晦涩,使用上也不方便。
我们以 chdir 函数为例。它的 C 函数原型为:
int chdir(const char* dir);正常来说,它的原型应该翻译成:
func Chdir(dir *int8) int32但是为了避免 gc 问题,选择将指针以 uintptr 代替。于是变成:
func Chdir(dir uintptr) int32在这种情况下,我们光看 Chdir 函数原型本身,是看不出 dir 是什么类型的,因为指针类型有太多种类了。
其实还有方案三。它本质上是方案二的变种,但我们利用 Go 的泛型能力去尽可能保留 C 指针的类型信息。
怎么做到?引入智能指针类型:
type Cptr[T any] struct {Data uintptr}
这样,T* 类型就转译成为 Cptr[T] 类型,这个类型自然也不会参与 gc,但类型信息被完整保留了下来。
当然这个方案的问题也很明显,转换后的代码必须用 go1.18 以上版本才能进行编译。
我们最终选择了介于方案二和方案三之间的一个折中方案:从生成的 Go 代码来看,我们按照方案二来执行。但是对于一个 C 模块的所有公开符号,我们通过 c2go.pub 文件保留其类型信息。
之前 c2go.pub 文件是很简单的,它只是记录哪些符号是公开的。例如:
chdirerrnogetenv
现在它变成了:
func chdir(dir *int8) int32var errno int32func getenv(name *int8) *int8
保留类型信息的目的是让一个 C 模块被 Go+ 引用的时候,仍然基于强指针类型的引用方式。例如,对于 chdir 的使用,我们希望仍然是:
C.chdir(C"/foo/bar")而不是将对 C 指针的使用都改为基于 uintptr 类型来表达。
我们不希望因为 gc 的原因而破坏程序的自然语义。转换将 C 指针转为 uintptr 这种事情,由 Go+ 的编译器来完成它就好了。
这样,与 gc 共舞这件事情所产生的影响,对 Go+ 来说就只是实现层面的改变。从使用界面来说,与之前并没有发生变化。当然,从 Go 的视角来说,事情的确发生了巨大的变化,而这也可以认为是 Go+ 相对 Go 而言在兼容 C 代码上很重要的一个优势。
如果你对 c2go 和 Go+ 感兴趣,可以扫描以下二维码加入讨论: