这是我参与「第五届青训营 」伴学笔记创作活动的第 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 + 1 即 x = 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.mod 和 go.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 的这一波操作其实带来了另外一个问题:如果你的项目同时依赖了某个库的 v1 和 v2,那你其实也不能直接把 v1 的类 A 直接当成 v2 的类 A,而是要把它们区分开来,变成 A/v1 和 A/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=.