标签 Go 下的文章

代码覆盖率新玩法:Russ Cox教你用差异化分析加速Go调试

本文永久链接 – https://tonybai.com/2025/05/07/debug-with-diff-cover

大家好,我是Tony Bai。

调试,尤其是调试并非自己编写的代码,往往是软件开发中最耗时的环节之一。面对一个失败的测试用例和庞大的代码库,如何快速有效地缩小问题范围?Go团队的前技术负责人 Russ Cox 近期分享了一个虽然古老但极其有效的调试技术——差异化覆盖率 (Differential Coverage)。该技术通过比较成功和失败测试用例的代码覆盖率,巧妙地“高亮”出最可能包含Bug的代码区域,从而显著加速调试进程。

在这篇文章中,我们来看一下Russ Cox的这个“古老绝技”,并用一个实际的示例复现一下这个方法的有效性。

核心思想:寻找失败路径上的“独特足迹”

代码覆盖率通常用于衡量测试的完备性,告诉我们哪些代码行在测试运行期间被执行了。而差异化覆盖率则利用这一信息进行反向推理:

假设: 如果一段代码仅在失败的测试用例中被执行,而在其他成功的用例中未被执行,那么这段代码很可能与导致失败的 Bug 相关。

反之,如果一段代码在成功的测试中执行了,但在失败的测试中未执行,那么这段代码本身大概率是“无辜”的,尽管它被跳过的原因(控制流的变化)可能提供有用的线索。

如何实践差异化覆盖率?

Russ Cox 通过一个向 math/big 包注入 Bug 的例子,演示了如何应用该技术:

假设 go test 失败,且失败的测试是 TestAddSub:

$ go test
--- FAIL: TestAddSub (0.00s)
    int_test.go:2020: addSub(...) = -0x0, ..., want 0x0, ...
FAIL
exit status 1
FAIL    math/big    7.528s

步骤 1:收集测试覆盖率prof文件

  • 生成“成功”的prof文件 (c1.prof): 运行除失败测试外的所有测试,并记录覆盖率。
# 使用 -skip 参数跳过失败的测试 TestAddSub
$ go test -coverprofile=c1.prof -skip='TestAddSub$'
# Output: PASS, coverage: 85.0% ...
  • 生成“失败”的prof文件 (c2.prof): 只运行失败的测试,并记录覆盖率。
# 使用 -run 参数只运行失败的测试 TestAddSub
$ go test -coverprofile=c2.prof -run='TestAddSub$'
# Output: FAIL, coverage: 4.7% ...

步骤 2:计算差异并生成 HTML 报告

  • 合并与筛选: 使用 diff 和 sed 命令,提取出仅存在于 c2.prof (失败测试) 中的覆盖率记录,并保留 c1.prof 的文件头,生成差异化配置文件 c3.prof。
# head 保留 profile 文件头
# diff 比较两个文件
# sed -n 's/^> //p' 只提取 c2.prof 中独有的行(以 "> " 开头)
$ (head -1 c1.prof; diff c1.prof c2.prof | sed -n 's/^> //p') > c3.prof
  • 可视化: 使用 go tool cover 查看 HTML 格式的差异化覆盖率报告。
$go tool cover -html=c3.prof

解读差异化覆盖率报告

在浏览器中打开的 HTML 报告将以不同的颜色标记代码:

  • 绿色 (Covered): 表示这些代码行仅在失败的测试 (c2.prof) 中运行,而在成功的测试 (c1.prof) 中没有运行。这些是重点怀疑对象,需要优先审查。
  • 红色 (Uncovered): 表示这些代码行在成功的测试中运行过,但在失败的测试中没有运行。这些代码通常可以被排除嫌疑,但它们被跳过的原因可能暗示了控制流的异常。
  • 灰色 (Not Applicable/No Change): 表示这些代码行要么在两个测试中都运行了,要么都没运行,或者覆盖状态没有变化。

在 Russ Cox 的 math/big 例子中,差异化覆盖率报告迅速将范围缩小到 natmul.go 文件中的一小段绿色代码,这正是他故意引入 Bug 的地方(else 分支缺少了 za.neg = false)。原本需要检查超过 15,000 行代码,通过差异化覆盖率,直接定位到了包含 Bug 在内的 10 行代码区域。

示例差异化覆盖率截图描述

从图中可以看到:Go覆盖率工具 HTML 报告显示 natmul.go 文件。大部分代码为红色或灰色,只有一小段 else 分支内的代码被标记为绿色,指示这部分代码仅在失败的测试中执行。

实践案例:定位简单计算器中的 Bug

为了更具体直观地感受差异化覆盖率的威力,让我们复现一下Russ Cox的“古老绝技”,来看一个简单的例子。假设我们有一个执行基本算术运算的函数,但不小心在乘法逻辑中引入了一个 Bug。

1. 存在 Bug 的代码 (calculator.go)

package calculator

import "fmt"

// Calculate 执行简单的算术运算
func Calculate(op string, a, b int) (int, error) {
    switch op {
    case "add":
        return a + b, nil
    case "sub":
        return a - b, nil
    case "mul":
        // !!! Bug introduced here: should be a * b !!!
        fmt.Println("Executing multiplication logic...") // 添加打印以便观察
        return a + b, nil // 错误地执行了加法
    default:
        return 0, fmt.Errorf("unsupported operation: %s", op)
    }
}

2. 测试代码 (calculator_test.go)

package calculator

import "testing"

func TestCalculateAdd(t *testing.T) {
    result, err := Calculate("add", 5, 3)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result != 8 {
        t.Errorf("add(5, 3) = %d; want 8", result)
    }
}

func TestCalculateSub(t *testing.T) {
    result, err := Calculate("sub", 5, 3)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result != 2 {
        t.Errorf("sub(5, 3) = %d; want 2", result)
    }
}

// 这个测试会因为 Bug 而失败
func TestCalculateMul(t *testing.T) {
    result, err := Calculate("mul", 5, 3)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    // 期望 15,但因为 Bug 实际返回 8
    if result != 15 {
        t.Errorf("mul(5, 3) = %d; want 15", result)
    }
}

3. 运行测试并定位 Bug

首先,运行所有测试,会看到 TestCalculateMul 失败:

$go test .
Executing multiplication logic...
--- FAIL: TestCalculateMul (0.00s)
    caculator_test.go:33: mul(5, 3) = 8; want 15
FAIL
FAIL    caculator   0.007s
FAIL

现在,我们应用差异化覆盖率技术:

  • 生成“成功”覆盖率 (c1.prof):
$go test -coverprofile=c1.prof -skip='TestCalculateMul$' ./...
ok      caculator   0.007s  coverage: 50.0% of statements
  • 生成“失败”覆盖率 (c2.prof):
$go test -coverprofile=c2.prof -run='TestCalculateMul$' ./...

Executing multiplication logic...
--- FAIL: TestCalculateMul (0.00s)
    caculator_test.go:33: mul(5, 3) = 8; want 15
FAIL
coverage: 50.0% of statements
FAIL    caculator   0.008s
FAIL
  • 计算差异并查看 (c3.prof):
$(head -1 c1.prof; diff c1.prof c2.prof | sed -n 's/^> //p') > c3.prof
$go tool cover -html=c3.prof

4. 分析结果

go tool cover命令会打开生成的 c3.prof HTML 报告,我们可以查看 calculator.go 文件的覆盖率情况。

这个结果清晰地将我们的注意力引导到了处理乘法逻辑的代码块,提示这部分代码是失败测试独有的执行路径,极有可能是 Bug 的源头。通过检查绿色的代码行,我们就能快速发现乘法被错误地实现成了加法。

这个简单的实例验证了差异化覆盖率在隔离和定位问题代码方面的有效性,即使在不熟悉的代码库中,也能提供极具价值的调试线索。

优点与局限性

通过上面的理论分析与复现展示,我们可以看出这门“古老绝技”的优点以及一些局限。

差异化覆盖率这项技术展现出多项优点。它能够极大地缩小代码排查范围,这在处理大型或不熟悉的代码库时尤其有用。此外,使用差异化覆盖率的成本相对低廉,只需要运行两次测试,然后执行一些简单的命令行操作即可。最重要的是,产生的 HTML 报告能够清晰地标示出重点区域,使得问题的定位更加直观。

然而,差异化覆盖率并非万能。它存在一些局限性。首先,对于依赖特定输入数据才会触发的错误(数据依赖性 Bug),即使错误代码在成功的测试中被执行,差异化覆盖率也可能无法直接标记出该代码。其次,如果成功的测试执行了错误代码,但测试断言没有捕捉到错误状态,那么差异化覆盖率也无法有效工作。最后,这项技术依赖于清晰的失败信号,因此需要有一个明确失败的测试用例作为对比基准。

其他应用场景

除了调试失败的测试,差异化覆盖率还有其他用途:

  1. 理解代码功能: 想知道某项特定功能(如 net/http 中的 SOCKS5 代理)是由哪些代码实现的?可以运行包含该功能和不包含该功能的两组测试,然后进行差异化覆盖率分析,绿色部分即为与该功能强相关的代码。
  2. 简化版 – 单一失败测试覆盖率: 即便不进行比较,仅仅查看失败测试本身的覆盖率报告 (c2.prof) 也非常有价值。它清晰地展示了在失败场景下,代码究竟执行了哪些路径,哪些代码完全没有运行(可以直接排除),有助于理解错误的产生过程。

小结

差异化覆盖率是一种简单、低成本且往往非常有效的调试辅助手段。它利用了 Go 内建的覆盖率工具,通过巧妙的比较,帮助开发者将注意力聚焦到最可疑的代码区域。虽然它不能保证找到所有类型的 Bug,但在许多场景下,它都能显著节省调试时间,将开发者从“大海捞针”式的排查中解放出来。下次遇到棘手的 Bug 时,不妨试试这个技巧!当然,还可以结合之前Russ Cox分享的Hash-based bisect调试技术共同快速的定位问题所在。

  • Russ Cox的文章原始地址:https://research.swtch.com/diffcover
  • 本文示例代码的地址:https://github.com/bigwhite/experiments/tree/master/diff-test-cover

调试奇技淫巧,你还有哪些?

差异化覆盖率确实为我们提供了一个在复杂代码中快速缩小问题范围的利器。除了这个“古老绝技”,你在日常 Go 开发中,还珍藏了哪些鲜为人知但极其高效的调试技巧或工具心得? 比如你是如何利用 Delve 的高级特性,或者有什么特别的日志分析方法?

热烈欢迎在评论区分享你的独门秘笈,让我们一起丰富Go开发者的调试工具箱!

想系统性提升你的Go调试与底层分析能力?

如果你对这类Go调试技巧、性能剖析、甚至Go语言的内部实现(比如GC、调度器)充满好奇,渴望从“知其然”到“知其所以然”,并系统性地构建自己的Go专家知识体系…

那么,我的 「Go & AI 精进营」知识星球 正是为你准备的!这里不仅有【Go进阶课】、【Go避坑课】带你深入Go的实用技巧与常见陷阱,更有【Go原理课】为你揭示语言底层的奥秘。当然,还有我亲自为你解答疑难,以及一个充满活力的Gopher社区与你共同成长,探索Go在AI等前沿领域的应用。

现在就扫码加入,和我们一起深入Go的世界,让调试不再是难题,让技术精进之路更加清晰!

img{512x368}


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

解读“Cheating the Reaper”:在Go中与GC共舞的Arena黑科技

本文永久链接 – https://tonybai.com/2025/05/06/cheating-the-reaper-in-go

大家好,我是Tony Bai。

Go语言以其强大的垃圾回收 (GC) 机制解放了我们这些 Gopher 的心智,让我们能更专注于业务逻辑而非繁琐的内存管理。但你有没有想过,在 Go 这个看似由 GC “统治”的世界里,是否也能体验一把“手动管理”内存带来的极致性能?甚至,能否与 GC “斗智斗勇”,让它为我们所用?

事实上,Go 官方也曾进行过类似的探索。 他们尝试在标准库中加入一个arena包,提供一种基于区域 (Region-based) 的内存管理机制。测试表明,这种方式确实能在特定场景下通过更早的内存复用减少 GC 压力带来显著的性能提升。然而,这个官方的 Arena 提案最终被无限期搁置了。原因在于,Arena 这种手动内存管理机制与 Go 语言现有的大部分特性和标准库组合得很差 (compose poorly)

官方的尝试尚且受阻,那么个人开发者在 Go 中玩转手动内存管理又会面临怎样的挑战呢?最近,一篇名为 “Cheating the Reaper in Go” (在 Go 中欺骗死神/收割者) 的文章在技术圈引起了不小的关注。作者 mcyoung 以其深厚的底层功底,展示了如何利用unsafe包和对 Go GC 内部运作机制的深刻理解,构建了一个非官方的、实验性的高性能内存分配器——Arena。

这篇文章的精彩之处不仅在于其最终实现的性能提升,更在于它揭示了在 Go 中进行底层内存操作的可能性、挑战以及作者与 GC “共舞”的巧妙思路需要强调的是,本文的目的并非提供一个生产可用的 Arena 实现(官方尚且搁置,其难度可见一斑),而是希望通过解读作者这次与 GC “斗智斗勇”的“黑科技”,和大家一起更深入地理解 Go 的底层运作机制。

为何还要探索 Arena?理解其性能诱惑

即使官方受阻,理解 Arena 的理念依然有价值。它针对的是 Go 自动内存管理在某些场景下的潜在瓶颈:

  • 高频、小对象的分配与释放: 频繁触碰 GC 可能带来开销。
  • 需要统一生命周期管理的内存: 一次性处理比零散回收更高效。

Arena 通过批量申请、内部快速分配、集中释放(在 Go 中通常是让 Arena 不可达由 GC 回收)的策略,试图在这些场景下取得更好的性能。

核心挑战:Go 指针的“特殊身份”与 GC 的“规则”

作者很快指出了在 Go 中实现 Arena 的核心障碍:Go 的指针不是普通的数据。GC 需要通过指针位图 (Pointer Bits) 来识别内存中的指针,进行可达性分析。而自定义分配的原始内存块缺乏这些信息。

作者提供了一个类型安全的泛型函数New[T]来在 Arena 上分配对象:

type Allocator interface {
  Alloc(size, align uintptr) unsafe.Pointer
}

// New allocates a fresh zero value of type T on the given allocator, and
// returns a pointer to it.
func New[T any](a Allocator) *T {
  var t T
  p := a.Alloc(unsafe.Sizeof(t), unsafe.Alignof(t))
  return (*T)(p)
}

但问题来了,如果我们这样使用:

p := New[*int](myAlloc) // myAlloc是一个实现了Allocator接口的arena实现
*p = new(int)
runtime.GC()
**p = 42  // Use after free! 可能崩溃!

因为 Arena 分配的内存对 GC 不透明,GC 看不到里面存储的指向new(int)的指针。当runtime.GC()执行时,它认为new(int)分配的对象已经没有引用了,就会将其回收。后续访问**p就会导致 Use After Free。

“欺骗”GC 的第一步:让 Arena 整体存活

面对这个难题,作者的思路是:让 GC 知道 Arena 的存在,并间接保护其内部分配的对象。关键在于确保:只要 Arena 中有任何一个对象存活,整个 Arena 及其所有分配的内存块(Chunks)都保持存活。

这至关重要,通过强制标记整个 arena,arena 中存储的任何指向其自身的指针将自动保持活动状态,而无需 GC 知道如何扫描它们。所以,虽然这样做后, *New[*int](a) = new(int) 仍然会导致释放后重用,但 *New[*int](a) = New[int](a) 不会!即arena上分配的指针仅指向arena上的内存块。 这个小小的改进并不能保证 arena 本身的安全,但只要进入 arena 的指针完全来自 arena 本身,那么拥有内部 arena 的数据结构就可以完全安全。

1. 基本 Arena 结构与快速分配

首先,定义 Arena 结构,包含指向下一个可用位置的指针next和剩余空间left。其核心分配逻辑 (Alloc) 主要是简单的指针碰撞:

package arena

import "unsafe"

type Arena struct {
    next  unsafe.Pointer // 指向当前 chunk 中下一个可分配位置
    left  uintptr        // 当前 chunk 剩余可用字节数
    cap   uintptr        // 当前 chunk 的总容量 (用于下次扩容参考)
    // chunks 字段稍后添加
}

const (
    maxAlign uintptr = 8 // 假设 64 位系统最大对齐为 8
    minWords uintptr = 8 // 最小分配块大小 (以字为单位)
)

func (a *Arena) Alloc(size, align uintptr) unsafe.Pointer {
    // 1. 对齐 size 到 maxAlign (简化处理)
    mask := maxAlign - 1
    size = (size + mask) &^ mask
    words := size / maxAlign

    // 2. 检查当前 chunk 空间是否足够
    if a.left < words {
        // 空间不足,分配新 chunk
        a.newChunk(words) // 假设 newChunk 会更新 a.next, a.left, a.cap
    }

    // 3. 在当前 chunk 中分配 (指针碰撞)
    p := a.next
    // (优化后的代码,去掉了检查 one-past-the-end)
    a.next = unsafe.Add(a.next, size)
    a.left -= words

    return p
}

2. 持有所有 Chunks

为了防止 GC 回收 Arena 已经分配但next指针不再指向的旧 Chunks,需要在 Arena 中明确持有它们的引用:

type Arena struct {
    next  unsafe.Pointer
    left, cap uintptr
    chunks []unsafe.Pointer  // 新增:存储所有分配的 chunk 指针
}

// 在 Alloc 函数的 newChunk 调用之后,需要将新 chunk 的指针追加到 a.chunks
// 例如,在 newChunk 函数内部实现: a.chunks = append(a.chunks, newChunkPtr)

原文测试表明,这个append操作的成本是摊销的,对整体性能影响不大,结果基本与没有chunks字段时持平。

3. 关键技巧:Back Pointer

是时候保证整个arena安全了!这是“欺骗”GC 的核心。通过reflect.StructOf动态创建包含unsafe.Pointer字段的 Chunk 类型,并在该字段写入指向 Arena 自身的指针:

import (
    "math/bits"
    "reflect"
    "unsafe"
)

// allocChunk 创建新的内存块并设置 Back Pointer
func (a *Arena) allocChunk(words uintptr) unsafe.Pointer {
    // 使用 reflect.StructOf 创建动态类型 struct { Data [N]uintptr; BackPtr unsafe.Pointer }
    chunkType := reflect.StructOf([]reflect.StructField{
        {
            Name: "Data", // 用于分配
            Type: reflect.ArrayOf(int(words), reflect.TypeFor[uintptr]()),
        },
        {
            Name: "BackPtr", // 用于存储 Arena 指针
            Type: reflect.TypeFor[unsafe.Pointer](), // !! 必须是指针类型,让 GC 扫描 !!
        },
    })

    // 分配这个动态结构体
    chunkPtr := reflect.New(chunkType).UnsafePointer()

    // 将 Arena 自身指针写入 BackPtr 字段 (位于末尾)
    backPtrOffset := words * maxAlign // Data 部分的大小
    backPtrAddr := unsafe.Add(chunkPtr, backPtrOffset)
    *(**Arena)(backPtrAddr) = a // 写入 Arena 指针

    // 返回 Data 部分的起始地址,用于后续分配
    return chunkPtr
}

// newChunk 在 Alloc 中被调用,用于更新 Arena 状态
func (a *Arena) newChunk(requestWords uintptr) {
    newCapWords := max(minWords, a.cap*2, nextPow2(requestWords)) // 计算容量
    a.cap = newCapWords

    chunkPtr := a.allocChunk(newCapWords) // 创建新 chunk 并写入 BackPtr

    a.next = chunkPtr // 更新 next 指向新 chunk 的 Data 部分
    a.left = newCapWords // 更新剩余容量

    // 将新 chunk (整个 struct 的指针) 加入列表
    a.chunks = append(a.chunks, chunkPtr)
}

// (nextPow2 和 max 函数省略)

通过这个 Back Pointer,任何指向 Arena 分配内存的外部指针,最终都能通过 GC 的扫描链条将 Arena 对象本身标记为存活,进而保活所有 Chunks。这样,Arena 内部的指针(指向 Arena 分配的其他对象)也就安全了!原文的基准测试显示,引入 Back Pointer 的reflect.StructOf相比直接make([]uintptr)对性能有轻微但可察觉的影响。

性能再“压榨”:消除冗余的 Write Barrier

分析汇编发现,Alloc函数中更新a.next(如果类型是unsafe.Pointer) 会触发 Write Barrier。这是 GC 用来追踪指针变化的机制,但在 Back Pointer 保证了 Arena 整体存活的前提下,这里的 Write Barrier 是冗余的。

作者的解决方案是将next改为uintptr:

type Arena struct {
    next  uintptr // <--- 改为 uintptr
    left  uintptr
    cap   uintptr
    chunks []unsafe.Pointer
}

func (a *Arena) Alloc(size, align uintptr) unsafe.Pointer {
    // ... (对齐和检查 a.left < words 逻辑不变) ...
    if a.left < words {
        a.newChunk(words) // newChunk 内部会设置 a.next (uintptr)
    }

    p := a.next // p 是 uintptr
    a.next += size // uintptr 直接做加法,无 Write Barrier
    a.left -= words

    return unsafe.Pointer(p) // 返回时转换为 unsafe.Pointer
}

// newChunk 内部设置 a.next 时也应存为 uintptr
func (a *Arena) newChunk(requestWords uintptr) {
    // ... (allocChunk 不变) ...
    chunkPtr := a.allocChunk(newCapWords)
    a.next = uintptr(chunkPtr) // <--- 存为 uintptr
    // ... (其他不变) ...
}

这个优化效果如何?原文作者在一个 GC 压力较大的场景下(通过一个 goroutine 不断调用runtime.GC()模拟)进行了测试,结果表明,对于小对象的分配,消除 Write Barrier 带来了大约 20% 的性能提升。这证明了在高频分配场景下,即使是 Write Barrier 这样看似微小的开销也可能累积成显著的性能瓶颈。

更进一步的可能:Arena 复用与sync.Pool

文章还提到了一种潜在的优化方向:Arena 的复用。当一个 Arena 完成其生命周期后(例如,一次请求处理完毕),其占用的内存理论上可以被“重置”并重新利用,而不是完全交给 GC 回收。

作者建议,可以将不再使用的 Arena 对象放入sync.Pool中。下次需要 Arena 时,可以从 Pool 中获取一个已经分配过内存块的 Arena 对象,只需重置其next和left指针即可开始新的分配。这样做的好处是:

  • 避免了重复向 GC 申请大块内存
  • 可能节省了重复清零内存的开销(如果 Pool 返回的 Arena 内存恰好未被 GC 清理)。

这需要更复杂的 Arena 管理逻辑(如 Reset 方法),但对于需要大量、频繁创建和销毁 Arena 的场景,可能带来进一步的性能提升。

unsafe:通往极致性能的“危险边缘”

贯穿整个 Arena 实现的核心是unsafe包。作者坦诚地承认,这种实现方式严重依赖 Go 的内部实现细节和unsafe提供的“后门”。

这再次呼应了 Go 官方搁置 Arena 的原因——它与语言的安全性和现有机制的兼容性存在天然的矛盾。使用unsafe意味着:

  • 放弃了类型和内存安全保障。
  • 代码变得脆弱,可能因 Go 版本升级而失效(尽管作者基于Hyrum 定律认为风险相对可控)。
  • 可读性和可维护性显著降低。

小结

“Cheating the Reaper in Go” 为我们呈现了一场精彩的、与 Go GC “共舞”的“黑客艺术”。通过对 GC 原理的深刻洞察和对unsafe包的大胆运用,作者展示了在 Go 中实现高性能自定义内存分配的可能性,虽然作者的实验性实现是一个toy级别的。

然而,正如 Go 官方的 Arena 实验所揭示的,将这种形式的手动内存管理完美融入 Go 语言生态,面临着巨大的挑战和成本。因此,我们应将这篇文章更多地视为一次理解 Go 底层运作机制的“思想实验”和“案例学习”,而非直接照搬用于生产环境的蓝图。

对于绝大多数 Go 应用,内建的内存分配器和 GC 依然是最佳选择。但通过这次“与死神共舞”的探索之旅,我们无疑对 Go 的底层世界有了更深的敬畏和认知。

你如何看待在 Go 中使用unsafe进行这类底层优化?官方 Arena 实验的受阻说明了什么?欢迎在评论区分享你的思考! 如果你对 Go 的底层机制和性能优化同样充满好奇,别忘了点个【赞】和【在看】!

原文链接:https://mcyoung.xyz/2025/04/21/go-arenas


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 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

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats