::: hljs-center

image.png

:::

::: hljs-center

Go工程进阶与依赖管理

:::

并发编程

并发与并行的区别
在谈及这两个概念之前,要先明白他们解决的是一个什么共同的问题。我们知道在单线程环境下,任务是堵塞的,当一个任务在运行的时候,另外一个任务只能等待它完成才能继续。如果说程序本身是高速公路的收费站,那么任务就是要通行的车辆,在上一辆车检查放行之前,你只能在原地等着,这样显然是效率很低的,考虑一个web应用,在与用户a通讯的时候,b得等a完成交互才能继续。

那么怎么解决这个问题呢?我们可以在高速公路上修建多个收费站或者开括多条车道,同时让多辆车通过,这便是并发。考虑土地是物理资源,在一条道上设立多个收费站便是并发,而开括更多条车道便是并行。

显然物理资源更加昂贵,我们只能采用第一个方案了,大部分语言使用了一种内核态的由操作系统提供的解决方案——线程(thread)

还有一个更恰当的比方,一个人(cpu)同时喂两个小孩吃饭,看着像两个小孩在吃饭,实际上只有一个人在喂(这即是并发,一个cpu通过调度不同的任务,当有一个线程唤醒时,其他线程必定堵塞,只是因为切换速度较快导致看起来像同时运行)。而并行当然就是有两个人同时在喂两个小孩吃饭了

协程
这种解决方案就是最好的了吗?线程在计算机中也是非常昂贵的资源,单个线程栈的内存占用可能达到MB级别!因此在很多情况下,我们可以使用更加廉价的解决方案

这种看似是线程实际上不是线程的东西我们通常称它为“协程”,协程可以理解为轻量级,用户级的线程。

协程的创建和调度由go本身完成,协程的栈一般在kb级别,一个线程可以调度上万级别的协程,这也是go更加适合高并发场景的原因所在

Goroutine的使用

接下来看看Go为我们提供的Goroutine
下面是一个简单的使用Gouroutine的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"time"
)

func say(str string) {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println(str)
}
}

func main() {
go say("hello")
say("zyy")
//阻塞了一秒,为防止子协程未完成之前主线程退出
time.Sleep(time.Second * 10)
}


上面的代码中定义了一个say函数,它每隔一秒打印一次传入的字符串str,重复五次。接下来在主函数中调用了两次say(),并传入两个不同的参数(“hello”和”zyy”),注意到第一个say函数之前声明了 go,这意味着一个新的Goroutine协程被运行。

看看输出的结果,就像同时有两个线程一样神奇。
image.png

实际上并不是真正的并行,将say函数中的time.sleep(second)去掉。再运行一遍
image.png
怎么又不是交替打印了?就像前文说的那样,同时执行只是一种错觉,只有在一端有空闲(sleep的时候),另一端才会去执行

有可能发生的事故:主线程提前结束
注意到上面代码main函数中的

1
time.Sleep(time.Second * 10)

了吗?将他注释掉试试。
image.png
为什么不打印”hello”了?这是因为在协程未完成任务时主线程退出了。我们使用了一种很暴力的方式,让主线程睡上十秒钟,这样不管怎么样都会完成了。但在真正工程实践中,我们不可能每个协程都会睡上一两秒,即是说我们无法预测协程的完成时间。
正确的方法是使用go标准库提供的 WaitGroup,它会更加优雅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
"sync"
"time"
)

func say(str string) {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println(str)
}
}

func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
say("hello")
}()
say("zyy")
wg.Wait()
}

运行一下
image.png
cool!,那么它是怎么运行的呢?回顾一下上面的代码
time.Sleep(time.Second)被移除,转而使用了wg.Wait(),它会堵塞主线程,什么时候释放呢?
看这一行代码

1
wg.Add(1)

我们往WaitGroup wg里传入了一个参数1,这实际上是WaitGroup内部实现的一个类似计数器的逻辑,当它归0时,Wait的堵塞会释放掉,
而计数器递减发生在

1
2
3
4
go func() {
defer wg.Done()
say("hello")
}()

defer wg.Done() 这一行代码里
defer 声明的代码的会在函数退出时执行,通常用于关闭io资源,数据库连接等
它所声明的代码 wg.Done() 会将WaitGroup的计数器-1,上面的代码中传入的初始值为1
即是说: 这个匿名函数在退出时调用wg.Done让计数器归0,主线程也完成了堵塞释放掉
怎么样?很优雅吧!

并发安全Lock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import "sync"

var wg sync.WaitGroup
var count int64

func add() {
for i := 0; i < 2000; i++ {
count += 1
}
wg.Done()
}

func main() {
count = 0
wg.Add(5)
func() {
for i := 0; i < 5; i++ {
go add()
}
}()
wg.Wait()
print(count)
}

看一下这段代码,每调用一次add会让count+2000,调用了五次,显然结果是10000吧,润一下看看
image.png
在多次运行后发现了不对劲,8333?怎么看都不可能吧!事实上这种事故是小概率事件,通常在工程代码中遇到了都很难排查
所以对并发安全的认知很重要,我们需要能提前预测出可能发生的事故。对于一个变量的修改并不是一个原子操作,原子操作意味着它是一步完成的,但+=这个操作有拥有三个操作,它需要先取出变量,修改变量,再赋值回去;试想一下,有一个协程在进行取出变量这个操作,此时又有一个协程赋值了变量,那不是乱套了吗,结果肯定就不会每次都是正确的了。

为了解决该问题,go提供了完善的方案,我们可以使用sync下的Mutex,修改一下上面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"sync"
)

var wg sync.WaitGroup
var count int64
var lock sync.Mutex

func add() {
for i := 0; i < 2000; i++ {
lock.Lock()
count += 1
lock.Unlock()
}
wg.Done()
}

func main() {
count = 0
wg.Add(5)
func() {
for i := 0; i < 5; i++ {
go add()
}
}()
wg.Wait()
print(count)
}

锁的原理是,第一次调用Mutex.Lock的时候会打个标记,当第二次调用时会堵塞,直到它被Unlock。
使用锁时需格外谨慎,尽量只在有并发安全的地方使用,它会很明显的降低性能;此外,使用锁需检查好逻辑,死锁的情况很容易发生

(go应该也有类似java的AtomicInteger那样的原子操作包?)

image.png
这下不论如何都能输出正确结果了

协程之间的同步 Channel

那么协程之间怎么进行数据传输通信呢?直接开一个数组或者列表之类的结构,不同协程直接取用不就行了,这样是很危险的。go提倡使用 通道(Channel) 来进行内存共享

通过如下方式来声明一个通道

1
2
ch := make(chan int)
ch0 := make(chan int, 3)

ch为一个不带缓冲的通道,指定了传输数据类型为int,无缓冲意味着每个数据的发送都得等待另一端接收,否则会发送方会发生堵塞。
而带缓冲区(ch0,声明了缓冲区大小为3)可以存在多个数据未被接收,此时发送方可继续发送而不会受到堵塞,当然未接收的数据不能超过声明的大小

这样向一个通道中传输数据

1
ch <- i //i为int类型变量

接受数据

1
receive int := <-ch //赋值给receive

通过for range取出所有数据。(for range会一直读取一个通道,直到channel被关闭)

来看一段例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import "fmt"

func main() {
ch := make(chan int)
ch0 := make(chan int, 3)
//协程a
go func() {
defer close(ch)
for i := 0; i < 10; i++ {
ch <- i
}
}()
//协程a
go func() {
//关闭channel
defer close(ch0)
for i := range ch {
ch0 <- i * i
}
}()
for i := range ch0 {
fmt.Println(i)
}

}

该程序由三个协程组成,a协程将 0-9 传入通道ch,b协程将ch中数据取出后进行平方计算传入ch0通道。
最后主线程打印出ch0中的所有平方后的结果
image.png


依赖管理

站在巨人的肩膀上

在实际开发项目中,需要学会站在巨人的肩膀上,使用他人已经封装好的,经过验证的工具库来提升开发效率
平常练手的玩具项目基于标准库即可完成,但在实际工程中较为复杂,不可能从0到1编码搭建
应把更多精力放在业务逻辑的实现上,其他依赖(如日志,框架,集合)可通过sdk的方式引入

Go的依赖演变
依赖管理并不是Golang的独创,早之前便有许多不同语言的依赖管理工具,如我们java程序员熟知的maven/gradle,rust的cargo。
Go语言的依赖管理经历了 GOPATH,GoVendor,GoModule 三个阶段的演变

GOPATH

在初学Go配环境的时候会注意到$GOPATH这个环境变量,它指向应该目录,所有的项目都会依赖这个目录下的源码。这将导致一个问题:
如果有不同的项目分别依赖于一个依赖库的不同版本,由于它是一个公共环境变量,并没有任何管理依赖版本的措施,将导致编译出错

GoVendor

为了解决这个问题,演进出了GoVendor
在项目目录下增加vendor文件夹,将所有的依赖包副本形式存放进去。依赖寻找时会优先从vendor下获取,未寻找到再查询GOPATH

image.png

这样也不是十全十美的,这会带来一个弊端,比如上图中,项目A依赖了PackageB和PackageC,而B和C又同时依赖了D的不同版本,它们作为同一个项目的依赖被同时存放在一个Vendor中,这样很大概率会发生依赖冲突

发生这种情况归根结底是它依旧依赖着项目源码,而不能很清晰的标注依赖的版本

GoModule

于是,GoModule应运而生了,它解决了之前依赖管理系统周多弊端,如同一个库多个版本等问题。

它会
通过 go.mod 文件管理依赖包版本
通过 go get/go mod 指令工具管理依赖包

它实现了依赖管理的终极目标: 定义版本规则和管理项目依赖关系

image.png

描述一个合法的go.mod

1
2
3
4
5
6
7
8
9
10
11
12
module example/project/app

go 1.16

require (
  example/lib1 v1.0.2
  example/lib2 v1.0.0 // indirect
  example/lib3 v0.1.0-20190725025543-5a5fe074e612
  example/lib4 0.0.0-20180306012644-bacd9c7efldd // indirect
  example/lib5/v3 v3.0.2
  example/lib6 v3.2.0+incompatible
)

这是一个基本的 go.mod 文件,它由主要的三部分组成

1
module example/project/app

标注了依赖管理的基本单元,它让我们知道从哪找到这个模块

1
go 1.16

标注go原生库

require 是最关键的一部分,它描述了单元依赖
每一个单元依赖由两个部分组成 [Module Path] [Version/Pseudo-version]

通过依赖名称路径(ModulePath),后面跟上所需的版本来定位一个依赖的某次提交;版本应遵守语义化版本或基于commit的伪版本
语义化版本: ${MAJOR}.${MINOR}.${PATCH}
如: V1.3.0,V2.3.0
基于commit伪版本: vX.0.0-yyyymmddhhmmss-abcdefgh1234
第二部分为时间戳,最后一位为提交时哈希码校验码的12位前缀

间接依赖与直接依赖:
部分依赖会使用 // indirect 标注,这代表该依赖单元并非由项目直接引入,而是通过其他依赖单元间接引入
image.png

+incompatible 是为了兼容非语义化版本依赖

依赖分发
我们的依赖可能来自于世界各地,它们使用了不同的代码托管(github.gitlab等),它们每分钟可能要接收数以百万次的使用请求,这样会增加第三方托管平台的压力

为了解决了这个问题,出现了 GoProxy,它作为一个存储站点,会缓存原站中的内容,缓存中的版本也不会改变,实现了稳定可靠的依赖分发。

image.png

可以通过设置环境变量 GOPROXY 来指定proxy服务器,在中国大陆我们可以使用以下两个站点增加稳定性。寻址的时候会优先选择proxy1
https://proxy1.cn,https://proxy2.cn

工具命令

go get

image.png

通过go get指令可以很方便的添加/移除依赖
如图example.org/pkg 为仓库地址,后面可以追加以下的值

@update 默认值,拉取最新的版本
@none 删除依赖
@v1.1.2 语义化版本
@23dfdd5 伪版本
@master 分支的最新提交

go mod

image.png
在项目创建时便需要使用 go mod init 来初始化go.mod文件,这是每一个项目开始前的必要步骤
go tidy是一个很实用的指令,在版本迭代中项目可能堆积了很多已经用不上的依赖,在提交前可以执行一遍来清除