Go 语言并发、依赖、测试 | 青训营笔记

66 阅读4分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天

Channel

make(chan 元素类型, [缓冲大小])

通道的运行和生产消费模型相似,往通道里塞东西的叫生产者,往通道里拿东西的叫消费者。

通道可以设置一个缓冲区,当缓冲区满了的时候,生产者便堵塞在通道写入的位置,无法继续生产。

当缓冲区为空时,消费者因为从通道中取不出东西,也会堵塞在通道读取的位置,无法继续消费。

通道也可以不设置缓冲区,这样的通道会导致生产者和消费者的代码以同步的模式执行,因此也称为同步通道。

这是我的,你们都别动! - 并发安全 Lock

当谈到并发的时候,肯定要讨论并发安全问题。

我们用 addWithoutLock 的例子来学习什么是并发安全问题。

假设我们有一段这样的代码:

var x int64

func addWithoutLock() {
	for i := 0; i < 2000; i++ {
		x += 1
	}
}

func main() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second)
	println("WithoutLock:", x)
}

它的目的很单纯,就是想通过 5 个协程各执行 x += 1 2000 次,这样总共会加 10000 次,最终得到 x 的结果为 10000。

然而,实际上 x 可能并不会等于 10000。

因为一个协程在执行的过程中,运行代码的机会被交给其他协程导致的。

假设协程 A 读到了 x = 3,当它计算了 x + 1 = 4 后,想把 4 赋值给 x 时,CPU 跑去执行其他协程了。

在另一个协程 B 中, x = x + 1x = 3 + 1 被执行了一次, x 已经是 4 了。

之后 CPU 回来给 A 执行的时间了, A 继续它之前没有完成的操作, 再一次把 4 赋值给了 x。

这个时候,我们就浪费了一次 x += 1 的机会。

当错误不断累积,最后得到的结果就是 x 比 10000 小,这就是问题所在。

(但也有可能仍然等于 10000,这可能是因为你的运气比较好)

我们把像 x 这样被多个协程轮着用的变量称为 临界资源,对临界资源进行操作的代码称为 临界区

那么解决这种并发安全的问题,就需要用锁了。

在 Go 中,我们可以这样召唤出一个锁:

var lock sync.Mutex

Mutex 叫互斥锁,它是用来防止两条协程同时对同一个公共资源进行读写的机制。

在 Go 中, sync.Mutex 有两个主要的函数: 锁定 Lock() 和 解锁 Unlock()

当一个互斥锁被锁定以后,其他协程就不能继续对它锁定,它会一直卡在那儿等到这个锁被解开。

我们来回顾一下 addWithLock 这个例子:

var (
	x    int64
	lock sync.Mutex
)
func addWithLock() {
	for i := 0; i < 2000; i++ {
		lock.Lock()
		x += 1
		lock.Unlock()
	}
}
func main() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	println("WithLock:", x)
}

在这个例子中,我们总共启动了 5 个协程来实现 x += 1 这一操作。

当协程 A 执行到了 lock.Lock() 之后,其他几个协程 1~4 就会堵塞在 lock.Lock() 处,无法继续执行。

这个时候,5 个协程里只有一个协程可以执行 x += 1 这一操作,保证了 x 结果的唯一性。

接下来,协程 A 会执行 lock.Unlock() 解锁。

此后 5 个协程中会有一个协程得到了执行代码的机会,抢在其他协程之前执行 lock.Lock(),然后由它来实现 x += 1

这样一来, x += 1 这段代码在执行的过程中,就不会有其他协程拿着 x 到处乱改, x 也可以安全地被加到 10000 了!

喂,你等我们一下呀! - 线程同步 WaitGroup

当我们召唤出了一堆协程搞事之后,你可能想知道他们搞完之后的结果。

这需要我们知道总共召唤出了多少个协程,还需要在一个协程执行完毕之后把数量减一。等数量变成了 0 的时候,就说明我们召唤出来的协程已经全部完活了。

但是如果让我们自己写会很麻烦,于是 Go 为我们准备了 WaitGroup 这个小可爱,来帮我们管理这堆协程。

下面这个例子简单粗暴地介绍了 WaitGroup 的使用方法:

func ManyGoWait() {
    // 召唤 WaitGroup
	var wg sync.WaitGroup 
    // 告诉 WaitGroup 我们会召唤 5 个 协程
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
            // 执行完毕就和 WaitGroup 吱一声
			defer wg.Done()
			hello(j)
		}(i)
	}
    // 等待 5 个协程全部执行完毕
	wg.Wait()
    // 执行完毕辣!
}

依赖管理

我们知道一个程序肯定会用到很多来自其他地方的代码,他们被称为依赖,这很好理解。

但人生总是充满了痛苦和折磨,管理依赖就是一件麻烦的事情,为此 Go 的依赖管理系统发展至今分为了三个阶段。

GOPATH

这是最简单粗暴的管理方案,当你装好 Go 的时候,你可能会注意到电脑上出现了一个叫 GOPATH 的环境变量,

Go 就把依赖藏在这个变量所指向的文件夹里。

在你的资源管理器中输入 %GOPATH 然后回车,你就可以看见他们。

GOPATH 中有一个 src 文件夹, Go 的依赖就按照项目的名字挨个放在里面。

问题来了

假如你有两个项目 ProjectA, ProjectB, 他们同时依赖一个叫做 Dep1 的玩意,那没什么问题。

但如果 Dep1 有两个版本 v1.0 和 v2.0, 而且 ProjectA 依赖的是 v1.0, ProjectB 依赖的是 v2.0,那咋办呢?

按照 GOPATH 的藏法,你的电脑上只能存一个版本的 Dep1。

假如你的电脑上放了 Dep1 的 v2.0,但如果 ProjectA 用的方法在 Dep1 的 v1.0 里有,而 v2.0 没有, 那你就只能编译 ProjectB,不能编译 ProjectA 了。

这太蠢了,于是就引出了 Go Vendor。

Go Vendor

Go Vendor 方案其实就是在 ProjectA 和 ProjectB 下面各放一个 vendor 文件夹,然后把项目的依赖往里面放。

在编译的时候, Go 先找 vendor 下的依赖,找不到了再去 GOPATH 里面找。

照这么看,问题好像解决了吧!

笑死,哪有这么简单?

千万可别忘了依赖的本身也会有依赖的。

假如 ProjectA 有两个依赖 Dep2 和 Dep3。他们分别依赖 Dep4 的不同版本,那咋办呢?这 GOPATH 的问题不就又回来了嘛!

于是,就引出了 Go Module。

Go Module

我们现在看见的很多 go 项目都有两个文件 go.modgo.sum,这就是 Go Module 的产物。

Go Module 的核心是通过 go.mod 这个文件来描述依赖之间的关系。

这个文件由三部分组成:

module example/project/app // 项目本身的依赖标识符

go 1.16 // 所使用的 Go 版本

require ( // 下面都是依赖
	bou.ke/monkey v1.0.2
	gopkg.in/gin-gonic/gin.v1 v1.3.0
	gopkg.in/go-playground/validator.v8 v8.18.2 // indirect
)

依赖标识符很像一段 URL,它这种格式可以让我们直观地知道要去哪里找到这个依赖。

// indirect 注释标注的依赖叫间接依赖,它们就是依赖的依赖。

这里还要介绍一个重要的概念叫语义化版本

在这个概念中,版本号被分为了三个部分:主版本号.次版本号.修订号

其中,最重要的是 主版本号,当你发布一个新版本时,如果这个数字改变了,就说明这个版本和上个版本相比,有不兼容的 API 更改,别人直接升级的话是编译不了的。

但如果只是改变了 次版本号 或者 修订号,那还是可以直接编译的。

还有一种版本的表示方式是 vx.0.0-yyyymmddhhmmss-abcdefgh1234,它和上面一个相比多了一串时间日期和 commit id,这可以用来更加精确地定位某一个版本。

当出现了版本号冲突时,如果主版本号相同, Go 会优先选择次版本号最大的那一个。

好吧,那怎么解决主版本号冲突的问题?

其实 Go 用了一个很简单的方法:

当一个模块的版本从 v1 变成了 v2 时候, Go 要求依赖标识符在它的后面加上一个 /v2,把它们当成两个不同的依赖来处理,这样就可以区分开来了。

到了这里,问题一定解决了,对吧?

困难总比办法多。

Go 的这一波操作其实带来了另外一个问题:如果你的项目同时依赖了某个库的 v1v2,那你其实也不能直接把 v1 的类 A 直接当成 v2 的类 A,而是要把它们区分开来,变成 A/v1A/v2,用的时候还要进行强制转换,这就有点混乱了。

如果发布了 v3,那岂不是得三三互转,发个 v4 又咋办?

所以一些开源项目现在在努力避免让自己的版本号跳到 v2,有的甚至不惜把自己的整个依赖标识符改掉。

举个例子:

github.com/golang/protobuf 重构以后,就改成了 google.golang.org/protobuf

也许之后 Go 还会有更好的解决方法吧!

Go Proxy

虽然依赖标识符看着像是个 URL,甚至有很多你放浏览器里也能直接打开,但 Go 并不是直接从那些网站里下载依赖的,而是先通过 Proxy。

考虑到很多人会把依赖放到 GitHub 上,如果一个依赖可能会有很多人下,大家都从 GitHub 拿的话, GitHub 也会有意见的 —— 凭什么白给!

所以我们的 go 会先找 Proxy 下载依赖,而 Proxy 可以缓存受欢迎的依赖,减轻压力。

Go 的 Proxy 地址是可以由用户指定的,所以我们可以把它换成国内的镜像,这样下载的时候就会更快了。

go env -w GOPROXY=https://goproxy.cn,direct

一些有用的命令

# 自动下载依赖,并删除没用的依赖
go mod tidy

测试

代码在发布之前是要进行测试的,否则造成严重损失的时候饭碗就保不住了。

测试的原理就是写一小段代码调用我们的实际函数,然后我们判断函数的返回结果是不是我们预期的结果,

如果是的话,就说明测试通过,函数没有问题。

单元测试

在 Go 中,以 _test.go 结尾的文件表示测试代码。

以 Test 开头的函数 func TestXxx(t *testing.T) 表示这是一个测试函数。

TestMain 负责初始化测试资源。

运行测试

执行下面的命令,就可以运行测试。

go test 

覆盖率

项目衡量测试水准和测试强度的指标是覆盖率,即运行测试时会被执行的代码占所有代码的比例。

我们可以用

go test a_test.go a.go --cover

让测试结果显示代码的覆盖率。

测试 Tips

一般覆盖率在 50%~60% 左右,80%+ 属于较高覆盖率。

在编写测试代码时,要保证分支互相独立、全面覆盖。

测试单元的粒度要小,函数职责单一。

Mock

测试单元要让我们的函数有两个基本要求:幂等、稳定。

即每次执行测试的结果都应该是一样的,不会随着测试次数的变化而变化。

但如果我们的函数在调用的时候,会往数据库里塞东西,或者往文件里面写东西,这会影响测试的结果,那怎么办呢?

这个时候,我们就需要 Mock。

Mock 可以帮我们打桩,它可以帮我们临时替换掉某个函数的实现逻辑,以此来避免真的往数据库或文件里写东西。

基准测试

基准测试可以让我们在优化代码时,用来寻找我们代码中的热点代码。

基准测试的函数与单元测试相似,不同的是它的函数以 Benchmark 开头,参数是 *testing.B

下面是基准测试的两个例子:

func BenchmarkSelect(b *testing.B) {
	InitServerIndex()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		Select()
	}
}
func BenchmarkSelectParallel(b *testing.B) {
	InitServerIndex()
	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			Select()
		}
	})
}

启动测试的方法:

go test -bench=.