0%

本文记录学习到的一些与 go 语言编译相关的内容。

编译速度

go 是编译速度比较快的语言之一,主要有以下几个原因,参考链接

  • 语法较为简单,不允许重载,不需要复杂的语义分析
  • 使用包管理,而非头文件形式,避免了解析头文件
  • 不使用虚拟机,编译时不需要加载 VM
  • 大部分包都使用静态链接的方式
  • 优化器更为简单,编译期优化较少,同时导致性能一定程度上下降,参考链接

编译缓存

在 C/C++ 中,大型项目的构建往往需要依赖 make 和 CMake 工具来提供编译缓存,以实现增量编译的功能。在 golang 中,从 1.10 版本后编译器支持了编译缓存的功能,可以进行增量构建。编译过程中的缓存文件将会被存放在环境路径 GOCACHE 下。此外,go test 也支持在特定条件下缓存 test 结果,从而加快执行测试的速度。

增量编译

go 语言的增量编译是以 package 为单位的,一个 package 内任意一个文件的变更都会导致整个 package 以及依赖该 package 的所有 package重新编译 。但是,golang 的文件修改判别依据相比 C/C++ 体系更加合理。在 qt5 中,使用 qmake 进行增量编译时,依赖于文件的修改时间戳,如果只是对文件进行了格式调整,也会导致项目重新编译。而在 golang 中,文件修改的判别依据是文件内容是否改变,只修改文件的行数、增加注释、删除后恢复内容都不会导致package 重新编译

在 GOCACHE 路径下,执行 tree 命令,可以得到如下输出(截取部分):

1
2
3
4
5
6
7
8
9
10
11
go-build % tree

.
├── 00
│   ├── 000d954e953e73d70b0fa00ceaf3c68f3adbb5164e5381754493a4e592ad714f-a
│   └── 0068e9620015a00b938707781ad8b56fdd86fe4a09eeb7802d874bd041f5ad79-a
├── 01
│   ├── 0107518e42b66ffb35a353ffd437cb2a6f448b07ad8fceba401e7bc1692c17e3-d
│   ├── 01126d3106b1aee39e975dcd10a2ac64f170d28ca14b37c8c5e7cec7817e60fd-a
...
└── trim.txt

可以看到,golang 中使用了内容摘要算法来组织存储,每一个目录下存储的文件名前两位字符都对应着文件夹名称。在编译过程中,go 编译器会将编译后 package 的 .a 文件求取 64 位摘要值,并将其名称命名为”摘要值-a” 的形式,并存储在摘要值前两位对应的文件夹下。

go test 缓存

在go 1.10中,go test 同样可以被缓存介入,不过需要满足一定的条件,go release note 中给出了缓存介入条件:

  • 本次测试的执行程序以及命令行(及参数)与之前的一次test运行匹配;
  • 上次测试执行时的文件和环境变量在本次没有发生变化;
  • 测试结果是成功的;
  • 以package list mode运行测试;
  • go test的命令行参数使用”-cpu, -list, -parallel, -run, -short和 -v”的一个子集时。

其中 package list mode 是指运行某个 package 的测试程序,而不是以当前目录为参数。

交叉编译

go build 工具链是支持交叉编译的,在不直接调用 C 代码的情况下,可以直接使用 go build 命令来完成交叉编译。在使用交叉编译时,需要通过变更 GO ENV 来控制编译输出。常用的环境变量如下:

  • CGO_ENABLE:关闭 CGO 选项,需要关闭,因为交叉编译不支持 CGO;
  • GOOS:编译的目标操作系统,如 linux、darwin、windows;
  • GOARCH:编译的架构,如 386、amd64、arm。

交叉编译示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# x86_64 linux
CGO_ENABLED=0
GOOS=linux
GOARCH=amd64
go build main.go

# x86_64 Windows
CGO_ENABLED=0
GOOS=windows
GOARCH=amd64
go build main.go

# arm Macos
CGO_ENABLED=0
GOOS=darwin
GOARCH=arm
go build main.go

如果在项目中依赖了 C 代码,则不能够使用 go build 工具来实现交叉编译,需要要借助第三方工具 xgo。

编译期赋值

go 语言可以实现在编译时对变量赋值,以完成在代码中增加版本等信息。该功能需要借助 -ldflags 选项。

1
2
3
4
5
var(
var version = "v0.0.0"
buildTime string
buildVersion string
)

直接使用 -ldflags 选项即可,不需要再借助 flag parse。

1
2
3
4
5
6
7
8
go build -ldflags \ 
"-X main.version=v0.0.1 -X main.dateTime=`date +%Y-%m-%d,%H:%M:%S` -X main.gitTag=`git tag`"

# 输出值
version is: v0.0.1
dateTime is: 2023-01-14,22:18:54
gitTag is: v0.0.0-beta

这是通过将值写入符号表来完成的,符号表用来存储程序中的标识符(即常量和变量的类型、值等相关数据)。go 语言编译期过程可以简化理解为:在编译期将部分的变量的值修改,相当于改变了变量的默认值。

条件编译

go 语言中不支持 define,但是可以依赖build tags或文件后缀的方式来实现不同平台的条件编译。

build tags

build tags 是一种特殊的注释,它必须位于 package 声明的上方,并且后跟一个空行。当一个包被编译时,编译器会根据构建标签的内容来判断该包是否需要编译。

build tags 可以指定以下内容:

  • 操作系统,环境变量中GOOS的值;
  • 操作系统的架构,环境变量中GOARCH的值;
  • 使用的编译器,gc或者gccgo
  • 是否开启CGO,cgo
  • golang版本号, 如go1.1
  • 其它自定义标签,通过go build -tags指定的值。

以下为 build tags 的一个例子。

1
2
3
4
5
// +build linux darwin
// +build x86

package os
...

build tags 需要遵循以下原则:

  • 每一行注释以+build开始;
  • 每个选项由数字和字母组成,如果开头为!代表反义;
  • 选项之间相隔' '代表或关系,选项之间相隔,代表与关系;
  • 不同行注释代表与关系

上面示例中代表在 linux 或 darwin 平台且架构为 x86 的情况下才会编译。

文件名后缀

类似测试文件的 _test后缀,go 语言中也可以通过添加后缀的方式来实现条件编译。文件名后缀的命名方式为:filename(_$GOOS)(_$GOARCH).go。其中 GOOS 如果出现,必须排列在 GOARCH 前面。

文件名后缀只能够实现在特定条件下进行编译,而不能实现在特定条件下取消编译。

Go Plugin

参考链接

kfifo 是 linux kernel 中一个简介优雅的无锁 ring_buffer 实现,能够保证在单线程写入和读线程读取场景下的线程安全。kfifo 的实现中使用了许多比较特殊的操作,值得进行学习。

kfifo 数据结构

kfifo 的数据结构非常简单,只有缓冲区指针及大小、读取写入偏移量,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
struct kfifo {
// 写入 offeset
unsigned int in;
// 读取 offset
unsigned int out;
// 缓冲区大小
unsigned int size;
// 缓冲区指针
void *buffer;
// 自旋锁,新版本已经移出
spinlock_t *lock;
};

kfifo 实现巧妙之处主要有三点:

  • 通过位运算判断 2 的幂以及向上取整为 2 的幂;
  • 不进行模运算,而是利用溢出来实现读写偏移量计算;
  • 使用内存屏障来保障单消费者、单生产者的无锁并发访问。

位运算部分

kfifo struct 中,要求缓冲区大小必须为 2 的幂,这是为了更加高效地判断计算出当前偏移量对应的内存位置。在 kfifo 初始化阶段,通过两个巧妙的位运算解决了判断 2 的幂以及向上取整为 2 的幂两个问题。

判断一个数是否为 2 的幂

在二进制存储中,如果一个数 n 是 2 的幂,那么这个数可以表示为 100···,除最高位外全部为 0。同样地,n-1 就可以表示为 0111…,除最高位外全部为 1。可以观察到 n 和 n-1 在二进制中,每一位都是相反的,因此 n & (n - 1) == 1。可以根据这种操作来利用位运算更快地判断一个数是否为 2 的幂。linux kernel 中的实现如下:

1
2
3
static inline bool is_power_of_2(uint32_t n){
return (n != 0 && ((n & (n - 1)) == 0));
}

将一个数取整为 2 的幂

linux kernel 中的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 求大于一个数的 2 的幂次数
static __inline__ int generic_fls(int x){
int r = 32;

// 判断 x 是否为 0
if (!x)
return 0;
// 判断最高八位是否有 1
if (!(x & 0xffff0000u)) {
// 如果没有 1,则左移
x <<= 16;
r -= 16;
}
// 若上一次有 1,由于没有左移,因此是判断最高四位是否有 1
// 若上一次没有 1,判断的则是后八位中的前四位
if (!(x & 0xff000000u)) {
x <<= 8;
r -= 8;
}
if (!(x & 0xf0000000u)) {
x <<= 4;
r -= 4;
}
if (!(x & 0xc0000000u)) {
x <<= 2;
r -= 2;
}
if (!(x & 0x80000000u)) {
x <<= 1;
r -= 1;
}
return r;
}

// 求大于一个数的 2 的幂
static inline unsigned long __attribute_const__ roundup_pow_of_two(unsigned long x)
{
return (1UL << generic_fls(x - 1));
}

这本质上是利用二分查找的原理,使用状态机的一种实现。主要的巧妙之处是利用了左移操作将每一次二分查找的分支进行了合并,使得代码变得更为简洁。

利用溢出实现读写偏移量计算

对一个数取模,从而使其保持在一定范围内,是常见的操作数组、缓冲区等长度固定数据结构的操作。但相对来说,取模的运算速度会相对较慢。kfifo 为了实现更快的偏移量计算,并没有使用取模操作,而是使用位运算使长度溢出来实现的。

以写入为例,该部分的核心代码可以简化为(将部分进行了聚合处理):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
unsigned int __kfifo_get(struct kfifo *fifo,
unsigned char *buffer, unsigned int len)
{
unsigned int l;

// 可以写入的最大长度,由于 fifo->in 只会增长,即使有线程写入
// 也只会导致可读区域变大,因此 len 是安全的
len = min(len, fifo->in - fifo->out);

// 内存屏障
smp_mb();

// 读当前位置到缓冲区末尾的数据
l = min(len, fifo->size - (fifo->out & (fifo->size - 1)));
memcpy(buffer, fifo->buffer + (fifo->out & (fifo->size - 1)), l);

// 从缓冲区头读取剩余数据
memcpy(buffer + l, fifo->buffer, len - l);

fifo->out += len;

return len;
}

先忽略内存屏障,这里用到了几个小技巧。由于写入和读取的偏移量 kfifo->inkfifio->out 都是无符号整形,即使 kfifo->in因为过大而发生了溢出,也能够保持kfifo->in - kfifio->out <= kfifo->size。 因为 unsigned long类型的加减是回环的。

1
2
3
// m 是 unsigned long 最大值
m := uint64(1<<64 - 1)
println(m + 5 - m) // 输出为 5

另外一个比较巧妙的地方是,没有使用取模来计算偏移量,而是修改为了位运算的方式,这依赖于 kfifo 的缓冲区大小限定为 2 的幂。

1
2
3
4
5
// 传统取模方式
int offset = kfifo->in % kfifo->size

// kfifo 取模方式
int offset = kfifo->in & (kfifo->size - 1)

在二进制中,kfifo->size 可以表示为 1000···,那么 kfifo->size - 1 就可以表示为 0111···;同样地,kfifo->in 也可以分解为 offset + n2,其中 n2 是 kfifo->size的倍数,offset 是 kfifo->in % kfifo->size。由于 n2 是 kfifo->size 倍数,所以其数据只分布在大于 kfifo->size 位数的部分,因此只需要截取 kfifo->in 小于 kfifo->size 位数的部分,就可以得到 offset。观察到,kfifo->size - 1在每一个小于 kfifo->size 的位上的值均为 1,那么只要进行运算 kfifo->in % kfifo->size ,超出 kfifo->size 位数的部分会被截去,而小于 kfifo->size 位数的部分会被保留,即得到 offset。

还有一个比较巧妙的点是没有使用 if 进行判断,直接使用了两个memcpy。这里是直接利用 memcpy 的第三个参数进行了控制,当第一次 memcpy 完全拷贝时,第二次 memcpy 将不进行任何操作。

内存屏障

在计算机操作系统中,一个读线程和一个写线程同时操作一个变量时不需要进行加锁的。在 kfifo 中,读操作与写操作分别只会更新 kfifo->out 和 kfifo->in,因此不会出现并发问题。

但是,参考《C++并发编程实践》第 123 页:“无论对象是怎么样的类型,对象都会存储在一个或多个内存位置上。每个内存位置不是标量类型的对象,就是标量类 型的子对象,比如,unsigned short、my_class*或序列中的相邻位域。当使用位域时就需要注意:虽然相邻位域 中是不同的对象,但仍视其为相同的内存位置。如图5.1所示,将一个struct分解为多个对象,并且展示了每个对象 的内存位置。”如下图,bf1 和 bf2 被保存在相同的位置。

image-20230113062116997

这种情况下,bf1 和 bf2 可能会有多个副本分别存储在不同 CPU 的 Cache 中,如果线程 th1 写入 bf1 的同时线程 th2 读取bf2,那么 th2 可能会读取到在 CPU Cache 中的过期数据。由于 kfifo 中的成员都是基本类型,它们很有可能会被存储在相同的内存位置,因此需要引入内存屏障来保证以下写入顺序:

  • kfifo->data 写入数据;
  • 更新 kfifo->in 值。

这也就是读取和写入操作中 smp_mb()的作用,用于强制保证内存的更新是全局的。

源码链接