Go高性能的本质
并发 VS 并行
如下图所示,如果一个多线程程序在一个核的cpu上运行,我们称之为并发。并发本质上是串行的。
然而,如下图所示,如果一个多线程程序在多个核的cpu上运行,我们称之为并行。
在 Go 中,可以充分发挥多核优势,高效运行。
Goroutine
Go 可以通过创建协程来充分发挥多核的优秀,提高程序运行效率。如下图所示,协程相比于线程来说,它更轻量级,栈是 KB 级别,而线程是 MB 级别。此外,一个线程可以跑多个协程。
如下所示, Go 通过关键字 go 来创建协程。
func hello(i int){
println("hello: " + fmt.Sprint(i))
}
func HelloGoRoutine(){
for i := 0; i < 5; i++ {
go func(j int){
hello(j)
}(i)
}
time.Sleep(time.Second)
}
CSP ( Communicating Sequential Processes )
如下展示了 Go 中的两种协程之间通信的方式,分别为通过通信共享内存、通过共享内存实现通信。两者区别在于,通信共享内存,通过一个通道,即队列的数据结构,发送 or 接收数据。而共享内存实现通信中,通过共享的临界区,实现不同协程之间的通信,每个协程都可以访问临界区中的数据。 Go 提倡通过通信共享内存而不是通过共享内存实现通信。
Go 通过 make( chan 元素类型,[缓冲大小]) 来创建一个通道。通道包括两种类型:
-
无缓冲通道 - make( chan int )
-
有缓冲通道 - make( chan int, 2 )
它们的区别在于:无缓冲通道:发送和接收操作会立即阻塞,直到另一方配对操作就绪。 有缓冲通道:发送和接收操作不会立即阻塞,除非通道已满(发送时)或为空(接收时)。
并发安全 Lock
Go 中通过 sync.Mutex 类型实现多协程之间的加锁与解锁,以保证并发安全。下图是一个示例,在没有加锁的情况下,会出现并发冲突,导致计算结果与预期不符。
WaitGroup
Go 中可以通过 sync.WaitGroup 类型解决主协程与子协程之间的同步问题。它主要包括 Add(), Done(), Wait() 三个方法。如下是一个使用示例,主协程阻塞在 wg.Wait() , 直到所有子协程执行完毕:
func hello(i int){
println("hello: " + fmt.Sprint(i))
}
func HelloGoRoutine(){
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int){
defer wg.Done()
hello(j)
}(i)
}
wg.Wait()
}
依赖管理
GOPATH
GOPATH 是 Go 语言支持的一个环境变量,是 Go 项目的工作区。目录有以下结构: src: 存放Go项目的源码; pkg: 存放编译的中间产物,加快编译速度; bin: 存放 Go 项目编译生成的二进制文件。那么项目代码直接依赖 src 下的代码, go get 下载最新版本的包到 src 目录下。
但是,这样存在一定的问题。如图,如果同一个 pkg 有两个版本,项目 A 和项目 B 分别依赖于不同的版本,而 src 下只能有一个版本存在,那么无法保证 A, B 两个项目都能编译通过。
Go Vendor
Vendor 是当前项目中的一个目录,其中存放了当前项目依赖的副本。在 Vendor 机制下,如果当前项目存在 Vendor 目录,会优先使用该目录下的依赖,如果依赖不存在,会从 GOPATH 中寻找。
但是,它依然存在一定的问题。如图所示,如果项目 A 依赖 pkg B 和 C, 而 B 和 C 依赖了 D 的不同版本,通过 vendor 的管理模式我们不能很好的控制对于 D 的依赖版本。
Go Module
依赖配置
Go 项目中,会有一个配置文件 go.mod 用于描述依赖。如下图所示, go.mod 主要包括:依赖管理基本单元、原生库、单元依赖三个部分。
其次,单元依赖中存在一些特殊标识符,首先是 indirect 后缀,表示 go.mod 对应的当前模块,没有直接导入该依赖模块的包,也就是非直接依赖,表示间接依赖。
然后是 incompatible 标识符,这能让 go module 按照不同的模块来处理同一个项目不同主版本的依赖。对于没有 go.mod 文件并且主版本2+的依赖,会 + incompatible。
依赖分发
直接使用版本管理仓库下载依赖,存在许多问题:
-
无法保证构建确定性:软件作者可以直接在代码平台增加/修改/删除软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本。
-
无法保证依赖可用性:依赖软件作者可以直接在代码平台删除软件,导致依赖不可用。
-
增加了第三方代码托管平台的压力。
于是使用 Go Proxy 解决这些问题。它是一个服务站点,它会缓存源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用。
工具
Go 主要通过 go get, go mod 指令工具进行版本依赖管理。如下展示了两个工具的常用指令:
测试
单元测试
单元测试主要是对项目的某一单元的功能进行验证,保证与我们的预期相符。单元包括:接口、函数、模块等。单元测试的流程如下图所示,根据输入,测试单元会计算输出,将期望结果与输出进行比较,如果一致则测试通过,否则不通过。
Go 中单元测试有以下几个规则:
-
所有测试文件以 _test.go 结尾。
-
测试函数命名格式为: func TestXXX( *testing.T )
-
初始化逻辑放到 TestMain 中。
通过指令 go test [flags] [packages] 运行测试文件。
Mock
在复杂的工程项目中,我们需要保证单元测试的稳定性和幂等性。
-
稳定性:单元测试互相隔离、能在任何时间,任何环境,运行测试。
-
幂等性:每一次运行测试,都应该产生与之前一样的结果。
以下是一个实例,测试的单元会将文件中的第一行字符串的11替换成00。于是,我们的单元测试依赖本地的文件,如果文件被修改或者删除,测试就会失败。于是我们读取文件时需要进行mock,屏蔽对于文件的依赖。
func ProcessFirstLine string {
line := ReadFirstLine()
destLine := strings.ReplaceAll(line, "11", ""00)
return destLine
}
func TestProcessFirstLine(t *testing.T) {
firstLine := ProcessFirstLine()
assert.Equal(t, "line00", firstLine)
}
常用的 Mock 工具有monkey。它的主要原理是进行函数替换。如下是一个实例,将 ReadFirstLine() 函数替换为一个直接返回字符串的函数,代替模拟读取文件功能。
func TestProcessFirstLine(t *testing.T) {
monkey.Patch(ReadFirstLine, func() string{
return "line110"
})
defer monkey.Unpatch(ReadFirstLine)
firstLine := ProcessFirstLine()
assert.Equal(t, "line000", firstLine)
}
基准测试
基准测试主要是为了测试代码的运行效率、性能。以下是一个实例,随机选择数组中的一个数,,其中 ResetTimer() 表示重置计时器。
import (
"match/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)]
}
func BenchmarkSelect(b *testing.B){
InitServerIndex()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Select()
}
}
总结
本节课主要讲述了关于 Go 并发编程、项目依赖管理、项目测试等内容。其中包括:如何在 Go 中创建协程,进行并发操作,如何使用 Go Module 管理项目依赖,如何在 Go 项目中编写单元测试、 Mock 测试、基准测试。