这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天
Go 语言进阶课堂笔记
1 本次课堂的内容
本节课程重点关注 Go 语言的并发编程, 着重分析 Go 语言中的 goroutine 和 channel 的使用,学习如何使用 Go 语言的并发机制来提高程序的性能和伸缩性. 同时也会学习使用 Go 语言的依赖管理工具来管理项目中的依赖关系,以及如何使用 Go 语言提供的单元测试工具来编写高质量的单元测试用例
2 详细知识点的介绍
2.1 并发编程
2.1.1 协程 (Coroutine)
Go 语言通过实现协程 (Coroutine) 可以充分发挥多核优势,高效运行。协程作为一种轻量级的线程,它直接由 Go 语言运行时管理的,而不是由操作系统管理。
协程和线程有很多相似之处,都可以让程序并发执行多个任务。但是协程比线程更轻量级,它的上下文切换更快,并且不需要像线程那样需要操作系统的内核支持。因此,协程可以更高效地利用多核 CPU 的资源。
2.1.2 Channel
Go 语言提倡通过通信来共享内存,而不是通过共享内存来通信。这种方式称为通信优先于共享内存 (Communication-Oriented Programming)。
通过共享内存来通信的方式,线程之间共享同一块内存,并通过锁来保证同步访问,但是这种方式有很多缺点,比如死锁、竞争条件和性能下降等。
通过通信来共享内存的方式,线程之间通过 channel 来进行通信,而不是直接访问共享内存。这样可以避免上述缺点,并且更加安全可靠。它允许一个 goroutine 将数据发送给另一个 goroutine,并且可以指定 channel 的类型,来保证发送和接收的数据类型一致。
使用 channel 的基本语法如下:
// 创建一个 int 类型的 channel
c1 := make(chan int)
// 创建一个缓冲大小为 10 的 channel
c2 := make(chan int, 10)
// 向 channel 发送数据
c1 <- 1
// 从 channel 接收数据
data := <-c1
channel 在使用上有点类似于生产者-消费者模型。在生产者-消费者模型中,生产者负责生产数据,消费者负责消费数据,而数据是通过一个共享的缓冲区进行传递的。这个缓冲区就类似于 channel。
在 Go 语言中,通过 channel 可以在不同的 goroutine 之间实现生产者和消费者的模型。生产者 goroutine 通过 channel 发送数据给消费者 goroutine,消费者 goroutine 通过 channel 接收数据,并处理数据。这样就可以实现多个生产者和消费者之间的并发执行。例如下面这个函数,A 子协程发送 0-9 B 子协程计算输入数字的平方,主协程输出最后的平方数:
func CalSquare(){
src := make(chan int)
dest := make(chan int, 3)
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}()
for i := range dest {
fmt.Println(i)
}
}
channel 在进行读取和写入时,会有一些内置的机制来管理读取和写入的状态。当数据被写入到 channel 中时,channel 内部的缓冲区会被填充数据,并且标记为可读状态。当数据被读取出来时,channel 内部的缓冲区会被清空,并且标记为不可读状态。
这样一来,如果主协程还没有读取 dest 中的数据 B 协程就不会向 dest 中写入,同理 B 协程没有读取 src 中的内容 A 协程就不会向 src 中写入,这样就保证的输出的顺序。
2.1.3 Lock
Go 语言也可以使用锁来实现同步,在 Go 语言自带的 sync 包提供了 Mutex(互斥锁)使用锁的方式如下:
var lock sync.Mutex
lock.Lock()
// 访问共享数据
lock.Unlock()
2.1.4 WaitGroup
WaitGroup 在 Go 语言中用于线程同步。调用 Add 方法来增加等待的线程数量,这时计数器会 +1。在每个线程中,我们调用 Done 方法来表示线程执行完毕,减少等待的线程数量,这时计数器会 -1。最后调用 Wait 方法来等待所有线程执行完毕,Wait 方法会阻塞直到计数器为 0。下面是一个例子,在这个例子中主协程将创建五个子协程,然后等待五个子协程结束后再退出。
func ManyGoWait() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func() {
defer wg.Done()
fmt.Println("Hello")
}()
}
wg.Wait()
}
2.2 依赖管理
2.2.1 GOPATH
GOPATH 环境变量指定了 Go 语言程序的工作目录,在这个目录中包含了三个子目录:src、pkg 和 bin。src 目录用于存放源码文件,pkg 目录用于存放编译后的包文件,bin 目录用于存放可执行文件。这种管理方法有很大的弊端,假如项目 A 与项目 B 依赖同一个包的不同版本,就无法运行。GOPATH 无法实现包的多版本控制。
2.2.2 Go Vendor
项目目录下增加 vender 文件,所有依赖包副本形式放在 $ProjectRoot/vendor 下。当项目中使用了 Vendor 管理依赖后, Go 会优先在 vendor 目录中寻找依赖包,而不是在 GOPATH 中寻找。通过对每一个项目引入一份依赖的副本解决了上面依赖冲突的问题。
但是这也有一些问题,比如可以想象,为每一个项目引入一份依赖的副本会增加项目体积,特别是当项目依赖的包很多时,还有当项目中有多个依赖使用同一个第三方包时,如果它们使用的版本不同,可能会导致冲突。
2.2.3 Go Module
go mod 是 Go 语言提供的依赖管理工具, Go 1.11 开始支持,在 1.16 默认开启。它可以可以方便地管理项目依赖的第三方包的版本。首先 go mod 工具需要一个 go. mod 文件,其中包含了项目依赖的包的信息,这些信息由 go mod 工具自动生成和维护。其次 Go 语言中的多数第三方包都托管在 Go 中央仓库上,开发者可以通过 go get 命令将第三方包下载到本地。而这个中央仓库缓解了代码管理仓库的压力,并且避免了代码管理仓库中对包的修改、删除造成的问题。
2.3 单元测试
2.3.1 单元测试概念和规则
单元测试是一种用来验证代码功能正确性的方法,它包括了输入、测试单元、输出、期望校对这几个部分。完整的单元测试可以有效防止程序出错,而且成本也相对较低。
单元测试的覆盖率是衡量代码质量的关键因素,通常我们应该尽量达到 50% ~ 60% 的覆盖率,对于高要求的场景,覆盖率要达到 80%。在进行单元测试时,应该注意测试分支之间应该相互独立,并且要尽量全面覆盖。同时,测试单元的粒度也要尽量小,保证函数单一职责。
在 Go 语言中所有的测试文件以 _test.go 结尾。测试函数要遵循 func TestXxx(*testing.T) , 将初始化的逻辑放到 TestMain 中
2.3.2 Mock 测试
Mock 测试是在单元测试中常用的一种方法。它是通过模拟或打桩等手段,来控制函数或类的返回值和行为,来进行单元测试。Mock 测试有以下几个优点:
- 控制函数的返回值和行为: 通过打桩或模拟的手段,可以更好地控制函数或类的返回值和行为,便于进行单元测试。
- 减少依赖: 当一个函数或类依赖于其他函数或类时,通过打桩或模拟的手段,可以减少对其他函数或类的依赖。
- 提高测试效率: 通过模拟或打桩的手段,可以更快地进行单元测试。
- 提高测试的可靠性: 通过模拟或打桩的手段,可以更好地控制函数或类的返回值和行为,提高单元测试的可靠性。
函数打桩是指在单元测试中,将一个函数的实际实现替换为一个桩函数的过程。这样做的目的是为了更好的控制函数的返回值和行为,便于单元测试。
举个例子,假设有一个函数 foo (),它依赖于另一个函数 bar ()。如果在测试 foo () 的时候,不能控制 bar () 的返回值,那么就会导致测试难以进行。此时,就可以使用函数打桩的方法,将 bar () 函数的实际实现替换为一个桩函数。这样就可以更好地控制 bar () 的返回值和行为,便于对 foo () 进行单元测试。如下:
package main
import (
"fmt"
"github.com/bouk/monkey"
)
func bar() int {
// ...
// 读取文件内容
return 文件内容 // 我们默认的文件内容是1
}
func foo() int {
return bar() + 1
}
func main() {
// 打桩
monkey.Patch(bar, func() int {
return 3
})
// 在函数结束后恢复
defer monkey.Unpatch(bar)
// 测试
result := foo()
fmt.Println(result) // 输出2
}
在这样 Mock 了之后,不论文件被修改还是文件被删除,我们都可以继续测试 foo () 函数而不受影响,这样就达到了 Mock 的目的。
2.3.3 基准测试
基准测试(benchmark testing)是一种用来测量代码性能的测试方法。它通过重复运行一段代码,来测量代码的运行时间、内存使用量等性能指标。
基准测试的语法与普通的单元测试类似,只需要在函数名前面加上 Benchmark 前缀即可。通过运行 go test -bench . 命令来运行基准测试。运行结果会显示每次操作的时间,以及每秒运行的次数。基准测试是很重要的一部分,因为它可以帮助我们评估代码的性能,找出瓶颈并进行优化。
基准测试通常用于测试:
- 代码的执行时间,可以用来比较不同算法、不同数据结构等的性能。
- 内存使用量,可以用来比较不同的内存分配策略或调优等的性能。
- 磁盘 I/O 性能,可以用来比较不同的存储方案或调优等的性能。
注意
在基准测试中,不应该进行任何输入输出操作,否则会影响测试结果。另外,还应该尽量让函数多运行几次,以便得到更精确的结果。
基准测试的结果通常以毫秒或纳秒为单位,以字节或兆字节为单位。下面来看一个例子:
package golearn
import (
"math/rand"
)
var ServerIndex [10]int
func InitServerIndex() {
for i := 0; i < 10; i++ {
ServerIndex[i] = i + 100
}
}
func Select() int {
return ServerIndex[rand.Intn(10)]
}
这个函数的功能是随机选择一个服务器,下面我们来完成他的基准测试:
import (
"testing"
)
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()
}
})
}
这里有两个基准测试,第一个是串行执行第二个是并行执行,我们可以看到结果,串行执行的速度反而更快,这是因为在并行执行的情况下,会有更多的上下文切换和调度操作,这些操作会增加系统开销,从而导致性能下降。
goos: windows
goarch: amd64
pkg: go-learn
cpu: Intel(R) Core(TM) i5-8300H CPU @ 2.30GHz
BenchmarkSelect-8 60658753 17.78 ns/op
BenchmarkSelectParallel-8 22484899 51.40 ns/op
PASS
ok go-learn 3.534s
3 实践练习例子
使用 Gin 框架实现一个简易的论坛,包括帖子与回复的展示。
4 总结
今天学习了 Go 语言的进阶知识,学习过程中可以发现 Go 与 java 之间的相似之处,也了解了他们之间的不同之处,对于最后的实践练习,只是阅读了源码,我还要多花一些时间来理解,尽量独自实现出相同的功能。