Go语言进阶(并发&依赖管理&benchmark)
前置
笔者环境
- macos 10.15.7
- Golang 1.18
- GoLand 2022.01
读完本文可以获得
- 并发编程的概念,并行与并发的区别
- Goroutine和CSP的用法
- channel的使用场景
- 并发安全的实现方法
- 测试的分类与方法
一、并发编程
1.1 并行和并发
并发
多线程程序在一个核心的CPU上运行(多个任务在1个核心的CPU上交替执行,在CPU单位时间内,只有一个任务在运行)
并行
- 多线程程序在多个核心的CPU上运行(多个任务在多个核心的CPU上执行,每个核心的CPU上执行的多任务即是并发)
- 并行是实现并发的一种手段
1.2 Goroutine
1.2.1 介绍
Goroutine是Go语言中的协程,一种轻量级的线程,由Go语言的运行时管理,可以实现高并发的程序设计。
1.2.2 协程与线程的区别
差异点 | 协程 | 线程 |
---|---|---|
语言 | Go语言的特性 | 多语言都支持线程 |
调度方式 | 由Go运行时管理 | 由操作系统内核管理 |
切换效率 | 无需切换,在用户态运行 | 用户态切换到内核态 |
内存 | KB级别 | MB级别 |
数量 | 一个程序可以增长到上百万个协程 | 线程数一般不能那么多 |
通信 | 通过channel等可以直接通信 | 线程间通信更复杂 |
使用方式 | go关键字可以启动 | 需要调用thread API创建 |
1.2.3 Goroutine使用
语法
go func() {
// 并发执行的函数
}()
使用多个协程打印hello
package main
import (
"fmt"
"time"
)
func hello(i int) {
fmt.Println("hello goroutine : " + fmt.Sprint(i))
}
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i)
}
time.Sleep(time.Second)
}
func main() {
HelloGoRoutine()
}
运行结果是乱序的表示是并行运行的
hello goroutine : 4
hello goroutine : 2
hello goroutine : 1
hello goroutine : 3
hello goroutine : 0
1.3 CSP
1.3.1 介绍
CSP(Communicating Sequential Processes)通信顺序进程,是Go语言中重要的并发模型。
主要特征有
-
顺序进程:每个进程内部按顺序执行。
-
通信进程:进程间通过通信(Message Passing)来协作。
-
数据流:程序通过在进程间传递数据来工作。
Go语言的CSP实现主要通过goroutine和channel
- goroutine作为顺序执行的进程
- channel用于goroutine间的通信
1.3.2 共享内存
假设有两个goroutine,一个负责统计数词频,一个负责记录最高频的数。
使用共享内存的方式
var frequencies map[int]int //共享内存
go func() {
//统计频次
for {
//读写frequencies map
}
}()
go func() {
//记录最高频数
for {
//读取frequencies map
}
}()
两个goroutine直接读写同一份内存frequencies map,这是共享内存的方式,可能会出现资源竞争问题。
使用CSP通信的方式
ch := make(chan map[int]int) //通信channel
go func() {
//统计频次
frequencies := make(map[int]int)
//通过channel发送frequencies
ch <- frequencies
}()
go func() {
//记录最高频数
//从channel接收frequencies
frequencies := <- ch
}()
两个goroutine之间通过channel传递frequencies,相当于“共享”了数据,但实际上是复制而不是直接共享内存。
相比通过共享内存来实现通,,CSP通过通信来实现共享内存,避免了竞争风险。
1.4 Channel
1.4.1 介绍
Go语言中的channel是实现goroutine之间通信的重要方式。
-
channel类型
chan 用于定义,标识传输的数据类型。
ch := make(chan 数据类型,[缓冲大小])
-
发送接收数据
ch <- 10 // 发送 x := <- ch // 接收
-
关闭channel
close(ch)
-
遍历channel
for x := range ch { fmt.Println(x) }
-
select可以监听channel
通过select,我们可以方便地处理多个channel的IO操作。
ch1 := make(chan int) ch2 := make(chan int) select { case <-ch1: // 收到ch1的数据 case <-ch2: // 收到ch2的数据 default: // 都未收到 }
- select可以同时监听一个或多个channel,当某个channel ready时,就会执行对应的case代码块。
- 它提供了一个可以同时响应多个channel的机制。
- 默认的default分支可以在都未ready时执行。
1.4.2 有缓冲VS无缓冲
无缓冲channel | 有缓冲channel | |
---|---|---|
创建 | make(chan Type) | make(chan Type, Capacity) |
发送 | 阻塞,直到接收准备好 | 若缓冲区未满,不阻塞 |
接收 | 阻塞,直到有数据发送 | 若缓冲区不为空,不阻塞 |
同步方式 | 强同步(完全对齐发送和接收) | 弱同步(允许一定时间的不对齐) |
使用场景 | 需强制同步时,保证实时性 | 容许短暂不对齐,换取高效率 |
总结:
- 无缓冲channel强制同步
- 有缓冲channel允许短暂异步,更灵活
1.4.3 生产消费模型
package main
import "fmt"
func CalSquare() {
src := make(chan int) // 生产无缓冲
dest := make(chan int, 3) // 消费缓冲3个元素
// 子协程发送0-9数字
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)
}
}
func main() {
CalSquare()
}
运行结果都是顺序的,保证了线程安全,符合CSP并发模型。
0
1
4
9
16
25
36
49
64
81
1.4 并发安全 Lock
1.4.1 介绍
Go语言实现并发时需要处理好同步访问共享资源的问题,防止数据竞争。 Go提供了两种线程安全的锁机制:
- sync.Mutex
互斥锁,确保同时只有一个goroutine可以访问共享数据。
var mu sync.Mutex
mu.Lock()
// 访问共享资源
mu.Unlock()
- sync.RWMutex
读写互斥锁,可以同时允许多个读,但写时独占。读写互斥锁实现了读写分离,相比普通的互斥锁可以提供更高的并发性能。
var mu sync.RWMutex
mu.RLock()
// 读共享资源
mu.RUnlock()
mu.Lock()
// 写共享资源
mu.Unlock()
使用这些锁机制可以确保同步访问共享资源,实现并发安全。
需要注意的是:
- 锁粒度控制,不要锁定过多代码
- 避免死锁,锁的顺序要一致
- 优先选择读写锁,提高效率
合理利用锁机制,可以构建高效健壮的 Go 并发程序。
1.4.2 sync.Mutex
对变量执行2000次+1的操作,5个协程并发执行,通过对共享资源(临界资源)加互斥锁,确保同一时间只有一个协程可以访问。
package main
import (
"fmt"
"sync"
"time"
)
var (
x int64
lock sync.Mutex
)
func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock()
x += 1
lock.Unlock()
}
}
func addWithoutLock() {
for i := 0; i < 2000; i++ {
x += 1
}
}
func add() {
x = 0
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
fmt.Println("withoutLock:", x)
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
fmt.Println("withLock:", x)
}
func main() {
add()
}
运行结果显示,5次运行后,不加锁的结果是未知的,也表示线程不安全
withoutLock: 7417
withLock: 10000
1.4.3 sync.RWMutex
5个协程分别并发地读取和增加count(共享资源),通过对count的读取加rwmu.RLock()锁,对count的写加rwmu.Lock()锁。确保同时多个goroutine可以读取count,但增加count时会互斥,只有一个goroutine可以写入。
package main
import (
"sync"
"time"
)
var (
count int64
lock sync.RWMutex
)
func getCount() int64 {
lock.RLock()
defer lock.RUnlock()
return count
}
func increaseCount() {
lock.Lock()
defer lock.Unlock()
count++
}
func main() {
for i := 0; i < 5; i++ {
go getCount()
}
for i := 0; i < 5; i++ {
go increaseCount()
}
time.Sleep(time.Second)
println(count) // 5
}
1.5 WaitGroup
1.5.1 介绍
sync.WaitGroup是Go语言中的一个常用同步工具,可以用于等待一组goroutine结束。主要的使用方式是
- 创建一个WaitGroup,通常以参数传入函数
var wg sync.WaitGroup
func doWork(wg sync.WaitGroup) {
}
- 在启动goroutine前调用Add添加计数
wg.Add(1)
go do()
- 在goroutine结束时调用Done减少计数
func do() {
defer wg.Done()
}
- 等待goroutine结束,调用Wait阻塞
wg.Wait()
Wait会阻塞直到计数器减为0。
这样就可以实现等待一组goroutine结束。
WaitGroup内部存放了一个计数器
- 开启协程—>计数器+1
- 协程执行结束—>计数器-1
- 主协程阻塞到计数器为0
WaitGroup非常实用,可以避免复杂的同步逻辑。它优雅地处理了同步问题。
1.5.2 Demo
启动了3个goroutine,并通过WaitGroup等待它们完成。
package main
import (
"fmt"
"sync"
)
func process(i int, wg *sync.WaitGroup) {
fmt.Println("Start goroutine", i)
defer wg.Done()
fmt.Printf("End goroutine%d\n", i)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go process(i, &wg)
}
wg.Wait()
fmt.Println("All goroutines finished executing")
}
二、依赖管理
2.1 Go 依赖管理演进
2.1.1 GOPATH
GOPATH是你的代码仓库所在的位置。go tool会在这个路径下查找和安装代码包。
GOPATH路径下有三个目录
- bin 项目编译的二进制文件
- pkg 项目编译的中间产物,加速编译
- src 项目源码
弊端
无法实现package的多版本控制
在GOPATH下,所有的package都是安装到一个全局的位置,比如:
/home/user/go/pkg/
这意味着,对于一个package,只能有一个版本存在。
但是在实际项目中,往往需要依赖不同版本的package,比如v1和v2。
GOPATH无法处理这种情况,会导致冲突。如果安装v2,v1的代码就会被覆盖掉。
2.1.2 Go Vendor
- 项目目录下增加vendor文件,所有的依赖包副本形式放在$ProjectRoot/vendor
- 依赖寻址方式:vendor => GOPATH
弊端
-
依赖冗余
vendor会把所有依赖都拷贝一份,造成大量冗余。
-
一致性问题
主项目依赖升级,vendor下的依赖可能没有同步升级。
-
维护困难
依赖关系隐藏在vendor中,理解和维护都更困难。
2.1.3 Go Module
Go Module是Go 1.11版本以后官方推出的依赖管理系统,通过go.mod
文件管理依赖包版本,通过go get/go mod
指令工具管理依赖包。(类似于Java里的maven)
有以下主要特征
- 基于语义导入版本号,如 v1.2.3
- 每个模块有自己的模块路径(module path)
- go.mod文件声明依赖关系
- go.sum记录依赖树hash
- 依赖存储在$GOPATH/pkg/mod中
- 不需要设置GOPATH
- 支持版本选择、升级等
- 提供诸如go mod tidy等命令
- 允许项目自由布局
相比GOPATH,Go Module提供了更可靠的依赖管理,解决了版本、项目布局等问题。
通过go mod init创建module,然后go get添加依赖,可以很便捷地使用modules。
2.3 Go Module实践
2.3.1 依赖管理三要素
- 配置文件,描述依赖 go.mod
- 中心仓库管理依赖 go Proxy
- 本地工具 go get/ mod
2.3.2 依赖配置 – go.mod
-
indirect
在go mod列表依赖时,会显示direct和indirect两种依赖
-
direct依赖表示被项目直接导入的依赖
-
indirect依赖表示被依赖间接导入的依赖
举个例子:
项目 |- 直接导入 pkgA |- pkgA |- 导入 pkgB
那么:
-
pkgA 就是direct依赖
-
pkgB 是indirect依赖
indirect依赖的特点是
-
不是被项目直接导入的,是被依赖导入的
-
是传递性的依赖
-
版本是由直接依赖决定的
-
项目并不直接引用这个包
所以indirect依赖对项目来说是不可见的。它们存在于依赖树中,但项目没直接引用过。
Go module会标记出这种被依赖的传递性依赖,以了解完整的依赖关系。
-
-
+incompatible
在Go模块版本号中,+incompatible表示该版本与之前版本不兼容,是一个重要的版本参考信息。
例如
v1.2.3 v1.3.0+incompatible
这里v1.2.3是正常的兼容版本。但在v1.3.0加了+incompatible标记。
这表示,相对于v1.2.3,在v1.3.0版本中,做了一些破坏兼容性的变更。导致v1.3.0与v1.2.3不再能够完全兼容。
如果一个项目原来依赖v1.2.3,现在直接升级到v1.3.0,就可能会编译错误或者运行时错误。
比如接口签名变化,导出类型调整等情况下,之前编译通过的代码,在v1.3.0就可能会报错。
这就是+incompatible的含义,表示与上一个正常版本相比,此版本做了不兼容的更改。不再保证编译运行的一致性。
那么为什么需要指出+incompatible呢?
-
Go模块的版本通常遵循语义化版本控制规范
-
根据语义化版本,v1.x.x只有v1不同时可以兼容
-
但是语义化版本并非强制要求,所以有时v1.3.0可能与v1.2.3就不兼容了
-
这时加上+incompatible可以明确表示不兼容变更
也就是说,+incompatible是一种向用户清楚表示不兼容变更的信号。
这样用户可以根据+incompatible信息来决定是否更新依赖版本。
-
2.3.3 依赖分发 – go proxy
-
GOPROXY是一个环境变量,它用来指定Go模块的代理服务器。
-
当你使用go命令获取、编译或运行依赖的模块时,Go会通过GOPROXY指定的代理服务器来下载模块,而不是直接从源码仓库下载。这样可以加速模块的获取,也可以避免一些网络问题或源码仓库的限制。
-
GOPROXY=”proxy1.cn, proxy2.cn, direct”
-
其中direct表示源站
-
先去proxy1.cn中获取,如果没有再到proxy2.cn中获取,如果也没有,最后到direct中获取
-
2.3.4 工具 – go get
go get是Go模块机制下的依赖管理命令,主要功能是获取远程包的依赖。
go get命令
- 下载包的源码
- 解压到GOPATH下
- 解析并下载依赖
- 缓存各版本的依赖
- 会更新go.mod文件
基本使用格式
go get <依赖路径>
例如
go get github.com/gin-gonic/gin
这会获取gin包及其依赖,并更新go.mod。
go get常用可选参数
- -u 更新已有依赖
- -t 仅下载不更新go.mod
- -d 只下载不安装
go get实现了模块化下的自动化依赖获取和版本管理。称得上是go模块的“管家”。
2.3.5 工具 – go mod
go mod是Go模块的依赖管理命令,是Go 1.11以后依赖管理的主要方式。
go mod提供了丰富的子命令,可以管理整个模块依赖的生命周期,主要包含:
-
init 初始化一个新模块
-
tidy 整理依赖
-
graph 打印模块依赖图
-
vendor 把依赖复制到vendor目录
-
edit 编辑go.mod文件
-
why 解释为何需要依赖
-
download 下载依赖的源码,会使用go get 下载
-
verify 验证依赖没有被篡改
通过go mod命令,可以便捷地进行依赖解析、升级、整理、查看等操作。
三、测试
测试是避免事物的最后一道屏障。
测试分类
从上倒下,覆盖率逐层变大,成本却逐层降低
3.1 单元测试
3.1.1 介绍
Go语言内置有对单元测试的支持,主要通过testing包实现。
3.1.2 单元测试规则
例如
func Add(a int, b int) int{
return a+b
}
func TestAdd(t *testing.T) {
output:=Add(1, 2)
expectOutput:=3
if output != expectOutput{
t.Error("Add failed")
}
}
测试函数名TestAdd告诉go test这是一个测试。
运行 go test xxx.go,通过t.Error可标记测试失败。
3.1.3 assert
Go语言内置的testing包提供了assert包来进行单元测试的断言,assert能大大简化测试代码,不需要过多的if条件判断,只要最终结果不符合断言,就会自动标记为测试失败。
package Test
import (
"github.com/stretchr/testify/assert"
"testing"
)
func Add(a int, b int) int {
return a + b
}
func TestAdd(t *testing.T) {
output := Add(1, 2)
expectOutput := 3
assert.Equal(t, expectOutput, output)
}
3.1.4 覆盖率
-
当一个程序有多个分支时,就会产生多种测试案例,如果所有的测试案例都符合结果,那覆盖率就是100%。
-
运行 go test xxx.go –cover,可以查看测试的覆盖率。
-
一般覆盖率在50%~60%,较高覆盖率在80%+
-
测试分支相互独立,全面覆盖
3.1.5 依赖
-
幂等:多次运行同一个测试,结果是一样的
-
稳定:单元测试之间相互隔离,单元测试在任何时间任何环境都可以运行
如果你测试的是一个文件功能,如果文件之后被删除,那这个单元测试就会失败,那么就说明这个测试依赖这个文件
3.2 Mock测试
Mock在Go语言单元测试中是一种非常有用的技术,主要用于模拟服务或依赖的行为。从而进行隔离测试。
monkey是一个开源的mock测试库,可以对函数或实例的参数进行mock。
打桩
为函数打桩(function stubbing)指对一个函数进行模拟替换,主要有以下几种方式:
- 空函数实现
函数体留空,直接返回
func ReadFile() string {
// 留空
}
- 硬编码返回值
返回预置的固定值
func ReadFile() string {
return "hello world"
}
- 自定义行为
根据输入返回不同值
func ReadFile(file string) string {
if file == "a.txt" {
return "aaa"
} else {
return "bbb"
}
}
- 使用存根对象
提前实现一个存根,存根实现假数据和行为。
打桩主要目的是隔离被测代码,注入假数据来验证代码逻辑,而非真实环境。
3.3 基准测试
Go语言通过testing包内置了对基准测试的支持,主要步骤是
-
基准测试用例文件名以_test.go结尾
-
基准测试函数名以Benchmark开头
-
使用*testing.B作为参数
-
在循环体内调用需要测试的代码
-
运行go test -bench来执行
例如
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
// 测试代码
}
}
b.N表示循环次数,由基准测试框架决定。
基准测试优点
- 可以测试代码性能
- 可以进行优化对比
- 可以裁剪程序的低效区域
基准测试十分适合优化关键路径、评测不同算法等场景。
四、总结
通过学习本文,可以了解到Go语言中实现高效并发的方式,包括Goroutine、CSP模型、Channel的使用。同时掌握依赖管理的工具和演进过程,以及Go语言自带的测试工具,能够编写单元测试、Mock测试、基准测试等,测试代码的正确性、性能指标等。Go语言内置的并发原语和测试工具,可以帮助我们构建稳定、高效的后端服务。