标签 调度器 下的文章

Go 1.18新特性前瞻:原生支持Fuzzing测试

本文永久链接 – https://tonybai.com/2021/12/01/first-class-fuzzing-in-go-1-18

今年6月初,Go官博发表了一篇名为《Fuzzing is Beta Ready》的文章,文中称gotip版本已经原生支持Fuzzing并开始了公测。这意味着Fuzzing将以一等公民(first-class citizen)的身份正式加入到即将于2022年2月发布的Go 1.18版本中:


图:关于add fuzz test的issue已经关闭并放入Go1.18里程碑

有了对Fuzzing技术的原生支持后,我相信会有更多代码经过Fuzzing测试,未来不久Go社区的Go代码的安全水平将会得到整体提升。本文我们就来简单聊聊Fuzzing这个Go 1.18版本的新特性。

注意:在Go 1.18版本Beta版发布前要想体验Fuzzing特性,需要自行安装gotip版本,安装方法如下:

$go install golang.org/dl/gotip@latest // go 1.17版本及以后使用go install。go 1.16及之前的版本用go get
$gotip download
$gotip version // 验证安装是否ok

为什么Go要支持Fuzzing

无论是在过去的单机时代,还是在今天的云计算时代,亦或是已经出现苗头的人工智能时代,安全都是程序员在构建软件过程中必不可少且日益重要的考量因素。

同时,安全也是Go语言设计者们在语言设计伊始就为Go设定的一个重要目标。在语言层面,Go提供了很多“安全保障”特性,比如:

  • 显式类型转换,不允许隐式转换;
  • 针对数组、切片的下标越界访问的检查;
  • 不安全代码隔离到unsafe包中,并提供安全使用unsafe包的几条rules;
  • go module构建模式内置包hash校验,放置恶意包对程序的攻击;
  • 雇佣安全专家,提供高质量且及时更新的crypto包,尽量防止使用第三方加解密包带来的不安全性;
  • 支持纯静态编译,避免动态连接到恶意动态库;
  • 原生提供方便测试的工具链,并支持测试覆盖率统计。
  • … …

进入云原生时代后,Go语言成为了云原生基础设施与云原生服务的头部语言,由Go语言建造的基础设施、中间件以及应用服务支撑着这个世界的很多重要系统的运行,这些系统对安全性的要求不言而喻。尤其是在“输入”方面,这些系统都会被要求:无论用户使用什么本文数据、二进制数据,无论用户如何构造协议包、无论用户提供的文件包含何种内容,系统都应该是安全健壮的,不会因为用户的故意攻击而出现处理异常、被操控,甚至崩溃。

这就需要我们的系统面对任何输入的情况下都能正确处理,但传统的代码review、静态分析、人工测试和自动化的单元测试无法穷尽所有输入组合,尤其是难于模拟一些无效的、意料之外的、随机的、边缘的输入数据。

于是,Go社区的一些安全方面的专家就尝试将业界在解决这方面问题的优秀实践引入Go,Fuzzing技术就是其中最重要的一个

什么是Fuzzing?

说了这么半天,啥是Fuzzing

Fuzzing,又叫fuzz testing,中文叫做模糊测试或随机测试。其本质上是一种自动化测试技术,更具体一点,它是一种基于随机输入的自动化测试技术,常被用于发现处理用户输入的代码中存在的bug和问题。

在具体实现上,Fuzzing不需要像单元测试那样使用预先定义好的数据集作为程序输入,而是会通过数据构造引擎自行构造或基于开发人员提供的初始数据构造一些随机数据,并作为输入提供给我们的程序,然后监测程序是否出现panic、断言失败、无限循环等。这些构造出来的随机数据被称为语料(corpus)。另外Fuzz testing不是一次性执行的测试,如果不限制执行次数和执行时间,Fuzz testing会一直执行下去,因此它也是一种持续测试的技术。

Fuzzing技术形态最初的出现可追溯到以打孔卡承载的数据作为计算机输入的时代。那个时候开发人员经常从废纸篓里随便拿出几张打孔卡输入到自己的程序中来验证程序是否可以正确处理这些“随机”的输入。但Fuzzing测试作为一门技术理论走进广大开发者视野是始于1988年的Barton Miller教授的研究成果,

当时任教于威斯康星大学的Barton Miller教授在高级操作系统研究生班(CS736)上的一个秋季项目中运用了随机测试技术,他通过fuzzing对一个UNIX工具进行测试,为该工具自动生成随机输入和命令行参数。该项目旨在通过快速连续执行大量的随机输入来测试UNIX命令行程序的可靠性,直到它们崩溃。当时Miller团队能够使他们所测试的实用程序中的25%至33%崩溃。然后,他们对每个崩溃的程序进行调试,以确定原因,并对每个检测到的故障进行分类。1990年,Miller团队对外发表了这一成果。自那以后,Fuzzing技术便确定了自己独立的研究分支,并逐渐发展演化到今天。在过去的十多年中,Fuzzing测试的技术水平有了很大的提高,以AFL(american fuzzy lop)AFL++libFuzzergo-fuzzsyzkaller等开源项目为代表的Fuzzing项目已经被各种编程语言的开发人员应用于实践,并且取得了不错的“战绩”

Fuzzing是对其他形式的测试、代码审查和静态分析的补充,它通过生成一个有趣的输入语料库,而这些输入几乎不可能用手去想去写出来,因此极易被传统类型的测试所遗漏。Fuzzing可以帮助开发人员发现难以发现的稳定性、逻辑性甚至是安全性方面的错误,特别是当被测系统变得更加复杂时。

更为关键的,也是Fuzzing技术能够流行开来的原因是构建一个Fuzzing测试足够简单。有了上述Fuzzing相关开源工具和开源库的支持,Fuzzing test不再是需要专业知识才能成功使用的技术,并且现代Fuzzing引擎可以更有策略的、更快的、更有效地找到有用的输入语料。

Go对Fuzzing技术支持的简要回顾

说过将Fuzzing技术引入Go,我们不能不提到一个人,它就是Go goroutine调度器的作者、前Intel Black Belt级工程师,现Google工程师的Dmitry Vyukov,它也是Go语言首个fuzzing工具go-fuzz的作者。

2015年的GopherCon大会上,Dmitry Vyukov在其名为“[Go Dynamic Tools]”的presentation中就介绍了go-fuzz。我个人的gocmpp项目就是使用go-fuzz搭建的Fuzz test

之后,Dmitry Vyukov发现虽然通过第三方的fuzzing工具可以解决Go开发者关于Fuzzing的部分需求,但有很多功能特性是通过第三方工具无法实现的。2016年,Dmitry Vyukov在Go官方issue列表中创建“cmd/compile: coverage instrumentation for fuzzing”的issue归纳说明了这些问题,也是从那时开始,Dmitry Vyukov极力推进Fuzzing进入Go原生工具链的。

2017年,Dmitry Vyukov首次提出希望在Go工具链中原生支持Fuzzing的Proposal,并给出了为什么我们需要在Go工具链的支持下使Fuzzing成为Go开发的标准做法的原因

之后,Dmitry还开源了一个名为syzkaller的项目,该项目用Go语言实现了一个针对操作系统内核的Fuzzing引擎。

Dmitry虽然是goroutine调度器的开发者,也是Google员工,但他却不是Go语言团队的一员,也许正是因为这样,又或是Go核心团队对新特性保持的一贯的谨慎态度,该Dmitry的提议一直处理讨论与反馈的状态,直到2020年中旬,Go安全团队的Katie Hockman才正式发布了在Go中原生支持Fuzzing的新提案

新提案与Dmitry的提案的目标是相同的,都是期望在Go工具链中加入对Fuzzing的原生支持。不同的是,新提案中的API稍微复杂一些,新提案允许以编程方式设置初始语料库的种子语料,并支持字节字符串以外的输入类型,包括原生的基本数据类型(整型、浮点、字符串等)、复合数据类型(数组、map、结构体等)以及实现了BinaryMarshaler/BinaryUnmarshaler或TextMarshaler/TextUnmarshaler接口的自定义类型。

不过Go fuzzing没有如预期加入到Go 1.17版本中,再继续打磨了半年后,Go 1.18版本正式接受了原生支持Fuzzing这个特性,Fuzzing成为Go的“一等公民”。

如何使用成为Go“一等公民”的Fuzzing

作为普通Go开发人员,我们无需关心Fuzzing引擎是如何实现的,我们只需根据Go提供的Fuzzing API使用就好了。前面也提到过了,Fuzzing技术之所以可以流行到现在,更多也是因为其使用起来十分简单。

Go 1.18将fuzz testing纳入了go test工具链,与单元测试、性能基准测试等一起成为了Go原生测试工具链中的重要成员。

go fuzzing test的测试用例与普通的测试用例(TestXxx)、性能基准测试(BenchmarkXxx)等一样放在xx_test.go中,只不过用例对应的函数名样式换为了FuzzXxx了。一个简单的Fuzzing test用例如下:

func FuzzXxx(f *testing.F) {
    // 设置种子语料(可选)

    // 执行Fuzzing
    f.Fuzz(func(t *testing.T, b []byte) {
        //... ...
    })
}

我们通过go test -fuzz命令即可执行所有xx_test.go中以Fuzz为前缀的Fuzz testing用例。不过这里要注意的是go test -fuzz会先进行普通TestXxx的用例执行,之后才会执行FuzzXxx。

我们看到:每个FuzzXxx函数的参数列表是固定的,fuzzing在testing包中新增了一个F结构体类型,其与testing.T、testing.B是类似的,用于表示一次Fuzzing test的执行,结构体变量中存储了此次fuzzing test执行的上下文信息。

FuzzXxx函数的最主要只能就是调用testing.F的Fuzz方法对目标函数/方法执行测试。testing.F的Fuzz方法接受一个函数类型的参数,按照fuzzing提案的说明,该函数的第一个参数必须是*testing.T,后面可以是多个输入参数,且输入参数的类型不局限于传统的byte切片,还可以是上面提到过的原生的基本数据类型(整型、浮点、字符串等)、复合数据类型(数组、map、结构体等)以及实现了BinaryMarshaler/BinaryUnmarshaler或TextMarshaler/TextUnmarshaler接口的自定义类型。

Fuzz方法会根据目标所需的输入参数类型生成fuzzing后的数据,并将这些数据作为输入参数传给其参数代表的函数。

Go开发人员也可以通过testing.F的Add方法为fuzzing过程提供初始的种子语料(seed corpus),后续fuzzing test会基于这些种子语料进行fuzzing,虽然这不是必须的,但提供与目标处理逻辑相关的种子语料,可能会加速被测目标中问题的暴露。

当然我们也可以不通过testing.F的Add方法来提供种子语料,go fuzzing还允许你通过文件的形式为每个FuzzXxx函数提供种子语料,以FuzzXxx为例,我们可以在testdata/fuzz/FuzzXxx目录下以文件的形式放置其初始语料,比如:

// testdata/fuzz/FuzzXxx/corpus1
go test fuzz v1
[]byte("ABC\xa8\x8c\xb3G\xfc")

在这个种子语料文件中,第一行go test fuzz v1是go fuzzing要求的文件头,用于标识这是一个种子语料文件,并且使用的编解码器的版本为v1。而下面的一行则是种子语料,其实这行就是一个go代码的片段,fuzzing工具会将其解码后直接作为输入赋值给被测目标。后续的fuzzing过程也是基于这个种子语料的。注意:种子语料可以有很多个,不同语料单独放置在一行中即可。目前,这个种子语料文件需要手工编辑,后续go团队可能会提供相关工具以方便对种子语料文件的编辑,尤其是针对一些二进制数据的种子语料。

下面我们用一个例子来看看如何使用go原生fuzzing。我这里还以我的gocmpp项目为例,对项目中的Submit消息的Unpack方法进行fuzz testing。

我们先来看不提供初始种子语料的情况。

不提供初始种子语料

虽说FuzzXxx测试用例可以与普通用例TestXxx放在一个xxx_test.go文件中,但例子中为了着重强调我们是在写Fuzz testing用例,我们将FuzzXxxx函数放在submit_fuzz_test.go中,下面是不显式提供初始种子语料的FuzzSubmit:

// submit_fuzz_test.go

//go:build go1.18
// +build go1.18

package cmpp_test

import (
    "testing"

    cmpp "github.com/bigwhite/gocmpp"
)

func FuzzSubmit(f *testing.F) {
    f.Fuzz(func(t *testing.T, data []byte) {
        p := &cmpp.Cmpp2SubmitReqPkt{}
        _ = p.Unpack(data)
    })
}

我们看到,为了兼容go 1.18之前的版本,我们使用build指示符来告诉编译器这个文件只在go 1.18及后续版本才参与编译,这样go 1.17及之前的版本会ignore这个源文件。

使用下面命令我们来运行一下这个fuzz test:

$gotip test -v -fuzz  .
=== RUN   TestTypeString
--- PASS: TestTypeString (0.00s)
... ...
=== RUN   TestCmppTerminateRspPktPack
--- PASS: TestCmppTerminateRspPktPack (0.00s)
=== RUN   TestCmppTerminateRspUnpack
--- PASS: TestCmppTerminateRspUnpack (0.00s)
=== FUZZ  FuzzSubmit
fuzz: elapsed: 0s, gathering baseline coverage: 0/38 completed
fuzz: elapsed: 0s, gathering baseline coverage: 38/38 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 403378 (134408/sec), new interesting: 0 (total: 38)
fuzz: elapsed: 6s, execs: 800451 (132348/sec), new interesting: 0 (total: 38)
fuzz: elapsed: 9s, execs: 1104435 (101366/sec), new interesting: 0 (total: 38)
fuzz: elapsed: 12s, execs: 1346809 (80789/sec), new interesting: 0 (total: 38)
fuzz: elapsed: 15s, execs: 1718297 (123747/sec), new interesting: 1 (total: 39)
^Cfuzz: elapsed: 16s, execs: 1771361 (92170/sec), new interesting: 1 (total: 39)
--- PASS: FuzzSubmit (15.59s)
PASS
ok      github.com/bigwhite/gocmpp  15.607s

通过上面的执行我们得到几点信息;

  • gotip test -fuzz会先进行普通TestXxx的用例执行,之后才会执行FuzzXxx。

  • fuzz testing默认会一直执行下去,直到遇到crash。如果要限制fuzz testing的执行时间,可以使用-fuzztime,比如下面的命令允许fuzz testing只执行10s:

$gotip test -v  -fuzztime 10s -fuzz .
  • 覆盖率引导的fuzzing

我们看到:测试输出内容中包含“gathering baseline coverage字样”,这里要告诉大家的是,go fuzzing使用的是一种名为“覆盖率引导的fuzzing(coverage-guided fuzzing)”。我们知道Fuzzing测试使用的是随机生成的随机数据对被测目标进行测试,如果仅仅是用凭空随机的方式生成的输入很难有效找到边缘情况,当今大多数现代的fuzzing工具都是用了coverage-guided fuzzing的引擎来驱动测试,它可以确定新生成的语料是否能覆盖到新的代码路径。Dmitry在“Fuzzing support for Go”一文中曾经对coverage-guided fuzzing的引擎的工作逻辑做过如下描述:

从一些(可能是空的)输入的语料库开始
for {
    从语料库中随机选择一个输入
    对输入进行变异(mutate)
    执行变异后的输入并收集代码覆盖率
    如果该输入给出了新的覆盖率,则将其添加到语料库中
}

收集代码覆盖率数据和检测一个输入”给出新的覆盖率”并非易事,这也是为什么很多第三方fuzzing工具难于实现的原因,而加入到go原生工具链中,这些特性可借助Go编译器以及已有的设施来完成。

  • 缓存新加入的语料

在上面go test -fuzz执行过程中,新生成的可以覆盖新代码路径的语料放置在哪里了呢?Go fuzzing会将其缓存在cache路径下,我在本机的gocache路径下发现如下与fuzz有关的内容:

//GOCACHE="/Users/tonybai/Library/Caches/go-build"

$ls /Users/tonybai/Library/Caches/go-build/fuzz/github.com/bigwhite/gocmpp/FuzzSubmit
01e40385855b3d317d42bf7ba4a9118702d867492fe2cd52bc84e554769480e5
06ba4bdb19de593e669c642987e270fe2488d4d58ecd712db136a3e011071253
086eabebec22d361087ce571571d86b1cbe1ad3e713f981473275f2c8fa64f09
... ...

go fuzzing会为每个FuzzXxx函数建立对应的语料缓存。目前fuzz cache的默认路径为$GOCACHE/fuzz/包路径/FuzzXxx,后续可能会提供环境变量或cmd option,以允许开发人员自定义fuzzing语料的缓存路径。

前面我们说过,fuzzing test默认会一直执行下去,如果不限制执行次数和执行时间,执行Fuzzing test的机器要有足够的存储才行。而要想清理过多的fuzz缓存,用gotip clean -cache是不行的,需要为clean加上-fuzzcache才可以清除fuzz的cache。

使用Add方法提供种子语料

下面是通过testing.F的Add方法为fuzzing test提供种子语料的例子:

//go:build go1.18
// +build go1.18

package cmpp_test

import (
    "testing"

    cmpp "github.com/bigwhite/gocmpp"
)

func FuzzSubmit(f *testing.F) {
    data := []byte{
        0x00, 0x00, 0x00, 0xbd, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x17, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x74, 0x65, 0x73, 0x74, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x02, 0x31, 0x33, 0x35, 0x30, 0x30, 0x30, 0x30, 0x32, 0x36, 0x39, 0x36, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x39, 0x30, 0x30, 0x30, 0x30,
        0x31, 0x30, 0x32, 0x31, 0x30, 0x00, 0x00, 0x00, 0x00, 0x31, 0x35, 0x31, 0x31, 0x30, 0x35, 0x31,
        0x33, 0x31, 0x35, 0x35, 0x35, 0x31, 0x30, 0x31, 0x2b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x39, 0x30, 0x30, 0x30, 0x30,
        0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x01, 0x31, 0x33, 0x35, 0x30, 0x30, 0x30, 0x30, 0x32, 0x36, 0x39, 0x36, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0x6d, 0x4b, 0x8b, 0xd5, 0x00, 0x67, 0x00, 0x6f, 0x00,
        0x63, 0x00, 0x6d, 0x00, 0x70, 0x00, 0x70, 0x00, 0x20, 0x00, 0x73, 0x00, 0x75, 0x00, 0x62, 0x00,
        0x6d, 0x00, 0x69, 0x00, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    }
    f.Add(data[8:])
    f.Fuzz(func(t *testing.T, data []byte) {
        p := &cmpp.Cmpp2SubmitReqPkt{}
        _ = p.Unpack(data)
    })
}

在上面代码中,我们使用了一个正确的cmpp2 submit消息包的数据作为种子语料,后续fuzzing会基于该语料做mutate,生成其他语料对Submit的Unpack方法进行测试。执行该fuzz test的输出与不设置种子语料的输出差别不大:

$gotip test -fuzz .
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 395490 (131760/sec), new interesting: 20 (total: 20)
fuzz: elapsed: 6s, execs: 795164 (133251/sec), new interesting: 22 (total: 22)
fuzz: elapsed: 9s, execs: 1106761 (103875/sec), new interesting: 22 (total: 22)
^Cfuzz: elapsed: 11s, execs: 1281577 (92654/sec), new interesting: 22 (total: 22)
PASS
ok      github.com/bigwhite/gocmpp  10.917s

使用testdata/fuzz/FuzzXxx目录提供种子语料

除了通过Add方法添加种子语料外,我们还可以在项目的testdata/fuzz/FuzzXxx目录下为FuzzXxx用例提供种子语料:

$tree testdata
testdata
└── fuzz
    └── FuzzSubmit
        └── corpus1

$cat testdata/fuzz/FuzzSubmit/corpus1
go test fuzz v1
[]byte("ABC\xa8\x8c\xb3G\xfc")

我们把FuzzSubmit代码恢复为不带有Add的形式:

func FuzzSubmit(f *testing.F) {
    f.Fuzz(func(t *testing.T, data []byte) {
        p := &cmpp.Cmpp2SubmitReqPkt{}
        _ = p.Unpack(data)
    })
}

执行这一用例,输出入下结果:

$gotip test -fuzz .
fuzz: elapsed: 0s, gathering baseline coverage: 0/23 completed
fuzz: elapsed: 0s, gathering baseline coverage: 23/23 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 355689 (118485/sec), new interesting: 3 (total: 25)
fuzz: elapsed: 6s, execs: 623934 (89390/sec), new interesting: 5 (total: 27)
fuzz: elapsed: 9s, execs: 756400 (44154/sec), new interesting: 5 (total: 27)
^Cfuzz: elapsed: 10s, execs: 771910 (28387/sec), new interesting: 5 (total: 27)
PASS
ok      github.com/bigwhite/gocmpp  9.564s

模拟crash

Fuzzing技术作为其他测试的一种补充,可以用于发现代码中潜在的不易被发现的问题,但这一过程有时候也是很漫长的。因此在这里我们模拟一个crash的产生来看看:当fuzzing test过程中发现代码bug时,fuzzing都做了那些事情。

我们在被测方法Unpack中故意放置了一个panic语句:

// submit.go
func (p *Cmpp2SubmitReqPkt) Unpack(data []byte) error {
    ... ...
    panic("panic for fuzz demo")
    ... ...
}

这样,当fuzzing test执行到这里时Unpack方法就会panic,而fuzzing会生成crash报告并终止fuzz testing执行。

但要注意的是:go test -fuzz是先运行TestXxx,然后再运行FuzzXxx的,如果在TestXxx中发生了panic,那么go fuzzing是不会生成crash报告的。另外还有一点要格外注意:go fuzzing将种子语料作为输入时出现的panic也是不会生成crash报告的,因为,此时对数据的fuzzing还没有真正开始。因此,这里我们使用上面那个不带有种子语料的FuzzSubmit。

好了,我们执行一下fuzzing test:

$gotip test -fuzz .
fuzz: elapsed: 0s, gathering baseline coverage: 0/27 completed
fuzz: minimizing 148-byte crash file
fuzz: elapsed: 0s, gathering baseline coverage: 0/27 completed
--- FAIL: FuzzSubmit (0.03s)
    --- FAIL: FuzzSubmit (0.00s)
        testing.go:1319: panic: panic for fuzz demo
            goroutine 49 [running]:
            runtime/debug.Stack()
                /Users/tonybai/sdk/gotip/src/runtime/debug/stack.go:24 +0x90
            testing.tRunner.func1()
                /Users/tonybai/sdk/gotip/src/testing/testing.go:1319 +0x1f2
            panic({0x11ca860, 0x1241148})
                /Users/tonybai/sdk/gotip/src/runtime/panic.go:838 +0x207
            github.com/bigwhite/gocmpp.(*Cmpp2SubmitReqPkt).Unpack(0xc00025a5a0, {0xc000024400, 0x1, 0x80})
                /Users/tonybai/Go/src/github.com/bigwhite/gocmpp/submit.go:262 +0x625
            github.com/bigwhite/gocmpp_test.FuzzSubmit.func1(0x0?, {0xc000024400, 0x1, 0x80})
                /Users/tonybai/Go/src/github.com/bigwhite/gocmpp/submit_fuzz_test.go:32 +0x45
            reflect.Value.call({0x11ccf40?, 0x12111c8?, 0x13?}, {0x1200df5, 0x4}, {0xc000217080, 0x2, 0x2?})
                /Users/tonybai/sdk/gotip/src/reflect/value.go:543 +0x814
            reflect.Value.Call({0x11ccf40?, 0x12111c8?, 0x10f9980?}, {0xc000217080, 0x2, 0x2})
                /Users/tonybai/sdk/gotip/src/reflect/value.go:339 +0xbf
            testing.(*F).Fuzz.func1.1(0x0?)
                /Users/tonybai/sdk/gotip/src/testing/fuzz.go:327 +0x20b
            testing.tRunner(0xc000201860, 0xc0003a0900)
                /Users/tonybai/sdk/gotip/src/testing/testing.go:1409 +0x102
            created by testing.(*F).Fuzz.func1
                /Users/tonybai/sdk/gotip/src/testing/fuzz.go:316 +0x5b8

    Crash written to testdata/fuzz/FuzzSubmit/582528ddfad69eb57775199a43e0f9fd5c94bba343ce7bb6724d4ebafe311ed4
    To re-run:
    go test github.com/bigwhite/gocmpp -run=FuzzSubmit/582528ddfad69eb57775199a43e0f9fd5c94bba343ce7bb6724d4ebafe311ed4
FAIL
exit status 1
FAIL    github.com/bigwhite/gocmpp  0.037s

我们看到go fuzzing在执行第一个测试时就发生了crash,fuzzing将crash输出了crash报告,并将引发这一crash的语料放入了testdata/fuzz/FuzzSubmit目录下:

$tree testdata
testdata
└── fuzz
    └── FuzzSubmit
        └── 582528ddfad69eb57775199a43e0f9fd5c94bba343ce7bb6724d4ebafe311ed4

查看一下引发crash的语料:

$cat testdata/fuzz/FuzzSubmit/582528ddfad69eb57775199a43e0f9fd5c94bba343ce7bb6724d4ebafe311ed4
go test fuzz v1
[]byte("0")

如果是真实的fuzz测试引发了crash,我们可以将该语料提取出来,建立针对它的TestXxx或为现有TestXxx添加一条测试数据来验证目标方法是否真实存在缺陷,如果的确存在缺陷,我们就需要修复它,并在修复后再次运行TestXxx,以保证我们的修复是有效的。

小结

在即将到来的Go 1.18中,Go fuzzing将正式成为Go的“一等公民”,Go将原生支持Fuzzing。不过Go fuzzing刚刚加入,可能还存在各种问题,根据以往经验,在2-3个版本后,go fuzzing必将成熟稳定起来,到那时,Gopher们就又拥有了一柄挖掘潜在bug和安全问题的利器了。

参考资料


“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!部落目前虽小,但持续力很强,欢迎大家加入!

img{512x368}

img{512x368}
img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

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

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

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

图解Go运行时调度器

本文翻译自《Illustrated Tales of Go Runtime Scheduler》

译注:原文章结构有些乱,笔者自行在译文中增加了一些分级标题,让结构显得更清晰一些:)。

goroutines形式的Go并发是编写现代并发软件的一种非常方便的方法,但是您的Go程序是如何高效地运行这些goroutines的呢?

在这篇文章中,我们将深入Go运行时底层,从设计角度了解Go运行时调度程序是如何实现其魔法的,并运用这些原理去解释在Go性能调试过程中产生的Go调度程序跟踪信息

所有的工程奇迹都源于需要。因此,要了解为什么需要一个Go运行时调度程序以及它是如何工作的,我们可以让时间回到操作系统兴起的那个时代,回顾操作系统的历史可以使我们深入的了解问题的根源。如果不了解问题的根源,就没有解决它的希望。这就是历史所能做的。

一. 操作系统的历史

  1. 单用户(无操作系统)。
  2. 批处理,独占系统,直到运行完成。
  3. 多道程序(译注:允许多个程序同时进入内存并运行)

多道程序的目的是使CPU和I/O重叠(overlap)。(译注:多道程序出现之前,当操作系统执行I/O操作时,CPU是空闲的;多道程序的引入实现了在一个程序占用CPU的时候,另一个程序在执行I/O操作)

那怎么实现多道程序(的CPU与I/O重叠)呢?两种方式:多道批处理系统和分时系统。

  • 多道批处理系统

    • IBM OS/MFT(具有固定数量的任务的多道程序)
    • IBM OS/MVT(具有可变数量的任务的多道程序)在这里,每个作业(job)仅获得其所需的内存量。随着job的进出,内存的划分会发生变化。
  • 分时

    • 这是一种多道程序设计,可以在作业之间快速切换。决定何时切换以及切换到哪个作业的过程就称为调度(scheduling)

当前,大多数操作系统使用分时调度程序

那么这些调度程序将用来调度什么实体(entity)呢?

  • 不同的正在执行的程序(即进程process)
  • 或作为进程子集存在使用CPU的基本单元:线程

但是在这些实体的切换是有代价的。

  • 调度成本

img{512x368}

图: 进程和线程的状态变量

因此,使用一个包含多个线程的进程的效率更高,因为进程创建既耗时又耗费资源。但是随后出现了多线程问题:C10k成为主要问题。

例如,如果将调度周期定为10ms(毫秒),并且有2个线程,则每个线程将分别获得5ms。如果您有5个线程,则每个线程将获得2ms。但是,如果有1000个线程怎么办?给每个线程一个10μs(微秒)的时间片?错,这样做很愚蠢,因为您将花费大量时间进行上下文切换,但是真正要完成的工作却进展缓慢或停滞不前。

您需要限制时间片的长度。在最后一种情况下,如果最小时间片为2ms并且有1000个线程,则调度周期需要增加到2s(10002ms)。如果有10,000个线程,则调度程序周期为20秒(100002ms)。在这个简单的示例中,如果每个线程都将分配给它的时间片用完,那么所有线程都完成一次运行需要20秒。因此,我们需要一些可以使并发成本降低而又不会造成过多开销的东西。

  • 用户层线程
    • 线程完全由运行时系统(用户级库)管理。
    • 理想情况下,快速高效:切换线程的代价不比函数调用多多少。
    • 操作系统内核对用户层线程一无所知,并像对待单线程进程(single-threaded process)一样对其进行管理。

在Go中,我们知道这样的用户层线程被称为“Goroutine”。

  • Goroutine

img{512x368}

图: goroutine vs. 线程

goroutine是由Go运行时管理的轻量级线程(lightweight thread)。要启动一个新的goroutine,只需在函数前面使用go关键字:go add(a, b)

  • Goroutine之旅
func main() {
    var wg sync.WaitGroup
    for i := 0; i <= 10; i++ {
        wg.Add(1)
        go func(i int) {
        defer wg.Done()
        fmt.Printf("loop i is - %d\n", i)
        }(i)
    }
    wg.Wait()
    fmt.Println("Hello, Welcome to Go")
}

https://play.golang.org/p/73lESLiva0A

您能猜出上面代码片段的输出吗?

loop i is - 10
loop i is - 0
loop i is - 1
loop i is - 2
loop i is - 3
loop i is - 4
loop i is - 5
loop i is - 6
loop i is - 7
loop i is - 8
loop i is - 9
Hello, Welcome to Go

如果我们看一下输出的一种组合,你可能马上就会有两个问题:

  • 11个goroutine如何并行运行?魔法?
  • goroutine以什么顺序运行?

img{512x368}

图:gopher版奇异博士

上面的这两个提问给我们带来了问题。

  • 问题概述
    • 如何将这些goroutines分配到在CPU处理器上运行的多个操作系统线程上运行?
    • 这些goroutines应该以什么顺序运行才能保证公平?

本文后续的讨论将主要围绕Go运行时调度程序从设计角度如何解决这些问题。但是,与所有问题一样,我们的讨论也需要定义一个明确的边界。否则,问题陈述可能太含糊,无法形成结论。调度程序可能针对多个目标中的一个或多个,对于我们来说,我们将自己限制在以下需求之内:

  1. 应该是并行、可扩展且公平的。
  2. 每个进程应可扩展到数百万个goroutine(C10M
  3. 内存利用率高。(RAM很便宜,但不是免费的。)
  4. 系统调用不应导致性能下降。(最大化吞吐量,最小化等待时间)

让我们开始为调度程序建模,以逐步解决这些问题。

二. Goroutine调度程序模型 (译者自行加的标题)

1. 模型概述(译者自行加的标题)

a) 一个线程执行一个Goroutine

局限性:

  • 并行和可扩展
    • 并行(是的)
    • 可扩展(不是真的)
  • 每个进程不能扩展到数百万个goroutine(C10M)。

b) M:N线程—混合线程

M个操作系统内核线程执行N个“goroutine”

img{512x368}

图: M个内核线程执行N个goroutine

实际执行代码和并行执行都需要内核线程。但是线程创建起来很昂贵,因此我们将N个goroutines映射到M个内核线程上去执行。Goroutine是Go代码,因此我们可以完全控制它。而且它在用户空间中,创建起来很便宜。

但是由于操作系统对goroutine一无所知。因此每个goroutine都有一个状态,以帮助调度器根据goroutine状态知道要运行哪个goroutine。与内核线程的状态信息相比,goroutine的状态信息很小,因此goroutine的上下文切换变得非常快。

  • 正在运行(Running) – 当前在内核线程上运行的goroutine。
  • 可运行(Runnable) – 等待内核线程来运行的goroutine。
  • 已阻塞(Blocked) – 等待某些条件的Goroutine(例如,阻塞在channel操作,系统调用,互斥锁上的goroutine)

img{512x368}

图: 2个线程同时运行2个goroutine

因此,Go运行时调度器通过将N个Goroutine多路复用到M个内核线程的方式来管理处于各种不同状态的goroutines。

2. 简单的M:N调度器

在我们简单的M:N调度器中,我们有一个全局运行队列(global run queue),某些操作将一个新的goroutine放入运行队列。M个内核线程访问调度程序从“运行队列”中获取并运行goroutine。多个线程正在尝试访问相同的内存区域,因此使用互斥锁来同步对该运行队列的访问。

img{512x368}

图: 简单的M:N调度器

但是,那些已阻塞的goroutine在哪里?

下面是goroutine可能会阻塞的情况:

  1. 在channel上发送和接收
  2. 网络I/O操作
  3. 阻塞的系统调用
  4. 使用定时器
  5. 使用互斥锁

那么我们将这些阻塞的goroutine放在哪里呢?— 将这些阻塞的goroutine放置在哪里的设计决策基本上是围绕一个基本原理进行的:

阻塞的goroutine不应阻塞底层内核线程!(避免线程上下文切换的成本)

channel操作期间阻塞的Goroutine

每个channel都有一个recvq(waitq),用于存储试图从该channel读取数据而阻塞的goroutine。

Sendq(waitq)存储试图将数据发送到channel而被阻止的goroutine 。(channel实现原理:-https://codeburst.io/diving-deep-into-the-golang-channels-549fd4ed21a8)

img{512x368}

图: channel操作期间阻塞的Goroutine

channel本身会将channel操作后的未阻塞goroutine放入“运行”队列(run queue)。

img{512x368}

图: channel操作后未阻碍的goroutine

那系统调用呢?

首先,让我们看一下阻塞系统调用。系统调用会阻塞底层内核线程,因此我们无法在该线程上调度任何其他Goroutine。

隐含阻塞系统调用可降低并行度。

img{512x368}

图: 阻塞系统调用可降低并行度

一旦发生阻塞系统调用,我们无法再在M2线程上安排任何其他Goroutine运行,从而导致CPU浪费。由于我们有工作要做,但没法运行它。

恢复并行度的方法是在进入系统调用时,我们可以唤醒另一个线程,该线程将从运行队列中选择可运行的goroutine。

img{512x368}

图: 恢复并行度的方法

但是现在,系统调用完成后,我们有超额等待调度的goroutine。因此,我们不会立即运行从阻塞系统调用中返回的goroutine。我们会将其放入调度程序的运行队列中。

img{512x368}

图: 避免超额等待调度

因此,在程序运行时,线程数远大于cpu核数。尽管没有明确说明,线程数大于cpu核数,并且所有空闲线程也由运行时管理,以避免启动过多的线程。

https://golang.org/pkg/runtime/debug/#SetMaxThreads

初始设置为10,000个线程,如果超过10,000个线程,程序将崩溃。

非阻塞系统调用-将goroutine阻塞在Integrated runtime poller上 ,并释放线程以运行另一个goroutine。

img{512x368}

例如,在非阻塞I/O(例如HTTP调用)的情况下。由于资源尚未准备就绪,第一个syscall将不会成功,这将迫使Go使用network poller并将goroutine暂停。

部分net.Read函数的实现:

    n, err := syscall.Read(fd.Sysfd, p)
        if err != nil {
            n = 0
            if err == syscall.EAGAIN && fd.pd.pollable() {
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }
    }

一旦完成第一个系统调用并明确指出资源尚未准备就绪,goroutine将暂停,直到network poller通知它资源已准备就绪。在这种情况下,线程M将不会被阻塞。

Poller将基于操作系统使用select/kqueue/epoll/IOCP等机制来知道哪个文件描述符已准备好,一旦文件描述符准备好进行读取或写入,它将把goroutine放回到运行队列中。

还有一个Sysmon OS线程,如果超过10ms未轮询网络,它就将定期轮询网络,并将已就绪的G添加到队列中。

基本上所有goroutine都被阻塞在下面操作上:

  1. channel
  2. 互斥锁
  3. 网络IO
  4. 定时器

有某种队列,可以帮助调度这些goroutine。

现在,运行时拥有具有以下功能的调度程序。

  • 它可以处理并行执行(多线程)。
  • 处理阻塞系统调用和网络I/O。
  • 处理阻塞在用户级别(在channel上)的调用。

但这不是可伸缩的(scalable)。

img{512x368}

图: 使用Mutex同步全局运行队列

您可以通过Mutex同步全局运行队列,但最终会遇到一些问题,例如

  1. 缓存一致性保证的开销。
  2. 在创建,销毁和调度Goroutine G时进行激烈的锁竞争。

使用分布式调度程序解决可伸缩性问题。

分布式调度程序-每个线程一个运行队列

img{512x368}

图: 分布式运行队列的调度程序

这样,我们可以看到的直接好处是,每个线程的本地运行队列(local run queue)现在都没有使用mutex。仍然有一个带有mutex的全局运行队列,但仅在特殊情况下使用。它不会影响可伸缩性。

但是现在,我们有多个运行队列。

  1. 本地运行队列
  2. 全局运行队列
  3. 网络轮询器(network poller)

我们应该从哪里运行下一个goroutine?

在Go中,轮询顺序定义如下:
1. 本地运行队列
2. 全局运行队列
3. 网络轮询器
4. 工作偷窃(work stealing)

即首先检查本地运行队列,如果为空则检查全局运行队列,然后检查网络轮询器,最后进行“偷窃工作”。到目前为止,我们对1,2,3有了一些概述。让我们看一下“工作偷窃(work stealing)”。

工作偷窃

如果本地工作队列为空,请尝试“从其他队列中偷窃工作”

img{512x368}

图: 偷窃工作

当一个线程有太多工作要做而另一个线程空闲时,工作偷窃可以解决这个问题。在Go中,如果本地队列为空,工作偷窃将尝试满足以下条件之一。

  • 从全局队列中拉取工作。
  • 从网络轮询器中拉取工作
  • 从其他线程的本地队列中偷窃工作

到目前为止,Go运行时的调度器具有以下功能:

  • 它可以处理并行执行(使用多线程)。
  • 处理阻塞系统调用和网络I/O。
  • 处理用户级别(比如:在channel)的阻塞调用。
  • 可伸缩扩展(scalable)

但这仍不是最有效的。

还记得我们在阻塞系统调用中恢复并行度的方式吗?

img{512x368}

图: 系统调用操作

它暗示在一个系统调用中我们可以有多个内核线程(可以是10或1000),这可能会比cpu核数多很多。这个方案将最终在以下期间产生了恒定的开销:

  • 偷窃工作时,它必须同时扫描所有内核线程(空闲的和运行goroutine的)本地运行队列,并且大多数都将是空闲的。
  • 垃圾回收,内存分配器都会遇到相同的扫描问题。(https://blog.learngoprogramming.com/a-visual-guide-to-golang-memory-allocator-from-ground-up-e132258453ed)

使用M:P:N线程克服效率问题。

M:P:N(3级调度程序)— 引入逻辑处理器P

P —表示处理器,可以将其视为在线程上运行的本地调度程序

img{512x368}

图: M:P:N模型

逻辑进程P的数量始终是固定的。(默认为当前进程可以使用的逻辑CPU数量)

然后,我们将本地运行队列(LRQ)放入固定数量的逻辑处理器(P)中(译者注:而不是每个内核线程一个本地运行队列)。

img{512x368}

图: 分布式三级运行队列调度程序

Go运行时将首先根据计算机的逻辑CPU数量(或根据请求)创建固定数量的逻辑处理器P。

每个goroutine(G)将在分配了逻辑CPU(P)的OS线程(M)上运行。

所以现在我们在以下期间没有了恒定的开销:

  • 偷窃工作 -只需扫描固定数量的逻辑处理器(P)的本地运行队列。
  • 垃圾回收,内存分配器也将获得相同的好处。

使用固定逻辑处理器(P)的系统调用呢?

Go通过将它们包装在运行时中来优化系统调用(无论是否阻塞)。

img{512x368}

图: 阻塞系统调用的包装器

阻塞SYSCALL方法封装在runtime.entersyscall(SB)和 runtime.exitsyscall(SB)之间。

从字面上看,某些逻辑在进入系统调用之前被执行,而某些逻辑在系统调用返回之后执行。进行阻塞的系统调用时,此包装器将自动将P与线程M(即将执行阻塞系统调用的线程)解绑,并允许另一个线程在其上运行。

img{512x368}

图:阻塞Syscall的M交出P

这使得Go运行时可以高效地处理阻塞的系统调用,而无需增加运行队列(译注:本地运行队列数量始终是和P数量一致的)。

一旦阻塞系统调用返回,会发生什么?

  • 运行时会尝试获取之前绑定的那个P,然后继续执行。
  • 运行时尝试在P空闲列表中获取一个P并恢复执行。
  • 运行时将goroutine放在全局队列中,并将关联的M放回M空闲列表。

自旋线程和空闲线程

当M2线程在syscall返回后变得空闲时。如何处理这个空闲的M2线程。从理论上讲,如果线程完成了所需的操作,则应将其销毁,然后再安排进程中的其他线程到CPU上执行。这就是我们通常所说的操作系统中线程的“抢占式调度”。

考虑上述syscall中的情况。如果我们销毁了M2线程,而同时M3线程即将进入syscall。此时,在OS创建新的内核线程并将其调度执行之前,我们无法处理可运行的goroutine。频繁的线程前抢占操作不仅会增加OS的负载,而且对于性能要求更高的程序几乎是不可接受的。

因此,为了适当地利用操作系统的资源并防止频繁的线程抢占给操作系统带来的负担,我们不会销毁内核线程M2,而是使其执行自旋操作并以备将来使用。尽管这看起来是在浪费一些资源。但是,与线程之间的频繁抢占以及频繁的创建和销毁操作相比,“空闲线程”要付出的代价更少。

Spinning Thread(自旋线程) — 例如,在具有一个内核线程M(1)和一个逻辑处理器(P)的Go程序中,如果正在执行的M被syscall阻塞,则运行时会请求与P数量相同的“Spinning Threads”以允许等待的可运行goroutine继续执行。因此,在此期间,内核线程的数量M将大于P的数量(自旋线程+阻塞线程)。因此,即使将runtime.GOMAXPROCS的值设置为1,程序也将处于多线程状态。

调度中的公平性如何?—公平地选择下一个要执行的goroutine

与许多其他调度程序一样,Go也具有公平性约束,并且由goroutine的实现所强加,因为Runnable goroutine应该最终得到调度并运行。

这是Go Runtime Scheduler的四个典型的公平性约束:

任何运行时间超过10ms的goroutine都被标记为可抢占(软限制)。但是,抢占仅在函数执行开始处才能完成。Go当前在函数开始处中使用了由编译器插入的协作抢占点。

  • 无限循环 – 抢占(约10毫秒的时间片)- 软限制

但请小心无限循环,因为Go的调度程序不是抢先的(直到Go 1.13)。如果循环不包含任何抢占点(例如函数调用或分配内存),则它们将阻止其他goroutine的运行。一个简单的例子是:

package main

func main() {
    go println("goroutine ran")
    for {}
}

如果你运行:

GOMAXPROCS=1 go run main.go

直到Go(1.13)才可能打印该语句。由于缺少抢占点,main Goroutine将独占处理器。

  • 本地运行队列 -抢占(〜10ms时间片)- 软限制
  • 通过每61次调度就检查一次全局运行队列,可以避免全局运行队列处于“饥饿”状态。
  • 网络轮询器饥饿 后台线程会在主工作线程未轮询的情况下偶尔会轮询网络。

Go 1.14有一个新的“非合作抢占”机制。

有了这种机制,Go运行时便有了具有所有必需功能的Scheduler。

  • 它可以处理并行执行(多线程)。
  • 处理阻塞系统调用和网络I/O。
  • 处理用户级别(在channel上)的阻塞调用。
  • 可扩展
  • 高效
  • 公平

这提供了大量的并发性,并且始终尝试实现最大的利用率和最小的延迟。

现在,我们总体上对Go运行时调度程序有了一些了解,我们如何使用它?Go为我们提供了一个跟踪工具,即调度程序跟踪(scheduler trace),目的是提供有关调度行为的信息并用来调试与goroutine调度器伸缩性相关的问题。

三. 调度器跟踪

使用GODEBUG=schedtrace=DURATION环境变量运行Go程序以启用调度程序跟踪。(DURATION是以毫秒为单位的输出周期。)

img{512x368}

图:以100ms粒度对schedtrace输出采样

有关调度器跟踪的内容,Go Wiki拥有更多信息。

参考:Dmitry Vyukov的可扩展Go Scheduler设计文档和演讲 https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit

Gopher艺术作品致谢:Ashley Mcnamara。


我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网上线了,感谢小伙伴们学习支持!

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

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

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

微博:https://weibo.com/bigwhite20xx
微信公众号:iamtonybai
博客:tonybai.com
github: https://github.com/bigwhite

微信赞赏:
img{512x368}

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

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