这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天
1. go高性能的原因
多线程: 线程 切换需要内核态,栈为MB级别(ulimit,默认为8M,线程需要消耗一定的系统资源)
协程:全程工作在用户态,栈为KB级别
协程是基于线程工作的,因此,如果是单线程多协程,仍然无法做到并行。只要不是并行,就没法利用多核cpu的资源。不过go的模型为 多线程且多协程的。因此go支持并行,并且由于协程的轻量级,可以支持百万级的协程创建。
// 创建协程只需要用go关键字,就可以把一段例程(表现为匿名函数) 以协程方式运行
func main() {
for i := 0; i < 5; i++ {
go func(j int) {
fmt.Println(j)
}(i)
}
time.Sleep(1 * time.Second)
}
1.2 goroutine间的通信
go使用通信实现共享内存,而不是通过共享内存实现通信
共享内存:多数语言使用的,基于内存的模型。
通信:面向消息的模型,go使用的内存模型。
被共享的内存称为临界区,为了避免诸如cpu/编译器打乱顺序/非原子操作 这样的undefined behavior,需要加锁避免数据竞争。
1.3 channel
分为
无缓冲队列:也称同步队列,顾名思义,生产者消费者会阻塞到同时进行生产/消费 动作。无消费者,生产者会阻塞。无生产者,消费者会阻塞。
缓冲队列:允许生产者先行生产,只要缓冲区不满,生产者就可以生产:应用场景
生产者生产速度快,消费者在处理生产内容时,往往会涉及到业务逻辑,消费速度慢。为了平衡生产快消费慢的现象,加入缓冲
可以对缓冲/非缓冲队列使用range操作,类似
for j := range src for { j := <- src if src.isClose() { break } }因此对管道使用range操作时,实际上就是对管道的循环读,直到管道被关闭,则退出
func main() {
src := make(chan int, 1)
dst := make(chan int, 3)
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() {
defer close(dst)
for j := range src {
dst <- j * j
}
}()
for i := range dst {
println(i)
}
}
注意,一定要养成释放资源的习惯。
如果第一个goroutine不释放掉src,那么第二个goroutine就会一直等待src的数据,而主goroutine则等待dst的数据,这样就死锁了。这时候程序会报错。
如果src使用完就释放,dst也跟着释放,那么主goroutine的for i := range dst 就会自动退出
1.4 互斥锁
sync.Mutex{}对象提供了互斥操作。虽然go是基于CSP的,但是go也有访问共享内存的情况。比如一个全局指针,虽然指针是值拷贝的,但是指针的值不变。这就意味着依旧是多个goroutine操作同一个地址,这时候不得不加锁了
lock := sync.Mutex{} lock.Lock() defer lock.Unlock()这个玩意相当于初始值为1的信号量。
1.5 waitgroup
这个玩意相当于一个信号量。
func main() { wg := sync.WaitGroup{} wg.Add(5) // 调用五次P() 加锁五次 // 一共解锁五次 for i := 0; i < 5; i++ { go func() { time.Sleep(1 * time.Second) defer wg.Done() // 调用一次V() 解锁一次 }() } wg.Wait() // 当信号量为0时 才解除阻塞 fmt.Println("执行结束") }
2. 依赖管理
sdk:sofeware develop kit 软件开发工具包。
无工具,不工程。我们开发web时,需要应用到各类框架:mvc,orm,rpc,go-redis(在go中创建redis模板,调用go方法可以直接操作redis)。 这些框架以开发工具包(第三方包)的方式导入我们的项目中。
如果手动管理这些项目的依赖,那么就麻烦死了。因此我们必须借助依赖管理工具
2.1 最初的依赖管理:GOPATH
实际上,GOPATH是一个环境变量。
这个环境变量只是提供了一个 go工作区的位置。
也就是说,我们的go程序会去GOPATH指定的目录下,寻找模块名。
- GOPATH可以有好多个
- 默认的查找路径为$GOPATH/src
- 查找方式为就近查找,假如我们要查找名为foo的模块,而GOPATH="/home/jb/demo:/home/jb/aaa"。我们在demo和aaa的src目录下都有foo,那么先查找到demo下具有foo,就不去查找aaa的foo了。
缺点也很明显。
假如位于GOPATH路径下的包pkg升级了,其中方法A被删除,多了一个方法B(实际上,升级后的包应该保证兼容性。但是确实有把方法直接删除的情况)
本地具有项目M,项目N,M依赖于旧版本,N依赖新版本。GOPATH只会根据环境变量顺序寻找到包,不会继续查找。因此M和N都只会找到同一个pkg,要满足M就无法满足N,反之同理。这就无法满足 多版本控制
2.2 进化版 govender
既然只靠GOPATH无法解决多版本控制的问题。那就引入一个govender。
在定位依赖时,会优先查找govender,最后查找GOPATH
但是govender仍然有缺陷
Go 包依赖管理工具 —— govendor - Jioby - 博客园 (cnblogs.com)
2.3 终极版本gomod
go1.11 推出的gomod。不仅解决了依赖包的定位,还解决了版本的控制。
类比java的maven
- 配置文件,描述依赖和版本 pom.xml
- 中心仓库管理依赖库:mvnrepo 前面的GOPATH就类似一个本地仓库,只不过没有版本管理,导致无法处理特殊情况
- 本地工具:
mvn
go.mod由三部分组成
module表示当前模块单元,由域名和版本组成。比如开源到github上的一个模块,可以命名为github.com/${ver}
go 1.19表示当前go sdk的版本。也就是go核心库的版本
require表示当前模块单元依赖于哪些其他的模块单元。由其他模块单元的模块单元名 + 版本组成。
go mod对版本的表示方法具备了兼容性。
版本号可以表示为 主次补丁版本表示法,或者git commit的版本表示法
go mod可以指定模块的直接依赖关系
如果A依赖B,B依赖C,那么A间接依赖C。
但是在require模块,A可以注明依赖于C,但是在依赖条目后可以加上
// indirect表示间接依赖这样的go mod就这么写的
require ( A 1.2 B 1.2 C 1.3 // indirect C 1.4 // indirect )那么选择哪个版本的C ?
将选择最低兼容的版本。选择C1.3,无法满足B1.2的依赖,选择C1.4,可以满足A1.2和B1.2的依赖,同时是满足A和B的最小版本,故选择C1.4,这就i意味着,就算有C1.5也不会选择。
2.4 依赖分发 回源
为什么需要依赖分发?
我们依赖管理的一切都是基于 中心仓库的。
但是中心仓库有没有不确定的地方?在go中,中心仓库就是一个个的go包托管平台
- 软件版本的 增删改
- 软件的删除,把仓库删了。
- 增加托管平台的压力
- 由于防火墙,访问不到托管仓库
为了保证依赖管理的可靠性和稳定性,引入了go proxy
go proxy实际上是开发者到托管平台的一层代理。而代理的主要功能就是提供软件包的缓存
我们会跟着代理链,依次查找。
比如Direct源站为github。但是我们是境内主机。访问不到github,那么我们就无法获取托管平台上的源代码。
此时我们选择Proxy1,Proxy1保证了
- 提供了源站的镜像,方便访问
- 保证了可靠性,避免软件包被删除或者版本错误
如果Proxy1没有,则继续去Proxy2访问数据包,如果都没有,才直接访问源站。
实际上,我们通过go get把源站代码直接下载到本地,可以认为本地也是一层go proxy。
2.5 工具 go get
可以根据软件包的版本/分支 拉取对应的代码包。
将代码包拉取到本地,相当于采取本地缓存代理,将大幅度提高依赖的可靠性和稳定性。
2.6 工具 go mod
查看go帮助文档即可
3. go测试
go以一种方便的方式引入了单元测试。
为什么要单元测试?
- 如果开发部署后再进行测试,那么测试成本将大大增加。
- 单元测试有利于CI/CD,在微服务的世界里极为方便
如何进行单元测试
测试文件以
_test结尾测试方法签名为
TestXXX(t *testing.T)
- 测试方法要保证单一职责,即一个测试方法尽量只测试一种可能
- 测试方法要设计足够全,满足一定指标的覆盖率
全局代码写在
TestMain(m *testing.M)中
3.1 go断言
go没有内置的断言语句,可以用第三方包实现
3.2 go测试标准
如何判断自己的测试是否达标,可以用覆盖率来量化
执行所有的测试方法后,不一定测试了目标方法的每一条语句。因此覆盖率 = 已测试的语句 / 总语句 数量。
在测试时,加上参数--cover 就可以计算覆盖率。
3.3 单元测试:依赖
在进行单元测试时,预测试的方法依赖外部内容,可能是外部的库,也可能是外部的文件。
有必要依赖这些东西吗?
答案:没有必要,因为不管依赖什么,这个函数的内容都是业务逻辑。我们需要测试这个业务逻辑是否可以让所有输入有正确 的输出。但是有时侯,如果业务逻辑依赖一个文件作为输入,或者业务逻辑中有数据库操作,我们可以把这些东西化简掉。
这时候,我们需要使用Mock测试。
Mock也就是模拟测试,顾名思义就是由测试者自行构造数据,并进行测试。这样有效地避免了业务逻辑依赖外部数据的情况。
go get github.com/bouk/monkey 运行后,我们将引入桩函数库的依赖。go get bou.ke/monkey
func ATest() string{
// 业务逻辑中依赖这个input
return input()
}
// 假如这个方法,通过读取外部文件/数据库获取数据
func input() string {
return "外部文件"
}
如上,业务逻辑依赖一个从外部文件中读取数据的方法。
如果外部文件没有了,那么测试就无法进行。
我们要保证测试的幂等性和稳定性,即对于同样的输入,要有相同输出。在一段测试中,被测试的方法要保持不会出错
我们可以通过 打桩,替换掉这个input方法。既然从外部获取输入是没必要的,我们可以直接换掉它,换成我们准备好的数据。
打桩就是替换一个方法的执行体。
编译期可以通过闭包实现。而运行时则可以通过反射实现。
可以看到,我们通过拦截运行时的
Input(),使得其返回值从"外部文件"变为"替换掉你"实际上,我们在运行测试时,额外添加参数
-gcflags=all=-l这是因为我们的Input()方法过于简单,会导致go编译器的内联优化,也就是这个方法调用被直接转换为了返回值,demo := Input()被优化为了demo := "外部文件"。而打桩是基于方法调用的,既然方法调用被优化了,就会导致调用失效,因此要取消内联优化。当然如果Input()的逻辑足够复杂,就不需要这么多了。
3.4 基准测试
目的:对算法进行压测。可以测试出算法的运行水平,可以进行进一步优化。
- 可以串行执行
- 可以并行执行
这里有一个很有意思的点:在并行执行时,如果调用了rand,其性能将大幅度下降。这是因为rand采取伪随机算法,多goroutine下,共享同一个seed。此时需要加锁。如果有并发环境调用rand的情况。可以牺牲掉rand的性能安全,改用无锁的fastrand实现