标签 Compiler 下的文章

一文搞懂如何在Go包中支持Hash-Based Bisect调试

本文永久链接 – https://tonybai.com/2024/mm/dd/how-to-support-hash-based-bisect-in-go-package

bisect是一个英文动词,意为“二分”或“分成两部分”。在数学和计算机科学中,通常指将一个区间或一个集合分成两个相等的部分。

对于程序员来说,最熟悉的bisect应用莫过于下面两个:

  • 算法中的二分查找(binary search)

二分查找是一个经典且高效的查找算法,任何一本介绍数据结构或计算机算法的书都会包含对二分查找的系统说明。所谓二分查找就是通过不断将搜索区间一分为二来找到目标值。一些排序算法也应用了bisect的思想,比如快速排序(QuickSort)等。

  • git bisect

git bisect是一个非常实用的Git命令,它通过二分查找的方式有效缩小可能导致错误的提交范围,帮助开发人员快速定位引入错误的提交。其工作原理是反复从版本控制系统中检出不同的提交并运行测试,将结果标记为“good”或“bad”。这个过程持续进行,直到找到引入bug的具体提交(bad commit):

git bisect特别适用于当你怀疑某个bug是由于代码库历史中的特定更改引起时,这种情况在日常开发中非常常见。

然而,并非所有的bug都能通过git bisect查找出来。尤其在编译器、运行时库以及大型复杂项目中,问题往往潜藏在难以排查的调用栈、数据流或代码路径中。在这些情况下,git bisect这种传统的工具可能会显得力不从心。

注:如果你还不熟悉git bisect的使用方法,可以参考本文后面附录中的入门示例。

在今年7月份,Go团队前技术主管Russ Cox在他的博客上发表了一篇题为“Hash-Based Bisect Debugging in Compilers and Runtimes”的文章,介绍了Go编译器和运行时团队内部使用的高级调试技术——Hash-Based Bisect。这一技术为我们提供了一种全新的问题定位方式。

在这篇文章中,我将带领大家深入了解Hash-Based Bisect这一高级调试技术,探索如何让我们自己的Go包支持这一调试技术,以及如何在日常开发中帮助我们快速定位一些难以排查的潜在问题。

1. Hash-Based Bisect是什么

前面提到过,git bisect常用于代码提交历史的回归问题排查。然而,当问题不是由提交历史引发,而是涉及程序行为的动态变化时,git bisect便显得无能为力。例如:

  • 某些代码路径或优化规则在特定运行时触发错误。
  • 测试程序在调用栈中的某些路径上表现异常。
  • 多线程或并行执行中,因运行时调度导致的问题。

Hash-Based Bisect正是为了解决这些问题而设计的。它突破了静态版本的局限,将调试范围扩展到了动态行为层面。

那么Hash-Based Bisect究竟是什么技术呢?它是一种基于哈希值和二分搜索的调试技术,旨在快速定位复杂程序中导致问题的最小变化点集合。通过为代码中的变化点(如函数、行号或调用栈)生成唯一的哈希值,该技术将程序行为映射到这些标识符上。接着,通过逐步启用或禁用特定变化点,结合测试程序的运行结果,递归缩小问题范围,最终定位问题根源(某几行代码甚至是某一行代码):

与git bisect专注于找到引入错误的提交不同,基于散列的bisect不会去遍历版本历史,而是直接对代码的结构和执行流进行操作,其调试的结果也不会与特定提交相关,而是与代码与特定执行路径或功能的交互相关,即精确定位特定的代码行,函数调用,甚至是触发失败的调用堆栈

下面我们再来仔细说明一下该技术的工作原理。

2. Hash-Based Bisect的工作原理

Hash-Based Bisect的核心在于利用哈希值为程序的变化点(如函数、代码行、调用栈等)分配唯一标识,并通过二分搜索算法,逐步缩小问题范围。它通过动态启用或禁用这些变化点,结合测试结果判断问题是否被触发,从而定位导致问题的最小变化集。

这个方法有两个关键要素:

  • 变化点的唯一标识

在Russ Cox的文章中,他提及了一些传统的二分方法,比如List-Based Bisect-Reduce、Counter-Based Bisect-Reduce等,但这些方法存在编号顺序不稳定、多变化点调试困难、扩展性有限以及不适合并发或动态场景等问题。

而通过哈希函数生成变化点的标识,确保无论代码执行顺序、环境或并发情况如何,变化点的标识始终唯一且稳定的。同时输入更为简洁,通过简短的哈希模式(如001+110),避免长列表或复杂编号,并且可适配多种问题类型(优化规则、运行时行为、动态调用栈等)。

  • 二分搜索

利用二分搜索算法在运行时动态启用和禁用变化点,高效缩小问题范围,减少需要手动排查的复杂度。

下面我们再通过Hash-Based Bisect的典型工作流程来进一步理解它的原理。

首先是定义变化点

将程序中可能导致问题的变化点抽象出来,比如:

  • 函数(函数名、文件路径)
  • 代码行(文件路径和行号)
  • 调用栈(运行时捕获)

接下来,生成变化点的唯一哈希值

以Go当前的hash-based bisect工具以及支持该工具调试的Go包为例,对于每个变化点,Go包需要通过bisect.Hash方法生成哈希值,用于唯一标识。例如:

id := bisect.Hash("foo.go", 10) // 生成foo.go文件第10行的唯一标识。

第三步,利用二分搜索进行自动的递归测试。具体来说,就是通过二分搜索逐步启用或禁用变化点:

  • 启用一个变化点集合,运行测试程序,观察是否触发问题。
  • 根据测试结果缩小范围,继续递归,直到找到最小变化点集合。

最后,报告变化点,即最终输出导致问题的最小变化集,帮助开发者快速定位问题。

Russ Cox文章中给了一个“某个函数的编译优化规则导致测试失败”的例子,例子中包含一组数学函数:

add, cos, div, exp, mod, mul, sin, sqr, sub, tan

要针对这个问题场景使用hash-based bisect进行调试,第一步就是要定义函数变化点,并为每个变化点生成唯一哈希值标识:

add: 00110010
cos: 00010000
sin: 11000111
...

然后启用二分搜索,利用Hash-Based Bisect工具依次禁用某些函数的优化,逐步缩小范围。例如:

第一步:禁用add, cos, div, exp, mod,测试通过。
第二步:禁用mul, sin, sqr, sub, tan,测试失败。
第三步:进一步细分,最终定位sin为导致问题的函数。开发者只需检查该函数的优化规则即可解决问题。

原文章中,Russ Cox利用函数变化点哈希值的位后缀构建了一颗二叉树(如下图),并利用后缀模式的不同进行问题定位:


图来自Russ Cox博客

了解了大致的工作原理后,我们再来看看Hash-Based Bisect在Go项目中的使用现状。

3. Hash-Based Bisect在Go项目中的使用现状

目前Hash-Based Bisect已经成为Go项目编译器和运行时的重要调试工具之一,其工具链(golang.org/x/tools/cmd/bisect)和库(golang.org/x/tools/internal/bisect)提供了强大的功能支持,帮助Go团队在编译器开发、运行时库升级和语言特性修改等场景下快速定位问题。

Go实现的hash-based bisect调试技术包含两部分:

bisect命令行工具可用于驱动测试运行(如go test)并自动化调试过程,支持灵活的模式定义(如-godebug、-compile选项),结合用户输入定位问题点。

  • golang.org/x/tools/internal/bisect包

该包为库和工具开发者提供一个接口,轻松实现与bisect工具的集成。并且提供了哈希生成、启用判断和变化点报告等功能,适配复杂调试需求。

上述工具目前在Go编译器的SSA(静态单赋值)后端开发、Go运行时库升级(比如Go 1.23的Timer Stop/Reset的新实现)以及语言特性的修改(比如loopvar语义变更)等方面都有重要的应用,大大提高了Go团队在定位复杂问题时的调试效率。

以上工具和包在Go项目中已经演化多年,颇为成熟。Russ Cox已经发起提案#67140,旨在将golang.org/x/tools/internal/bisect包发布为标准库debug/bisect包,这样编译器、运行时、标准库甚至标准库之外的包都可以基于它提供的功能实现与bisect工具的兼容,并利用bisect工具实现基于变更点hash值的高级调试。

讲到这里,屏幕前的你是否已经感到“迫不及待”了呢?这样优秀的工具!我们现在能否使用它?是否可以将其应用于我们自己的Go包的调试过程中呢?接下来,我就来用一个示例演示一下如何让我们自己的包支持Go bisect工具,以帮助我们提升调试效率。

4. 让你的库支持Hash-Based Bisect调试

要利用bisect调试技术,我们首先要解决的是bisect包位于internal中的问题,好在Russ Cox在实现bisect包时考虑了这个问题,bisect包没有任何外部依赖,连Go标准库都不依赖,这样避免了后续变为debug/bisect后导致标准库循环依赖的问题。现在,我们可以将它直接copy出来,放到我们自己的工程中使用。

下面是我准备的示例的目录结构:

$tree -F hash-based-bisect/bisect-demo
hash-based-bisect/bisect-demo
├── bisect/
│   └── bisect.go
├── foo/
│   ├── foo.go
│   └── foo_test.go
└── go.mod

其中bisect目录下的bisect.go来自github.com/golang/tools/blob/master/internal/bisect/bisect.go,foo包是我们这次要调试的目标包,我们先来看看foo.go的代码:

// bisect-demo/foo/foo.go

package foo

import (
    "bisect-demo/bisect"
    "flag"
)

var (
    bisectFlag = flag.String("bisect", "", "bisect pattern")
    matcher    *bisect.Matcher
)

// Features represents different features that might cause issues
const (
    FeatureRangeIteration  = "range-iteration"  // Using range vs classic for loop
    FeatureConcurrentLogic = "concurrent-logic" // Adding concurrent modifications
)

func Init() {
    flag.Parse()
    if *bisectFlag != "" {
        matcher, _ = bisect.New(*bisectFlag)
    }
}

func ProcessItems(items []int) []int {
    result := make([]int, 0, len(items))

    // First potential problematic change: different iteration approach
    id1 := bisect.Hash(FeatureRangeIteration)
    if matcher == nil || matcher.ShouldEnable(id1) {
        if matcher != nil && matcher.ShouldReport(id1) {
            println(bisect.Marker(id1), "enabled feature:", FeatureRangeIteration)
        }
        // Potentially problematic implementation using range
        for i := range items {
            result = append(result, items[i]*2)
        }
    } else {
        // Correct implementation using value iteration
        for _, v := range items {
            result = append(result, v*2)
        }
    }

    // Second potential problematic change: concurrent modifications
    id2 := bisect.Hash(FeatureConcurrentLogic)
    if matcher == nil || matcher.ShouldEnable(id2) {
        if matcher != nil && matcher.ShouldReport(id2) {
            println(bisect.Marker(id2), "enabled feature:", FeatureConcurrentLogic)
        }
        // Potentially problematic implementation with concurrency
        for i := 0; i < len(result); i++ {
            go func(idx int) {
                result[idx] += 1 // Race condition
            }(i)
        }
    }

    return result
}

大家可以结合前面提及的Hash-Based Bisect的典型工作流程来理解上面的代码。

首先,我们模拟可能导致问题的两个功能特性并定义了变化点,变化点由特性标识符的hash值标识,这里我们定义的特性标识符为:

const (
    // 使用有意义的特性名称作为 hash 的输入
    FeatureRangeIteration  = "range-iteration"  // 使用 range vs 经典 for 循环
    FeatureConcurrentLogic = "concurrent-logic" // 添加并发修改逻辑
)

接下来,对于每个可能有问题的变化点,都遵循相同的模式:

// 1. 计算特性的唯一Hash值
id1 := bisect.Hash(FeatureRangeIteration)

// 2. 检查是否应该启用该特性
if matcher == nil || matcher.ShouldEnable(id1) {
    // 3. 如果需要,报告该特性被启用
    if matcher != nil && matcher.ShouldReport(id1) {
        println(bisect.Marker(id1), "enabled feature:", FeatureRangeIteration)
    }

    // 4. 执行可能有问题的实现
    for i := range items {
        result = append(result, items[i]*2)
    }
} else {
    // 5. 执行正确的实现
    for _, v := range items {
        result = append(result, v*2)
    }
}

这里对matcher == nil的检查算是一个小优化:当不在bisect调试模式时,matcher为nil。此时我们直接启用所有特性,不需要计算hash和调用其他方法。

代码中的ShouldEnable()决定是否启用该特性的代码,ShouldReport() 决定是否需要报告该特性被启用。这两个可能返回不同的值,尤其是在bisect搜索最小失败集合时。

Marker()用于生成标准格式的匹配标记,这些标记会被bisect工具用来识别和追踪启用了哪些特性,标记会在最终输出中被移除,只显示实际的描述文本。

这里还有一个接收bisect pattern的设置,我们是通过命令行参数来接收bisect每次传给foo包的Pattern的,这里我们在Init函数,而不是init函数中调用Parse,是因为如果在init函数中调用Parse,会干扰go test测试框架,导致出现类似“flag provided but not defined: -test.paniconexit0”的测试执行错误。

下面是foo_test.go的代码:

// bisect-demo/foo/foo_test.go

package foo

import (
    "flag"
    "testing"
    "time"
)

func TestMain(m *testing.M) {
    flag.Parse()
    Init()
    m.Run()
}

func TestProcessItems(t *testing.T) {
    input := []int{1, 2, 3, 4, 5}
    result := ProcessItems(input)

    // Wait for all goroutines to complete
    time.Sleep(1000 * time.Millisecond)

    // Verify results
    if len(result) != len(input) {
        t.Fatalf("got len=%d, want len=%d", len(result), len(input))
    }

    // Check if results are correct
    for i, v := range input {
        expected := v * 2
        if result[i] != expected {
            t.Errorf("result[%d] = %d, want %d", i, result[i], expected)
        }
    }
}

显然为了foo包能成功获取命令行参数,我们重写了TestMain,在其中调用了foo.Init函数。

接下来,我们就来执行一下bisect工具,对foo包进行一下调试,你可以通过go install golang.org/x/tools/cmd/bisect@latest安装bisect。此外下面bisect命令行中的PATTERN是一个“占位符”,bisect命令会识别该“占位符”,并将其替换为相应的字符串,这个在bisect的执行过程中你也会看到:

// 在hash-based-bisect/bisect-demo/foo目录下执行

$bisect -v go test -v -args -bisect=PATTERN
bisect: checking target with all changes disabled
bisect: run: go test -v -args -bisect=n... ok (0 matches)
bisect: matches:
bisect: run: go test -v -args -bisect=n... ok (0 matches)
bisect: matches:
bisect: checking target with all changes enabled
bisect: run: go test -v -args -bisect=y... FAIL (2 matches)
bisect: matches:
[bisect-match 0xcf0b8943315a7804] enabled feature: range-iteration
[bisect-match 0x4d642a7960e4693f] enabled feature: concurrent-logic
bisect: run: go test -v -args -bisect=y... FAIL (2 matches)
bisect: matches:
[bisect-match 0xcf0b8943315a7804] enabled feature: range-iteration
[bisect-match 0x4d642a7960e4693f] enabled feature: concurrent-logic
bisect: target succeeds with no changes, fails with all changes
bisect: searching for minimal set of enabled changes causing failure
bisect: run: go test -v -args -bisect=+0... ok (1 matches)
bisect: matches:
[bisect-match 0xcf0b8943315a7804] enabled feature: range-iteration
bisect: run: go test -v -args -bisect=+0... ok (1 matches)
bisect: matches:
[bisect-match 0xcf0b8943315a7804] enabled feature: range-iteration
bisect: run: go test -v -args -bisect=+1... FAIL (1 matches)
bisect: matches:
[bisect-match 0x4d642a7960e4693f] enabled feature: concurrent-logic
bisect: run: go test -v -args -bisect=+1... FAIL (1 matches)
bisect: matches:
[bisect-match 0x4d642a7960e4693f] enabled feature: concurrent-logic
bisect: confirming failing change set
bisect: run: go test -v -args -bisect=v+x3f... FAIL (1 matches)
bisect: matches:
[bisect-match 0x4d642a7960e4693f] enabled feature: concurrent-logic
bisect: run: go test -v -args -bisect=v+x3f... FAIL (1 matches)
bisect: matches:
[bisect-match 0x4d642a7960e4693f] enabled feature: concurrent-logic
bisect: FOUND failing change set
--- change set #1 (enabling changes causes failure)
enabled feature: concurrent-logic
---
bisect: checking for more failures
bisect: run: go test -v -args -bisect=-x3f... ok (1 matches)
bisect: matches:
[bisect-match 0xcf0b8943315a7804] enabled feature: range-iteration
bisect: run: go test -v -args -bisect=-x3f... ok (1 matches)
bisect: matches:
[bisect-match 0xcf0b8943315a7804] enabled feature: range-iteration
bisect: target succeeds with all remaining changes enabled

简单解读一下这个bisect调试过程的输出。

bisect执行分为几个阶段:

  • 初始检查阶段

首先用-bisect=n禁用所有变更进行测试 → 测试通过(ok)
然后用-bisect=y启用所有变更进行测试 → 测试失败(FAIL)

这表明程序在没有任何变更时是正常的,但启用所有变更后会失败。

启用所有变更时观察到两个特性:

[bisect-match 0xcf0b8943315a7804] enabled feature: range-iteration
[bisect-match 0x4d642a7960e4693f] enabled feature: concurrent-logic
  • 二分查找阶段

测试+0(启用第一个变更:range-iteration)→ 测试通过(ok)
测试+1(启用第二个变更:concurrent-logic)→ 测试失败(FAIL)

这个过程帮助定位到具体是哪个变更导致了失败。

  • 确认阶段

使用v+x3f 模式再次确认 → 测试失败(FAIL)
明确找到了导致失败的变更集合:

--- change set #1 (enabling changes causes failure)
enabled feature: concurrent-logic
---
  • 最终验证

使用-x3f 模式(禁用确认的问题变更)进行测试 → 测试通过(ok)
确认启用其他所有变更(除了concurrent-logic)时程序都能正常运行。

从中得出调试结论:bisect工具成功定位到问题出在concurrent-logic特性上,range-iteration特性是安全的,不会导致测试失败。问题明确是在并发逻辑中的“故意”逻辑导致的,这符合我们的代码实现中的预期问题(在 concurrent-logic 特性中,我们确实故意修改了数据)。

5. 小结

在本文中,我们深入探讨了Hash-Based Bisect这一先进的调试技术,特别是在Go语言项目中的应用。Hash-Based Bisect通过为代码的变化点生成唯一的哈希值,结合二分搜索算法,帮助开发者快速定位复杂程序中的问题,超越传统的git bisect方法。我们还详细介绍了其工作原理、在Go项目中的现状,以及如何将这一技术集成到自己的Go库中,以提升调试效率。也许这里的示例也许并不恰当,但已经达成了我向你展示如何使用bisect工具和bisect包的目的。

尽管Hash-Based Bisect在定位复杂问题上表现出色,但感觉其当前设计仍存在一些不足,这些不足可能会影响开发者的使用体验,尤其是在将其集成到Go包或项目时,这个不足主要体现在对代码的侵入性上。为了支持Hash-Based Bisect,Go包需要显式实现与bisect工具交互的协议,包括支持从命令行或环境变量接收bisect传入的模式(pattern);需要在代码中创建bisect.Matcher对象,并调用ShouldEnable和ShouldReport接口来管理变化点;代码中必须为潜在变化点显式生成唯一的哈希值,并根据需要启用或禁用。

这种显式集成导致代码逻辑被调试相关代码“污染”,增加了代码复杂度和维护成本。对于一些简单的库或项目,开发者可能不愿为调试需求增加这种负担。

在\$GOROOT/src/cmd/compile/internal/base中,编译器相关代码就将bisect封装到了一个HashDebug结构中,一定程度上减少了代码的侵入深度以及手动集成的工作量。

此外,golang.org/x/tools/internal/bisect包尚未正式变为debug/bisect,后续其API是否会发生变化,尚不得而知,本文中的示例代码不保证在后续的Go版本调整后依然能够正确运行。

本文涉及的源码可以在这里下载。

6. 参考资料

7. 附录:git bisect使用示例

假设你有一个Go语言项目,并且发现最近的某次提交引入了一个问题(例如,某个测试用例失败了)。你希望使用git bisect找到引入该问题的具体提交。

你的项目目录设计如下:

my-go-project/
├── main.go
└── main_test.go

我们来建立这个示例项目:

// 在hash-based-bisect/git-bisect下面执行
$mkdir my-go-project
$cd my-go-project
$git init

创建main.go:

// main.go
package main

func main() {
    println("Hello, world!")
}

func Add(a, b int) int {
    return a + b
}

提交变更:

$git add main.go
git commit -m "Initial commit with Add function"
[master (root-commit) 16f8736] Initial commit with Add function
 1 file changed, 9 insertions(+)
 create mode 100644 main.go

创建main_test.go:

// main_test.go
package main

import "testing"

func TestAdd(t *testing.T) {
    if Add(2, 3) != 5 {
        t.Error("Expected 5, got something else")
    }
}

提交变更:

$git add main_test.go
git commit -m "Add test for Add function"
[master b7b3c44] Add test for Add function
 1 file changed, 9 insertions(+)
 create mode 100644 main_test.go

故意引入一个bug并提交变更:

$sed -i 's/return a + b/return a - b/' main.go
$git commit -am "Introduce a bug in Add function"
[master 977e647] Introduce a bug in Add function
 1 file changed, 1 insertion(+), 1 deletion(-)

添加一些其他提交(无关的变更):

$echo "// Just a comment" >> main.go
$git commit -am "Add a comment"
[master 25f88b0] Add a comment
 1 file changed, 2 insertions(+)

这里列出上面所有commit的list,便于后续对照:

$git log --oneline
25f88b0 (HEAD -> master) Add a comment
977e647 Introduce a bug in Add function
b7b3c44 Add test for Add function
16f8736 Initial commit with Add function

接下来,我们就可以演示git bisect了,先来演示一下手工bisect。

启动git bisect模式:

$git bisect start

标记当前最新提交为bad:

$git bisect bad

标记首次提交为good:

$git bisect good 16f8736
Bisecting: 0 revisions left to test after this (roughly 1 step)
[977e647e7461c4c03ee25e53728dd743af925f17] Introduce a bug in Add function

我们看到git bisect自动切换到一个中间的提交,我们需要验证这次中间提交是否能通过测试:

$go test
--- FAIL: TestAdd (0.00s)
    main_test.go:7: Expected 5, got something else
FAIL
exit status 1
FAIL    github.com/bigwhite/experiments/hash-based-bisect/git-bisect/my-go-project  0.006s

测试失败,我们将该提交标记为bad:

$git bisect bad
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[b7b3c444f0fd55086e6ce36fb543a136a1611b61] Add test for Add function

git bisect又切换到了另外一个中间提交,我们用go test验证是否能通过:

$go test
PASS
ok      github.com/bigwhite/experiments/hash-based-bisect/git-bisect/my-go-project  0.005s

测试通过,我们将这个中间提交标记为good:

$git bisect good
977e647e7461c4c03ee25e53728dd743af925f17 is the first bad commit
commit 977e647e7461c4c03ee25e53728dd743af925f17
Author: Tony Bai <bigwhite.cn@aliyun.com>
Date:   Fri Nov 24 13:27:08 2024 +0800

    Introduce a bug in Add function

:100644 100644 e357c05d933724eb8b7c1aafee34b8f95913355e e65baa0414a2a1f983379c23ac549b7d8b056db3 M  main.go

我们看到:git bisect找到了一个bad commit,并显示“977e647e7461c4c03ee25e53728dd743af925f17 is the first bad commit”。

结束git bisect模式:

$git bisect reset

上面的过程可以使用git bisect run进行自动化,而无需中间手动多次的执行go test和标记,下面是一个等价的git bisect过程:

$git bisect start

$git bisect bad

$git bisect good 16f8736
Bisecting: 0 revisions left to test after this (roughly 1 step)
[977e647e7461c4c03ee25e53728dd743af925f17] Introduce a bug in Add function

$git bisect run go test
running go test
--- FAIL: TestAdd (0.00s)
    main_test.go:7: Expected 5, got something else
FAIL
exit status 1
FAIL    github.com/bigwhite/experiments/hash-based-bisect/git-bisect/my-go-project  0.006s
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[b7b3c444f0fd55086e6ce36fb543a136a1611b61] Add test for Add function
running go test
PASS
ok      github.com/bigwhite/experiments/hash-based-bisect/git-bisect/my-go-project  0.006s
977e647e7461c4c03ee25e53728dd743af925f17 is the first bad commit
commit 977e647e7461c4c03ee25e53728dd743af925f17
Author: Tony Bai <bigwhite.cn@aliyun.com>
Date:   Fri Nov 24 13:27:08 2024 +0800

    Introduce a bug in Add function

:100644 100644 e357c05d933724eb8b7c1aafee34b8f95913355e e65baa0414a2a1f983379c23ac549b7d8b056db3 M  main.go
bisect run success

$git bisect reset
Previous HEAD position was b7b3c44 Add test for Add function
Switched to branch 'master'

我们看到通过git bisect run可以更快速地定位问题,而无需中间的手工操作,这是我们日常开发中主要使用的bisect手段!


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go编译的几个细节,连专家也要停下来想想

本文永久链接 – https://tonybai.com/2024/11/11/some-details-about-go-compilation

在Go开发中,编译相关的问题看似简单,但实则蕴含许多细节。有时,即使是Go专家也需要停下来,花时间思考答案或亲自验证。本文将通过几个具体问题,和大家一起探讨Go编译过程中的一些你可能之前未曾关注的细节。

注:本文示例使用的环境为Go 1.23.0、Linux Kernel 3.10.0和CentOS 7.9。

1. Go编译默认采用静态链接还是动态链接?

我们来看第一个问题:Go编译默认采用静态链接还是动态链接呢?

很多人脱口而出:动态链接,因为CGO_ENABLED默认值为1,即开启Cgo。也有些人会说:“其实Go编译器默认是静态链接的,只有在使用C语言库时才会动态链接”。那么到底哪个是正确的呢?

我们来看一个具体的示例。但在这之前,我们要承认一个事实,那就是CGO_ENABLED默认值为1,你可以通过下面命令来验证这一点:

$go env|grep CGO_ENABLED
CGO_ENABLED='1'

验证Go默认究竟是哪种链接,我们写一个hello, world的Go程序即可:

// go-compilation/main.go

package main

import "fmt"

func main() {
    fmt.Println("hello, world")
}

构建该程序:

$go build -o helloworld-default main.go

之后,我们查看一下生成的可执行文件helloworld-default的文件属性:

$file helloworld-default
helloworld-default: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ldd helloworld-default
   不是动态可执行文件

我们看到,虽然CGO_ENABLED=1,但默认情况下,Go构建出的helloworld程序是静态链接的(statically linked)。

那么默认情况下,Go编译器是否都会采用静态链接的方式来构建Go程序呢?我们给上面的main.go添加一行代码:

// go-compilation/main-with-os-user.go

package main

import (
    "fmt"
    _ "os/user"
)

func main() {
    fmt.Println("hello, world")
}

和之前的hello, world不同的是,这段代码多了一行包的空导入,导入的是os/user这个包。

编译这段代码,我们得到helloworld-with-os-user可执行文件。

$go build -o helloworld-with-os-user main-with-os-user.go

使用file和ldd检视文件helloworld-with-os-user:

$file helloworld-with-os-user
helloworld-with-os-user: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), not stripped

$ldd helloworld-with-os-user
    linux-vdso.so.1 =>  (0x00007ffcb8fd4000)
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fb5d6fce000)
    libc.so.6 => /lib64/libc.so.6 (0x00007fb5d6c00000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fb5d71ea000)

我们看到:一行新代码居然让helloworld从静态链接变为了动态链接,同时这也是如何编译出一个hello world版的动态链接Go程序的答案。

通过nm命令我们还可以查看Go程序依赖了哪些C库的符号:

$nm -a helloworld-with-os-user |grep " U "
                 U abort
                 U __errno_location
                 U fprintf
                 U fputc
                 U free
                 U fwrite
                 U malloc
                 U mmap
                 U munmap
                 U nanosleep
                 U pthread_attr_destroy
                 U pthread_attr_getstack
                 U pthread_attr_getstacksize
                 U pthread_attr_init
                 U pthread_cond_broadcast
                 U pthread_cond_wait
                 U pthread_create
                 U pthread_detach
                 U pthread_getattr_np
                 U pthread_key_create
                 U pthread_mutex_lock
                 U pthread_mutex_unlock
                 U pthread_self
                 U pthread_setspecific
                 U pthread_sigmask
                 U setenv
                 U sigaction
                 U sigaddset
                 U sigemptyset
                 U sigfillset
                 U sigismember
                 U stderr
                 U strerror
                 U unsetenv
                 U vfprintf

由此,我们可以得到一个结论,在默认情况下(CGO_ENABLED=1),Go会尽力使用静态链接的方式,但在某些情况下,会采用动态链接。那么究竟在哪些情况下会默认生成动态链接的程序呢?我们继续往下看。

2. 在何种情况下默认会生成动态链接的Go程序?

在以下几种情况下,Go编译器会默认(CGO_ENABLED=1)生成动态链接的可执行文件,我们逐一来看一下。

2.1 一些使用C实现的标准库包

根据上述示例,我们可以看到,在某些情况下,即使只依赖标准库,Go 仍会在CGO_ENABLED=1的情况下采用动态链接。这是因为代码依赖的标准库包使用了C版本的实现。虽然这种情况并不常见,但os/user包net包是两个典型的例子。

os/user包的示例在前面我们已经见识过了。user包允许开发者通过名称或ID查找用户账户。对于大多数Unix系统(包括linux),该包内部有两种版本的实现,用于解析用户和组ID到名称,并列出附加组ID。一种是用纯Go编写,解析/etc/passwd和/etc/group文件。另一种是基于cgo的,依赖于标准C库(libc)中的例程,如getpwuid_r、getgrnam_r和getgrouplist。当cgo可用(CGO_ENABLED=1),并且特定平台的libc实现了所需的例程时,将使用基于cgo的(libc支持的)代码,即采用动态链接方式。

同样,net包在名称解析(Name Resolution,即域名或主机名对应IP查找)上针对大多数Unix系统也有两个版本的实现:一个是纯Go版本,另一个是基于C的版本。C版本会在cgo可用且特定平台实现了相关C函数(比如getaddrinfo和getnameinfo等)时使用。

下面是一个简单的使用net包并采用动态链接的示例:

// go-compilation/main-with-net.go

package main

import (
    "fmt"
    _ "net"
)

func main() {
    fmt.Println("hello, world")
}

编译后,我们查看一下文件属性:

$go build -o helloworld-with-net main-with-net.go 

$file helloworld-with-net
helloworld-with-net: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), not stripped

$ldd helloworld-with-net
    linux-vdso.so.1 =>  (0x00007ffd75dfd000)
    libresolv.so.2 => /lib64/libresolv.so.2 (0x00007fdda2cf9000)
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fdda2add000)
    libc.so.6 => /lib64/libc.so.6 (0x00007fdda270f000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fdda2f13000)

我们看到C版本实现依赖了libresolv.so这个用于名称解析的C库。

由此可得,当Go在默认cgo开启时,一旦依赖了标准库中拥有C版本实现的包,比如os/user、net等,Go编译器会采用动态链接的方式编译Go可执行程序。

2.2 显式使用cgo调用外部C程序

如果使用cgo与外部C代码交互,那么生成的可执行文件必然会包含动态链接。下面我们来看一个调用cgo的简单示例。

首先,建立一个简单的C lib:

// go-compilation/my-c-lib

$tree my-c-lib
my-c-lib
├── Makefile
├── mylib.c
└── mylib.h

// go-compilation/my-c-lib/Makefile

.PHONY:  all static

all:
        gcc -c -fPIC -o mylib.o mylib.c
        gcc -shared -o libmylib.so mylib.o
static:
        gcc -c -fPIC -o mylib.o mylib.c
        ar rcs libmylib.a mylib.o

// go-compilation/my-c-lib/mylib.h

#ifndef MYLIB_H
#define MYLIB_H

void hello();
int add(int a, int b);

#endif // MYLIB_H

// go-compilation/my-c-lib/mylib.c

#include <stdio.h>

void hello() {
    printf("Hello from C!\n");
}

int add(int a, int b) {
    return a + b;
}

执行make all构建出动态链接库libmylib.so!接下来,我们编写一个Go程序通过cgo调用libmylib.so中:

// go-compilation/main-with-call-myclib.go 

package main

/*
#cgo CFLAGS: -I ./my-c-lib
#cgo LDFLAGS: -L ./my-c-lib -lmylib
#include "mylib.h"
*/
import "C"
import "fmt"

func main() {
    // 调用 C 函数
    C.hello()

    // 调用 C 中的加法函数
    result := C.add(3, 4)
    fmt.Printf("Result of addition: %d\n", result)
}

编译该源码:

$go build -o helloworld-with-call-myclib main-with-call-myclib.go

通过ldd可以看到,可执行文件helloworld-with-call-myclib是动态链接的,并依赖libmylib.so:

$ldd helloworld-with-call-myclib
    linux-vdso.so.1 =>  (0x00007ffcc39d8000)
    libmylib.so => not found
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f7166df5000)
    libc.so.6 => /lib64/libc.so.6 (0x00007f7166a27000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f7167011000)

设置LD_LIBRARY_PATH(为了让程序找到libmylib.so)并运行可执行文件helloworld-with-call-myclib:

$ LD_LIBRARY_PATH=./my-c-lib:$LD_LIBRARY_PATH ./helloworld-with-call-myclib
Hello from C!
Result of addition: 7

2.3 使用了依赖cgo的第三方包

在日常开发中,我们经常依赖一些第三方包,有些时候这些第三方包依赖cgo,比如mattn/go-sqlite3。下面就是一个依赖go-sqlite3包的示例:

// go-compilation/go-sqlite3/main.go
package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/mattn/go-sqlite3"
)

func main() {
    // 打开数据库(如果不存在,则创建)
    db, err := sql.Open("sqlite3", "./test.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 创建表
    sqlStmt := `CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);`
    _, err = db.Exec(sqlStmt)
    if err != nil {
        log.Fatalf("%q: %s\n", err, sqlStmt)
    }

    // 插入数据
    _, err = db.Exec(`INSERT INTO user (name) VALUES (?)`, "Alice")
    if err != nil {
        log.Fatal(err)
    }

    // 查询数据
    rows, err := db.Query(`SELECT id, name FROM user;`)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    for rows.Next() {
        var id int
        var name string
        err = rows.Scan(&id, &name)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("%d: %s\n", id, name)
    }

    // 检查查询中的错误
    if err = rows.Err(); err != nil {
        log.Fatal(err)
    }
}

编译和运行该源码:

$go build demo
$ldd demo
    linux-vdso.so.1 =>  (0x00007ffe23d8e000)
    libdl.so.2 => /lib64/libdl.so.2 (0x00007faf0ddef000)
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007faf0dbd3000)
    libc.so.6 => /lib64/libc.so.6 (0x00007faf0d805000)
    /lib64/ld-linux-x86-64.so.2 (0x00007faf0dff3000)
$./demo
1: Alice

到这里,有些读者可能会问一个问题:如果需要在上述依赖场景中生成静态链接的Go程序,该怎么做呢?接下来,我们就来看看这个问题的解决细节。

3. 如何在上述情况下实现静态链接?

到这里是不是有些烧脑了啊!我们针对上一节的三种情况,分别对应来看一下静态编译的方案。

3.1 仅依赖标准包

在前面我们说过,之所以在使用os/user、net包时会在默认情况下采用动态链接,是因为Go使用了这两个包对应功能的C版实现,如果要做静态编译,让Go编译器选择它们的纯Go版实现即可。那我们仅需要关闭CGO即可,以依赖标准库os/user为例:

$CGO_ENABLED=0 go build -o helloworld-with-os-user-static main-with-os-user.go
$file helloworld-with-os-user-static
helloworld-with-os-user-static: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ldd helloworld-with-os-user-static
    不是动态可执行文件

3.2 使用cgo调用外部c程序(静态链接)

对于依赖cgo调用外部c的程序,我们要使用静态链接就必须要求外部c库提供静态库,因此,我们需要my-c-lib提供一份libmylib.a,这通过下面命令可以实现(或执行make static):

$gcc -c -fPIC -o mylib.o mylib.c
$ar rcs libmylib.a mylib.o

有了libmylib.a后,我们还要让Go程序静态链接该.a文件,于是我们需要修改一下Go源码中cgo链接的flag,加上静态链接的选项:

// go-compilation/main-with-call-myclib-static.go
... ...
#cgo LDFLAGS: -static -L my-c-lib -lmylib
... ...

编译链接并查看一下文件属性:

$go build -o helloworld-with-call-myclib-static main-with-call-myclib-static.go

$file helloworld-with-call-myclib-static
helloworld-with-call-myclib-static: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=b3da3ed817d0d04230460069b048cab5f5bfc3b9, not stripped

我们得到了预期的结果!

3.3 依赖使用cgo的外部go包(静态链接)

最麻烦的是这类情况,要想实现静态链接,我们需要找出外部go依赖的所有c库的.a文件(静态共享库)。以我们的go-sqlite3示例为例,go-sqlite3是sqlite库的go binding,它依赖sqlite库,同时所有第三方c库都依赖libc,我们还要准备一份libc的.a文件,下面我们就先安装这些:

$yum install -y gcc glibc-static sqlite-devel
... ...

已安装:
  sqlite-devel.x86_64 0:3.7.17-8.el7_7.1                                                                                          

更新完毕:
  glibc-static.x86_64 0:2.17-326.el7_9.3

接下来,我们就来以静态链接的方式在go-compilation/go-sqlite3-static下编译一下:

$go build -tags 'sqlite_omit_load_extension' -ldflags '-linkmode external -extldflags "-static"' demo

$file ./demo
./demo: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=c779f5c3eaa945d916de059b56d94c23974ce61c, not stripped

这里命令行中的-tags ‘sqlite_omit_load_extension’用于禁用SQLite3的动态加载功能,确保更好的静态链接兼容性。而-ldflags ‘-linkmode external -extldflags “-static”‘的含义是使用外部链接器(比如gcc linker),并强制静态链接所有库。

我们再看完略烧脑的几个细节后,再来看一个略轻松的话题。

4. Go编译出的可执行文件过大,能优化吗?

Go编译出的二进制文件一般较大,一个简单的“Hello World”程序通常在2MB左右:

$ls -lh helloworld-default
-rwxr-xr-x 1 root root 2.1M 11月  3 10:39 helloworld-default

这一方面是因为Go将整个runtime都编译到可执行文件中了,另一方面也是因为Go静态编译所致。那么在默认情况下,Go二进制文件的大小还有优化空间么?方法不多,有两种可以尝试:

  • 去除符号表和调试信息

在编译时使用-ldflags=”-s -w”标志可以去除符号表和调试符号,其中-s用于去掉符号表和调试信息,-w用于去掉DWARF调试信息,这样能显著减小文件体积。以helloworld为例,可执行文件的size减少了近四成:

$go build -ldflags="-s -w" -o helloworld-default-nosym main.go
$ls -l
-rwxr-xr-x 1 root root 2124504 11月  3 10:39 helloworld-default
-rwxr-xr-x 1 root root 1384600 11月  3 13:34 helloworld-default-nosym
  • 使用tinygo

TinyGo是一个Go语言的编译器,它专为资源受限的环境而设计,例如微控制器、WebAssembly和其他嵌入式设备。TinyGo的目标是提供一个轻量级的、能在小型设备上运行的Go运行时,同时尽可能支持Go语言的特性。tinygo的一大优点就是生成的二进制文件通常比标准Go编译器生成的文件小得多:

$tinygo build -o helloworld-tinygo main.go
$ls -l
总用量 2728
-rwxr-xr-x  1 root root 2128909 11月  5 05:43 helloworld-default*
-rwxr-xr-x  1 root root  647600 11月  5 05:45 helloworld-tinygo*

我们看到:tinygo生成的可执行文件的size仅是原来的30%。

注:虽然TinyGo在特定场景(如IoT和嵌入式开发)中非常有用,但在常规服务器环境中,由于生态系统兼容性、性能、调试支持等方面的限制,可能并不是最佳选择。对于需要高并发、复杂功能和良好调试支持的应用,标准Go仍然是更合适的选择。

注:这里使用的tinygo为0.34.0版本。

5. 未使用的符号是否会被编译到Go二进制文件中?

到这里,相信读者心中也都会萦绕一些问题:到底哪些符号被编译到最终的Go二进制文件中了呢?未使用的符号是否会被编译到Go二进制文件中吗?在这一小节中,我们就来探索一下。

出于对Go的了解,我们已经知道无论是GOPATH时代,还是Go module时代,Go的编译单元始终是包(package),一个包(无论包中包含多少个Go源文件)都会作为一个编译单元被编译为一个目标文件(.a),然后Go链接器会将多个目标文件链接在一起生成可执行文件,因此如果一个包被依赖,那么它就会进入到Go二进制文件中,它内部的符号也会进入到Go二进制文件中。

那么问题来了!是否被依赖包中的所有符号都会被放到最终的可执行文件中呢?我们以最简单的helloworld-default为例,它依赖fmt包,并调用了fmt包的Println函数,我们看看Println这个符号是否会出现在最终的可执行文件中:

$nm -a helloworld-default | grep "Println"
000000000048eba0 T fmt.(*pp).doPrintln

居然没有!我们初步怀疑是inline优化在作祟。接下来,关闭优化再来试试:

$go build -o helloworld-default-noinline -gcflags='-l -N' main.go

$nm -a helloworld-default-noinline | grep "Println"
000000000048ec00 T fmt.(*pp).doPrintln
0000000000489ee0 T fmt.Println

看来的确如此!不过当使用”fmt.”去过滤helloworld-default-noinline的所有符号时,我们发现fmt包的一些常见的符号并未包含在其中,比如Printf、Fprintf、Scanf等。

这是因为Go编译器的一个重要特性:死码消除(dead code elimination),即编译器会将未使用的代码和数据从最终的二进制文件中剔除。

我们再来继续探讨一个衍生问题:如果Go源码使用空导入方式导入了一个包,那么这个包是否会被编译到Go二进制文件中呢?其实道理是一样的,如果用到了里面的符号,就会存在,否则不会。

以空导入os/user为例,即便在CGO_ENABLED=0的情况下,因为没有使用os/user中的任何符号,在最终的二进制文件中也不会包含user包:

$CGO_ENABLED=0 go build -o helloworld-with-os-user-noinline -gcflags='-l -N' main-with-os-user.go
[root@iZ2ze18rmx2avqb5xgb4omZ helloworld]# nm -a helloworld-with-os-user-noinline |grep user
0000000000551ac0 B runtime.userArenaState

但是如果是带有init函数的包,且init函数中调用了同包其他符号的情况呢?我们以expvar包为例看一下:

// go-compilation/main-with-expvar.go

package main

import (
    _ "expvar"
    "fmt"
)

func main() {
    fmt.Println("hello, world")
}

编译并查看一下其中的符号:

$go build -o helloworld-with-expvar-noinline -gcflags='-l -N' main-with-expvar.go
$nm -a helloworld-with-expvar-noinline|grep expvar
0000000000556480 T expvar.appendJSONQuote
00000000005562e0 T expvar.cmdline
00000000005561c0 T expvar.expvarHandler
00000000005568e0 T expvar.(*Func).String
0000000000555ee0 T expvar.Func.String
00000000005563a0 T expvar.init.0
00000000006e0560 D expvar..inittask
0000000000704550 d expvar..interfaceSwitch.0
... ...

除此之外,如果一个包即便没有init函数,但有需要初始化的全局变量,比如crypto包的hashes:

// $GOROOT/src/crypto/crypto.go
var hashes = make([]func() hash.Hash, maxHash)

crypto包的相关如何也会进入最终的可执行文件中,大家自己动手不妨试试。下面是我得到的一些输出:

$go build -o helloworld-with-crypto-noinline -gcflags='-l -N' main-with-crypto.go
$nm -a helloworld-with-crypto-noinline|grep crypto
00000000005517b0 B crypto.hashes
000000000048ee60 T crypto.init
0000000000547280 D crypto..inittask

有人会问:os/user包也有一些全局变量啊,为什么这些符号没有被包含在可执行文件中呢?比如:

// $GOROOT/src/os/user/user.go
var (
    userImplemented      = true
    groupImplemented     = true
    groupListImplemented = true
)

这就要涉及Go包初始化的逻辑了。我们看到crypto包包含在可执行文件中的符号中有crypto.init和crypto..inittask这两个符号,显然这不是crypto包代码中的符号,而是Go编译器为crypto包自动生成的init函数和inittask结构。

Go编译器会为每个包生成一个init函数,即使包中没有显式定义init函数,同时每个包都会有一个inittask结构,用于运行时的包初始化系统。当然这么说也不足够精确,如果一个包没有init函数、需要初始化的全局变量或其他需要运行时初始化的内容,则编译器不会为其生成init函数和inittask。比如上面的os/user包。

os/user包确实有上述全局变量的定义,但是这些变量是在编译期就可以确定值的常量布尔值,而且未被包外引用或在包内用于影响控制流。Go编译器足够智能,能够判断出这些初始化是”无副作用的”,不需要在运行时进行初始化。只有真正需要运行时初始化的包才会生成init和inittask。这也解释了为什么空导入os/user包时没有相关的init和inittask符号,而crypto、expvar包有的init.0和inittask符号。

6. 如何快速判断Go项目是否依赖cgo?

在使用开源Go项目时,我们经常会遇到项目文档中没有明确说明是否依赖Cgo的情况。这种情况下,如果我们需要在特定环境(比如CGO_ENABLED=0)下使用该项目,就需要事先判断项目是否依赖Cgo,有些时候还要快速地给出判断。

那究竟是否可以做到这种快速判断呢?我们先来看看一些常见的作法。

第一类作法是源码层面的静态分析。最直接的方式是检查源码中是否存在import “C”语句,这种引入方式是CGO使用的显著标志。

// 在项目根目录中执行
$grep -rn 'import "C"' .

这个命令会递归搜索当前目录下所有文件,显示包含import “C”的行号和文件路径,帮助快速定位CGO的使用位置。

此外,CGO项目通常包含特殊的编译指令,这些指令以注释形式出现在源码中,比如前面见识过的#cgo CFLAGS、#cgo LDFLAGS等,通过对这些编译指令的检测,同样可以来判断项目是否依赖CGO。

不过第一类作法并不能查找出Go项目的依赖包是否依赖cgo。而找出直接依赖或间接依赖是否依赖cgo,我们需要工具帮忙,比如使用Go工具链提供的命令分析项目依赖:

$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' ./...  | grep -v '\[\]'

其中ImportPath是依赖包的导入路径,而CgoFiles则是依赖中包含import “C”的Go源文件。我们以go-sqlite3那个依赖cgo的示例来验证一下:

// cd go-compilation/go-sqlite3

$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' ./...  | grep -v '\[\]'
runtime/cgo: [cgo.go]
github.com/mattn/go-sqlite3: [backup.go callback.go error.go sqlite3.go sqlite3_context.go sqlite3_load_extension.go sqlite3_opt_serialize.go sqlite3_opt_userauth_omit.go sqlite3_other.go sqlite3_type.go]

用空导入os/user的示例再来看一下:

$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}'  main-with-os-user.go | grep -v '\[\]'
runtime/cgo: [cgo.go]
os/user: [cgo_lookup_cgo.go getgrouplist_unix.go]

我们知道os/user有纯go和C版本两个实现,因此上述判断只能说“对了一半”,当我关闭CGO_ENABLED时,Go编译器不会使用基于cgo的C版实现。

那是否在禁用cgo的前提下对源码进行一次编译便能验证项目是否对cgo有依赖呢?这样做显然谈不上是一种“快速”的方法,那是否有效呢?我们来对上面的go-sqlite3项目做一个测试,我们在关闭CGO_ENABLED时,编译一下该示例:

// cd go-compilation/go-sqlite3
$ CGO_ENABLED=0 go build demo

我们看到,Go编译器并未报错!似乎该项目不需要cgo! 但真的是这样吗?我们运行一下编译后的demo可执行文件:

$ ./demo
2024/11/03 22:10:36 "Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub": CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);

我们看到成功编译出来的程序居然出现运行时错误,提示需要cgo!

到这里,没有一种方法可以快速、精确的给出项目是否依赖cgo的判断。也许判断Go项目是否依赖CGO并没有捷径,需要从源码分析、依赖检查和构建测试等多个维度进行。

7. 小结

在本文中,我们深入探讨了Go语言编译过程中的几个重要细节,尤其是在静态链接和动态链接的选择上。通过具体示例,我们了解到:

  • 默认链接方式:尽管CGO_ENABLED默认值为1,Go编译器在大多数情况下会采用静态链接,只有在依赖特定的C库或标准库包时,才会切换到动态链接。

  • 动态链接的条件:我们讨论了几种情况下Go会默认生成动态链接的可执行文件,包括依赖使用C实现的标准库包、显式使用cgo调用外部C程序,以及使用依赖cgo的第三方包。

  • 实现静态链接:对于需要动态链接的场景,我们也提供了将其转为静态链接的解决方案,包括关闭CGO、使用静态库,以及处理依赖cgo的外部包的静态链接问题。

  • 二进制文件优化:我们还介绍了如何通过去除符号表和使用TinyGo等方法来优化生成的Go二进制文件的大小,以满足不同场景下的需求。

  • 符号编译与死码消除:最后,我们探讨了未使用的符号是否会被编译到最终的二进制文件中,并解释了Go编译器的死码消除机制。

通过这些细节探讨,我希望能够帮助大家更好地理解Go编译的复杂性,并在实际开发中做出更明智的选择,亦能在面对Go编译相关问题时,提供有效的解决方案。

本文涉及的源码可以在这里下载。


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



Statcounter View My Stats