这是我参与「第五届青训营 」伴学笔记创作活动的第 1 天,今天学习了Go语言进阶Goroutine和Channel、依赖管理、测试等内容。
1. 语言进阶
1.1 Goroutine
协程与线程的区别
-
协程:用户态,轻量级线程,没有上下文切换的耗时,栈KB级别
-
线程:内核态,一个线程上运行多个协程,栈MB级别
因为协程需要的资源极小,一个应用中可以开启上万个协程,这就是Go语言应用适合高并发场景的原因所在。
开启一个协程
package main
import (
"fmt"
"time"
)
func hello(i int) {
fmt.Printf("hello goroutine: %d\n", i)
}
func main() {
for i := 0; i <= 5; i++ {
go hello(i)
}
time.Sleep(time.Second)
}
在Go中开启协程非常简单,只需要在调用函数前加上关键字go即可。最后一行time.Sleep的目的是保证子协程执行完之前,主协程不会退出。
1.2 CSP(Communicating Sequential Processes)
Go语言哲学:通过通信共享内存而不是通过共享内存通信 使用共享内存通信需要对临界区加锁,可能造成数据竞态,在一定程度上影响程序的性能。
1.3 Channel
Go语言中提供了Channel这一特性用于协程之间的数据传输。
如何定义一个channel
- 无缓冲通道
make(chan int) - 有缓冲通道
make(chan int, 2)
区别:
- 无缓冲通道会阻塞发送goroutine,直到数据被接收,也被称为同步通信
- 有缓冲通道当缓冲区满后才会阻塞发送goroutine
代码示例
package main
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 {
// 。。。复杂操作
println(i)
}
}
第一个协程和第二个协程通过src通信,第二个协程和主协程通过dest通信,带缓冲的channel可以一定程度上缓解生产者和消费者速度上的差异。
1.4 并发安全 Lock
多个协程并发工作时,可能会有多个协程同时操作一个资源的情况,也就是数据竞态,访问资源时需要对资源加锁。
代码示例
启动5个协程,每个协程对变量进行2000次+1操作
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)
println("withoutLock: ", x)
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("withLock: ", x)
}
可以看到没有加锁的方式出现了多个协程同时执行的现象,导致丢失了很多次操作。
1.5 WaitGroup
前面的例子中都使用了time.Sleep()来阻塞主协程,但这是不优雅的,因为我们不知道子协程确切的执行时间,无法精确的设置sleep时间。
Go中可以使用WaitGroup来进行协程同步,提供了三个方法:
- Add(delta int): 计数器+delta
- Done(): 计数器-1
- Wait(): 阻塞直到计数器为0 代码示例
func hello(i int) {
fmt.Printf("hello goroutine: %d\n", i)
}
func HelloGoroutine() {
var wg sync.WaitGroup
for i := 0; i <= 5; i++ {
wg.Add(1)
go func (i int) {
defer wg.Done()
hello(i)
}(i)
}
wg.Wait()
}
2. 依赖管理
2.1 背景
- 工程项目开发中不可能基于标准库从0到1搭建,应该把更多的精力放到业务逻辑上。
- 外部的依赖都可以通过SDK的方式引入。
2.2 依赖管理演进
GOPATH -> Go Vendor -> Go Module
目标:
- 实现不同环境(项目)使用的依赖版本不同
- 控制使用的依赖包版本
2.2.1 GOPATH
弊端
场景:项目A和B同时依赖于某一package的不同版本,这种情况下可能导致A和B无法同时编译成功 问题:无法实现package的多版本控制
2.2.2 Go Vendor
- 在项目目录下增加vendor文件,所有依赖包以副本的形式存放在$ProjectRoot/vendor
- 依赖的寻址方式:vendor => GOPATH 通过每个项目引入一份依赖副本的方式,解决了多个项目依赖同一个package的不同版本的场景。
弊端
场景:项目A依赖package B和C,B和C依赖package D的不同版本,容易导致依赖冲突 问题:不能控制依赖的依赖的版本
2.2.3 Go Module
- 通过go.mod文件管理依赖包版本
- 通过go get/go mod管理依赖包
2.3 依赖管理三要素
- 配置文件,描述依赖 go.mod
- 中心仓库管理依赖库 Proxy
- 本地工具 go get/go mod
2.3.1 依赖配置 & Proxy
1. go.mod
2. version
- 语义化版本 ${MAJOR}.${MINOR}.${PATCH} V1.3.0
- 基于commit伪版本 vX.0.0-yyyymmddhhmmss-abcdefg1234
3. indirect
对于非直接依赖会用// indirect标识
4. incompatible
对于没有go.mod文件并且主版本在V2+的包会标识出来,标识可能会有不兼容的问题
5. 依赖图
依赖的依赖使用不同版本时,选择最低的兼容版本
6. 回源&Proxy
直接使用代码托管平台依赖的问题:
- 无法保证构建稳定性
- 无法保证依赖可用性
- 增加第三方压力,代码托管平台负载能力
为了解决直接使用代码托管平台的问题,在代码托管平台与开发者之间增加了Proxy进行依赖代理。
2.3.2 工具
1. go get
使用时go get example.org/pkg后面可以增加参数
- @update 使用最新的提交
- @none 删除依赖
- @v1.1.2 拉取特定版本
- @23dfdd5 拉取特定commit
- @master 拉取分支的最新commit
2. go mod
- go mod init 初始化,创建go.mod文件
- go mod download 下载模块到本地缓存
- go mod tidy 增加需要的依赖,删除不需要的依赖,建议每次提交代码前执行
3. 测试
3.1 单元测试
1. 目的
- 保证开发质量
- 提高开发效率(快速定位问题)
2. 规则
- 测试文件以_test.go结尾
- 函数命名:func TestXxx(*testing.T)
- 比较好的实践:初始化逻辑放到TestMain中
func TestMain(m *testing.M) { // 测试前数据装载、配置初始化等 code := m.Run() // 测试后资源释放等收尾工作 os.Exit(code) }
使用assert包来比较预期输出与实际输出
3. 覆盖率
go test a_test.go a.go --cover
评估代码是否经过了足够的测试、测试的水准、是否达到高水准测试等级的指标
4. tips
- 一般覆盖率50%-60%
- 测试分支相互独立、全面覆盖
- 测试单元力度足够小,函数单一职责
3.2 Mock
1. 目的
对于一些外部依赖比如File、DB、Cache,希望在测试过程中得到稳定幂等的结果,测试结果只与代码本身有关,排除其他因素干扰。
2. 如何Mock
使用一些开源库例如monkey、goMock进行Mock,测试过程中对依赖的外部资源进行打桩。
3.3 基准测试
go test -bench=.
1. 目的
测试程序的性能、CPU损耗
2. 规则
- 函数以Benchmark开头
BenchmarkXxx(b *testing.B)
4. 学习小结
- 因为协程所需的资源很少,应用中可以轻松开启大量协程,且协程处于用户态,没有切换上下文的开支,这些特性使Go成为了适合高并发场景的语言。
- channel用于协程间传输数据,实现了通过通信共享内存。
- 站在巨人的肩膀上,Go的依赖管理在发展中遇到并解决了许多问题,最终形成了Go Module的解决方案,是否还有改进空间?