Go 语言进阶「并发、依赖管理、测试」| 青训营笔记

85 阅读3分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 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 测试有以下几个优点:

  1. 控制函数的返回值和行为: 通过打桩或模拟的手段,可以更好地控制函数或类的返回值和行为,便于进行单元测试。
  2. 减少依赖: 当一个函数或类依赖于其他函数或类时,通过打桩或模拟的手段,可以减少对其他函数或类的依赖。
  3. 提高测试效率: 通过模拟或打桩的手段,可以更快地进行单元测试。
  4. 提高测试的可靠性: 通过模拟或打桩的手段,可以更好地控制函数或类的返回值和行为,提高单元测试的可靠性。

函数打桩是指在单元测试中,将一个函数的实际实现替换为一个桩函数的过程。这样做的目的是为了更好的控制函数的返回值和行为,便于单元测试。

举个例子,假设有一个函数 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 . 命令来运行基准测试。运行结果会显示每次操作的时间,以及每秒运行的次数。基准测试是很重要的一部分,因为它可以帮助我们评估代码的性能,找出瓶颈并进行优化。

基准测试通常用于测试:

  1. 代码的执行时间,可以用来比较不同算法、不同数据结构等的性能。
  2. 内存使用量,可以用来比较不同的内存分配策略或调优等的性能。
  3. 磁盘 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 之间的相似之处,也了解了他们之间的不同之处,对于最后的实践练习,只是阅读了源码,我还要多花一些时间来理解,尽量独自实现出相同的功能。