这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天
并发编程
并发编程对于任何语言来说都是一个重要话题,Go语言支持并发编程,支持协程,为并发编程提供了更加轻量级的解决方案,可以充分发挥多核优势,高效运行程序。
Goroutine
协程:用户态,轻量级线程,栈KB级别,开销小,灵活
线程:内核态,线程可以跑多个协程,栈MB级别,开销大
通常而言,线程创建上千个就已经是一个比较大的开销了,但是协程可以轻松创建上万个,这就是协程的优势。
func hello(i int) {
fmt.Println("hello goroutine: ", i)
}
func helloGoroutine() {
for i := 0; i < 5; i++ {
go func(j int) { // go 关键字创建协程
hello(j)
}(i)
}
time.Sleep(time.Second) // 等待协程执行结束
}
如上个例子所示,使用 go 关键字就可以创建协程了。
通信顺序进程-CSP
CSP(Communicating Sequential Process)顺序通信过程通常有两种通信方式:
- 通过通道共享内存实现通信
- 通过直接共享内存实现通信
在实践中,通常建议使用通道来实现内存共享,而不是直接进行内存共享。共享内存往往会造成各种各样的*数据竞态(race)*问题影响程序的性能。
Channel
channel其实比较类似于一个生产者消费者模型。channel一共有两种类型:
-
无缓冲通道:
make(chan int)生产者发送一个消息,接收者接收,由于没有缓存,两个协程之间通过通道实现了同步,本质上是一个同步过程,因此无缓冲通道也被称为是同步通道。
-
有缓冲通道:
make(chan int, 2)类似于Java中的一个阻塞队列
func CalSquare() {
src := make(chan int)
dest := make (chan int, 3)
// 生产 0 ~ 9 的数字
go func() {
for i := 0; i < 10; i++ {
src <- i
}
}()
// 消费 0 ~ 9,并生产他们的平方
go func() {
for i := range src {
dest <- i * i
}
}()
// 消费最终产品(复杂过程)
go func() {
for i := range dest {
// 处理复杂的逻辑
fmt.Println(i)
}
}()
}
Mutex
对变量执行 2000 次 +1 操作,5个协程执行,最终结果应该是 10000
var (
x int64
lock sync.Mutex
)
func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock()
x = x + 1
lock.Unlock()
}
}
func addWithoutLock() {
for i := 0; i < 2000; i++ {
x = x + 1
}
}
// AddWithLock: 10000
// AddWithoutLock: 8362
分别执行后的结果,不出意料没有加锁的线程造成了误差,最后导致结果错误。因此,对于共享内存要避免不加锁的操作。
WaitGroup
由于不清楚任务的确切结束时间,使用 time.Sleep() 方法来等待并不是一个好方法。对于这种需求,Java的一个并发工具 CountDownLatch 就可以解决。Go语言的工具就是 WaitGroup 来完成。
| 方法 | 描述 |
|---|---|
| Add(delta int) | 计数器 +delta |
| Done() | 计数器 -1 |
| Wait() | 阻塞等待计数器为0 |
上面这三个方法就可以达到线程的同步
func WaitGroupTest() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func() {
defer wg.Done()
// 一些耗时工作
}()
}
wg.Wait()
}
依赖管理
Go依赖管理的发展
-
环境变量
GOPATHbin 项目编译的二进制文件 pkg 项目编译的中间产物,加速编译 src 项目源代码项目代码直接依赖src,通过
go get下载最新的包到 src 目录下。弊端:项目 A 和 B 依赖于某个 package 的不同版本,但是Go并不能保证两个版本之间就是相互依赖的,这样就没办法实现 package 的多版本控制
-
Go Vendor
项目目录下增加
vendor目录,所有依赖包以副本的形式放在下载到${ProjectRoot}/vendor。项目依赖寻址,就优先从 vendor 目录下寻找,否则去GOPATH寻找依赖。Go Vendor 解决了多个项目之间的版本依赖问题。弊端:虽然解决了项目之间的以来冲突问题,但是还是通过源码来管理依赖,那么项目内部的 package 版本冲突问题也还是没办法解决。
-
Go Module
通过
go.mod文件管理依赖包版本,通过go get/go mod指令工具管理依赖包,最终完成定义版本规则和管理项目依赖的关系
依赖管理三要素
-
配置文件,描述依赖:
go.mod文件module example/project/app 依赖管理基本单元 go 1.19 原生库 require ( 单元依赖 example/lib1 v1.0.2 example/lib2 v1.0.0 // indirect example/lib3 v1.0.0-yyyymmddhhmmss-abcdefgh1234 example/lib4 v2.0.0-yyyymmddhhmmss-abcdefgh1234 // indirect example/lib5/v3 v3.0.2 example/lib6 v3.2.0+incompatible )-
依赖标识:[Module Path] [Version/Pseude-version]
-
语义化版本:
${MAJOR}.${MINOR}.${PATCH} -
基于commit 伪版本:
vx.0.0-yyyymmddhhmmss-abcdefgh1234 -
直接依赖和间接依赖:间接依赖会标识
indirectA -> B -> C A和B是直接依赖,A和C是间接依赖 -
兼容性
对于主版本大于2的模块会在路径后增加
/vN后缀。但是对于没有go.mod文件并且主版本大于2的依赖会增加+incompatible后缀来区别 -
依赖版本的选择:选择最低的兼容版本
对于上面这种情况,A1.2依赖C1.3,B1.2依赖C1.4,最后编译的时候,会选择C1.4
-
-
中心仓库管理依赖库:
go proxy依赖分发回源:直接使用版本控制平台下载依赖,例如
githubSVN等等。但是这样会带来很多问题:- 无法保证构建的稳定性:代码仓库作者可以随意增加/修改/删除版本,这样很可能导致开发者没办法稳定得到某个版本的依赖
- 无法保证依赖可用性:如果某个作者因为个人原因,删除了代码仓库删除了软件,那就会导致这个依赖没办法再次获取到
- 增加第三方仓库压力:第三方仓库的初衷就是为了作为代码源码的版本控制,而不是为了作为下载站点,这样无疑会带来额外的流量压力
综上所述,就需要一个代理来帮助我们处理这些痛点问题。
GOPROXY="https://proxy1.cn, https://proxy2.cn" 这里可以使用环境变量来定义中心仓库便于依赖管理依赖仓库的定义顺序,决定了优先从哪里那个中心仓库来下载依赖,如果依赖在这些代理节点都不存在,那么就会
direct直连下载 -
本地工具:
go get和go mod-
go getgo get example.org/pkg @update 默认 @none 删除依赖 @v1.2.3 Tag/语义版本 @23d45fdd 指定的commit @master 分支最新 -
go modgo mod init 初始化,创建 go.mod download 下载模块到本地 tidy 增加必要依赖,删除不必要依赖
-
测试
代码发生错误将会导致损失,为了避免损失,就需要在项目上线前进行充分的测试。从回归测试到系统测试再到单元测试,覆盖率逐层增大,成本却逐层减少。为了提高项目代码质量,作为程序员就需要做好单元测试。
单元测试
单元测试的覆盖率也一定程度上能说明代码的质量。
单元测试规则
- 所有测试文件以
xxx_test.go结尾 - 测试函数需要按照这样的命名规范
func TestXxx(t *test.T) - 初始化逻辑放到
TestMain函数中
// "github.com/stnetchr/testify/assert"
func TestPublishPost(t *testing.T) {
output := PublishPost()
expectOutput := true
// assert.Equal(t, exceptOutput, output)
if output != exceptOutput {
t.Errorf("Excepted %v do not match actual %v",
exceptOutput, output)
}
}
func TestMain(m *testing.M) {
// 测试前
/* 初始化操作 */
code := m.Run()
// 测试后
/* 收尾操作 */
os.Exit(code)
}
覆盖率
覆盖率可以衡量代码是否得到充分测试,测试的水准。
执行 Go 命令 go test xxx_test.go xxx.go --cover 查看测试覆盖率。
ok command-line-arguments 1.296s coverage: 66.7% of statements
Tips
- 一般覆盖率:50% - 60%,较高的覆盖率就是 80+%
- 测试分支相互独立,全面覆盖
- 测试单元粒度足够小,函数职责单一
Mock测试
monkey:github.com/bouk/monkey
快速 Mock 函数:为一个函数打桩、为一个方法打桩
func TestProsessFirstLineWithMock(t *testing.T) {
// 打桩:ReadFirstLine需要IO操作,但是这里替换以后
// 就可以实现和文件操作的解耦
monkey.Patch(ReadFirstLine, func() string {
return "line110";
})
defer monkey.Unpatch(ReadFirstLine)
// 测试内容
line := ProcessFirstLine()
assert.Equal(t, "line110", line)
}
基准测试
基准测试(Benchmark)可以分析代码的性能情况。类似于单元测试,基测试函数命名规范 func BenchmarkXxx(t *test.B)
// 串行执行测试
func BenchmarkSelect(b *testing.B) {
InitServerIndex()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Select()
}
}
// 并行执行测试
func BenchmarkSelectParallel(b *tesing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.next() {
Select()
}
})
}
每次在执行 Benchmark 测试的时候,如果有初始化操作那么就需要重置计时器 b.ResetTimer()。
rand 和 fastrand:
rand在测试中的性能相对 fastrand 低很多,因此在使用随机数的场景中最好选择 fastrand 而不是rand。当然,fastrand的随机数是牺牲了一定的一致性来达到的高效率,这点在程序设计也需要考虑。