这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记
1. 前言
上篇笔记介绍了Go语言的基础入门,本篇内容介绍Go语言并发编程与依赖管理。主要作为后续Go语言项目实战的前置知识储备。
2. Go并发
Goroutine(协程)
-
Go语言通过高效的调度模型,来实现协程(Goroutine)的高并发的操作
-
线程是平时开发用到比较多的,是一种比较昂贵的系统资源,属于内核态。它的创建、切换、停止都属于重量级的系统操作。栈属于MB级别
-
协程可以理解为是一种轻量级的线程。它的创建、调度由Go语言本身去完成,比线程会轻量很多。栈属于KB级别
-
线程上可以并发的跑多个协程,一次可以创建上万数量的协程,这就是Go语言更适合高并发场景的原因所在
实际开发过程中如何开启协程?
- 只需要在调用函数时,在前面加上一个
go关键字,这就可以为函数创建一个协程来运行
import (
"fmt"
"time"
)
// 快速打印hello goroutine
func hello(i int) {
println("hello goroutine : " + fmt.Sprint(i))
}
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i)
}
time.Sleep(time.Second) // 保证子协程执行完毕前,主协程不退出
}
3. 协程的通信
CSP(Communicating Sequential Processes)
Go提倡通过协程通信来共享内存,而不是通过共享内存来实现通讯
-
通过通信共享内存,会涉及到Channel
Channel通道相当于为协程建立连接,相当于传输队列,遵循先入先出,能保证收发数据的顺序。
Channel是一种允许一个Goroutine的值传递到另一个Goroutine的通信机制
-
Go也保留着通过共享内存通信的机制
使用共享内存进行数据交换,通过互斥量对内存进行加锁,即获取临界区的权限。
这种机制下,不同的Goroutine之间容易发生“数据竞态”的问题,在一定程序上会影响程序的性能
对比上述两种方式,Go提倡通过通信来共享内存
Channel
Go提倡通过通信来实现共享内存
Channel是Go中的一种引用类型,通过
make关键字创建,参数需要传入包含的元素类型与缓冲区的大小
make(chan 元素类型,[缓冲大小])
根据是否有缓冲区的大小,Channel通道又分为:
-
无缓冲通道
make(chan int)- 通信时,会导致发送的Goroutine与接收的Goroutine同步化。因此,无缓冲通道也称为同步通道。解决同步问题的一个方式就是使用带有缓冲区的有缓冲通道
-
有缓冲通道
make(chan int,2)- 通道的容量代表了,通道中能存放多少元素。类比于货架的格子,满了就装不下了,会阻塞发送。知道有人取走货物,才能够放入新的货物,是典型的生产消费模型
实际开发中Channel的使用
func CalSquare() {
src := make(chan int) // 无缓冲通道
dest := make(chan int, 3) // 有缓冲通道
// A生产:发送0-9数字
go func() {
defer close(src) // 延迟的资源关闭
for i := 0; i < 10; i++ {
src <- i
}
}()
// B消费:计算输入数字的平方
go func() {
defer close(dest) // 延迟的资源关闭
for i := range src {
dest <- i * i
}
}()
// M:输出最后的平方数
for i := range dest {
//复杂操作
println(i)
}
}
-
通过src和dest的传递,其实能够保证顺序性,即是并发安全的
-
dest使用到了有缓冲的通道,考虑到消费者的消费速度可能因为复杂的逻辑,比起生产速度稍慢一些。使用带缓冲的通道,就不会因为消费者的速度问题影响生产者的执行效率,也就是说带缓冲的Channel可以解决生产和消费速度不均衡带来的效率问题
-
代码中每个channel都是用defer做了延迟的资源关闭
4. Go并发安全与协程同步
Sync
实现并发安全操作以及协程间的同步
-
Lock
-
Go也保存着通过共享内存来实现通信的机制
-
这种机制下会存在多个Goroutine同时操作同一块内存的情况,也就是“数据竞态”
实际开发中Lock的使用
-
对变量执行2000次+1操作,5个协程并发执行
var ( x int64 lock sync.Mutex // 通过互斥量的关键字来实现加锁 ) -
lock sync.Mutex通过互斥量的关键字来实现加锁func addWithLock() { for i := 0; i < 2000; i++ { lock.Lock() x += 1 lock.Unlock() } } -
通过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) } func ManyGoWait() { 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() } -
不加锁会出现并发安全问题。加锁通过对临界区的控制来避免并发安全问题
-
实际开发中,并发安全问题有一定概率会引起错误出现的,难以定位。在开发中应该避免对于共享内存做一些非并发安全的读写操作
-
WaitGroup
-
之前协程和Lock的例子都使用了sleep实现了暴力的阻塞,这不优雅
-
我们不知道子协程确切的执行时间,无法精确的设置sleep的时间。Go语言中可以使用WaitGroup来实现并发任务的同步。
-
WaitGroup有三个方法
- Add
- Done
- Wait
内部维护了一个计数器:开启协程+1;执行结束-1;主协程阻塞知道计数器为0
例如启动了n个并发任务,Add(n);每个任务完成时可以调用Done()方法,使得计数器-1;Wait()阻塞,知道所有的并发任务执行完
优化之前打印
hello goroutine的例子func ManyGoWait() { 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() } -
5.Go依赖管理
依赖就是各种开发包,应用开发好的,经过验证的工具/组件来提升开发效率
背景
实际的开发中,相对复杂:
- 工程项目不可能基于标准库0~1编码搭建
- 管理依赖库
还需要关注业务逻辑的实现上,其他的一些依赖(涉及框架、日志、driver、集合等),通过SDK的方式引入,此时对依赖包的管理就显得非常重要
Go依赖管理演进
主要经历了三个阶段:Go Path --> Go Vendor --> Go Module
-
Go Path
Go语言支持的环境变量,是Go项目的工作区
-
环境变量
$GOPATH -
项目代码直接依赖
src下的代码,所有依赖的源代码都会放在src下 -
go get下载最新版本的包到src目录下
弊端
场景:A和B依赖于某一package的不同版本
问题:无法实现package的多版本控制
-
package有V1和V2两个版本,V1实现了项目A中依赖的A方法,V2实现了项目B中依赖的B方法。
-
V2没有做到前后的一个兼容,可能删除了A函数。这对于本地项目来说,他们依赖的是同一个src的源码,对于项目A和B就无法同时构建成功
-
-
Go Vendor
改进GOPATH的问题,在项目目录新增
vendor的文件夹-
项目目录下增加vendor文件夹,存放当前项目依赖的副本
-
项目的依赖会优先从vendor目录获取,如果没有就去GOPATH
通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题。项目A下是V1版本,项目B下是V2版本,这样可以同时构建成功
弊端
场景:一个项目A依赖B也依赖了C,而B和C又同时依赖了D,D有v1和v2版本
问题:
-
无法控制依赖版本
通过vendor的管理模式,就无法很好的控制v1和v2的版本选择。
-
更新项目又可能出现依赖冲突,导致编译出错
一旦更新了项目,容易出现依赖冲入,导致编译错误
归根结底,vendor出现弊端的原因是:vendor依赖项目源码,无法清晰标识版本
-
-
Go Module
解决了vendor依赖管理系统无法依赖多个库的版本问题
1.11实验性引入,1.16默认开启
- 通过
go.mod文件管理依赖包版本 - 通过
go get/go mod指令工具管理依赖包
终极目标:定义版本规则和管理项目依赖关系
- 通过
迭代目的:
- 需要实现或管理不同项目依赖的版本
- 需要能够控制依赖库的版本
6. Go Module依赖管理方案
依赖管理三要素
-
配置文件,描述依赖——
go.mod有一个文件能够描述我依赖了哪些包,包是如何去唯一的定位
-
中心仓库管理依赖库——
Proxy对应Go Module的Proxy
-
本地工具——
go get/go modGo Module中主要涉及两个工具,go get/go mod。类比java的maven。
配置
-
go.mod
组成
-
模块路径
-
标识了一个模块,可以看出从哪里能够找到这个模块
-
如果项目复杂,很多包,每个包想要被单独引用的话,需要在每个包的目录下都需要建立一个go.mod文件
-
-
原生库
-
标识我们依赖的Go的原生库的版本号
-
不同项目需要原生库的版本可能是不一样的
-
-
单元依赖
-
依赖标识:[Module Path] + [Version/Pseudo-version]
-
这样可以唯一定位仓库的某一版本或某次提交
-
-
-
version
GOPATH和Go Vendor都是源码副本方式的依赖,没有版本规则的概念
Go Module为了更方便的版本管理,定义自己的版本规则:
-
语义化版本
${MAJOR}.${MINOR}.${PATCH}v1.3.0 v2.3.0 // 来源于Git中tag的概念- major是大版本,不同的major版本之间可以不兼容,即代码隔离
- minor,通常做一些新增函数/功能,需要保持在major下,做到前后兼容
- patch,做一些代码bug修复
-
基于 commit 伪版本
vx.0.0-yyyymmddhhmmss-abcdefgh1234v0.0.0-20220401081311-c38fb59326b7 v1.0.0-20201130134442-10cb98267c6c- 版本前缀(同语义化版本),
- 时间戳:提交或commit的时间戳
- 12位hash码的前缀(hash校验码):每次提交,Go都会默认生成伪版本号
-
-
indirect
- require单元中有一些关键字,首先看
indirect关键字
场景:项目A依赖B,B又依赖C
- 其中,A对B是直接依赖;A对C是间接依赖。go.mod中对没有直接导入的依赖模块,就会标识为非直接依赖,用indirect标识出来
- require单元中有一些关键字,首先看
-
incompatible
-
主板本2+模块会在模块路径增加/vN后缀
-
在Go Model的版本规则中,认为主版本v2及以上版本,这类模块的路径都需要增加后缀(见lib5)
-
允许不同的major版本之间不相互兼容
-
-
对于没有go.mod文件并且主版本2+的依赖,会+incompatible
- go.mod也是1.11版本实验性引入,之前的仓库已经v2或更高版本的tag了,为了兼容,go.mod会定义对于没有go.mod文件,并且主版本在v2及其以上依赖,它会在版本号的后面加incompatible标识,标识可能会存在一些不兼容的代码逻辑
-
-
依赖图
问题:最终编译时所使用的C项目的版本为?(v1.4)
Go通过MVS(Minimal Version Selection,最小版本选择)选择最低的兼容版本
分发
依赖分发:表示依赖可以从哪里下载,以及如何下载
-
回源
-
Github代码托管平台,go.mod中定义的依赖最终都可以对应到代码仓库管理系统中的某一个项目/版本特定提交
-
对于go.mod中的依赖可以直接从对应仓库中下载到指定的某个依赖,来完成依赖分发
-
但是,直接使用版本管理仓库下载依赖有问题:
-
无法保证构建稳定性
-
增加/修改/删除软件版本
-
对于Github或者其他第三方代码套管平台来说,其实软件作者可以直接在代码平台增加/修改/删除软件版本。
-
这会导致一个问题,下一次构建项目的时候发现之前依赖的某个版本找不到了
-
-
无法保证依赖可用性
- 作者可以对代码仓库删除,这样就无法保证依赖的可用性
-
增加第三方压力
-
直接去第三方拉取依赖的话,会增加第三方的压力
-
第三方代码管理系统只是做代码管理的。如果去依赖,就相当于大流量场景了,不符合系统建立初衷
-
-
-
-
Proxy
解决了回源的问题
-
Proxy是一个服务站点,会缓存原站中的软件内容,缓存的版本也不会该变
-
如果作者删除某个版本、仓库,也可以通过Proxy保证稳定性,从Proxy中拉取依赖以实现稳定可靠的依赖分发
场景
不一定能满足需求下游的接口需求,项目设计过程中,可以通过引入一层Proxy的形式,或者说通过适配器的方案来解决问题。
也就是说,没有一层Proxy解决不了的问题,如果有那就两层。
-
-
变量 GOPROXT
go.mod是通过变量 GOPROXY环境变量进行proxy的配置。
GOPROXY是url列表,逗号分割
GOPROXY="https://proxy1.cn,https://proxy2.cn,direct" // direct表示源站-
如果Proxy1中没有依赖就会进入Proxy2,如果Proxy2仍然不存在依赖,就会回源到第三方平台
-
direct表示,如果前面站点都没有依赖的话,会回源到第三方代码平台
这种模式与设计缓存的场景一致,比如加本地缓存----分布式缓存---最终依赖DB
-
工具
-
go get
- 默认go get会拉取majro最新的版本(同@update)
-
go mod
-
init
- 初始化项目时,需要init来创建一个go.mod文件
-
download
- 下载模块到本地缓存,即把所有的依赖拉下来
-
tidy
-
常用,主要作用就是增加需要的依赖,删除不需要的依赖
在每次提交代码之前都可以执行
go mod tidy指令,比如说go.mod文件中之前用过依赖包,经过代码修改,有些依赖就不是必须得了,可以通过go mod tidy删除,节省项目编译的时间 -
-