字节青训营2Go工程进阶与依赖管理
::: hljs-center
:::
::: hljs-center
Go工程进阶与依赖管理
:::
并发编程
并发与并行的区别
在谈及这两个概念之前,要先明白他们解决的是一个什么共同的问题。我们知道在单线程环境下,任务是堵塞的,当一个任务在运行的时候,另外一个任务只能等待它完成才能继续。如果说程序本身是高速公路的收费站,那么任务就是要通行的车辆,在上一辆车检查放行之前,你只能在原地等着,这样显然是效率很低的,考虑一个web应用,在与用户a通讯的时候,b得等a完成交互才能继续。
那么怎么解决这个问题呢?我们可以在高速公路上修建多个收费站或者开括多条车道,同时让多辆车通过,这便是并发。考虑土地是物理资源,在一条道上设立多个收费站便是并发,而开括更多条车道便是并行。
显然物理资源更加昂贵,我们只能采用第一个方案了,大部分语言使用了一种内核态的由操作系统提供的解决方案——线程(thread)
还有一个更恰当的比方,一个人(cpu)同时喂两个小孩吃饭,看着像两个小孩在吃饭,实际上只有一个人在喂(这即是并发,一个cpu通过调度不同的任务,当有一个线程唤醒时,其他线程必定堵塞,只是因为切换速度较快导致看起来像同时运行)。而并行当然就是有两个人同时在喂两个小孩吃饭了
协程
这种解决方案就是最好的了吗?线程在计算机中也是非常昂贵的资源,单个线程栈的内存占用可能达到MB级别!因此在很多情况下,我们可以使用更加廉价的解决方案
这种看似是线程实际上不是线程的东西我们通常称它为“协程”,协程可以理解为轻量级,用户级的线程。
协程的创建和调度由go本身完成,协程的栈一般在kb级别,一个线程可以调度上万级别的协程,这也是go更加适合高并发场景的原因所在
Goroutine的使用
接下来看看Go为我们提供的Goroutine
下面是一个简单的使用Gouroutine的例子
1 | package main |
上面的代码中定义了一个say函数,它每隔一秒打印一次传入的字符串str,重复五次。接下来在主函数中调用了两次say(),并传入两个不同的参数(“hello”和”zyy”),注意到第一个say函数之前声明了 go,这意味着一个新的Goroutine协程被运行。
看看输出的结果,就像同时有两个线程一样神奇。
实际上并不是真正的并行,将say函数中的time.sleep(second)去掉。再运行一遍
怎么又不是交替打印了?就像前文说的那样,同时执行只是一种错觉,只有在一端有空闲(sleep的时候),另一端才会去执行
有可能发生的事故:主线程提前结束
注意到上面代码main函数中的
1 | time.Sleep(time.Second * 10) |
了吗?将他注释掉试试。
为什么不打印”hello”了?这是因为在协程未完成任务时主线程退出了。我们使用了一种很暴力的方式,让主线程睡上十秒钟,这样不管怎么样都会完成了。但在真正工程实践中,我们不可能每个协程都会睡上一两秒,即是说我们无法预测协程的完成时间。
正确的方法是使用go标准库提供的 WaitGroup,它会更加优雅
1 | package main |
运行一下
cool!,那么它是怎么运行的呢?回顾一下上面的代码
time.Sleep(time.Second)被移除,转而使用了wg.Wait(),它会堵塞主线程,什么时候释放呢?
看这一行代码
1 | wg.Add(1) |
我们往WaitGroup wg里传入了一个参数1,这实际上是WaitGroup内部实现的一个类似计数器的逻辑,当它归0时,Wait的堵塞会释放掉,
而计数器递减发生在
1 | go func() { |
defer wg.Done() 这一行代码里
defer 声明的代码的会在函数退出时执行,通常用于关闭io资源,数据库连接等
它所声明的代码 wg.Done() 会将WaitGroup的计数器-1,上面的代码中传入的初始值为1
即是说: 这个匿名函数在退出时调用wg.Done让计数器归0,主线程也完成了堵塞释放掉
怎么样?很优雅吧!
并发安全Lock
1 | package main |
看一下这段代码,每调用一次add会让count+2000,调用了五次,显然结果是10000吧,润一下看看
在多次运行后发现了不对劲,8333?怎么看都不可能吧!事实上这种事故是小概率事件,通常在工程代码中遇到了都很难排查
所以对并发安全的认知很重要,我们需要能提前预测出可能发生的事故。对于一个变量的修改并不是一个原子操作,原子操作意味着它是一步完成的,但+=这个操作有拥有三个操作,它需要先取出变量,修改变量,再赋值回去;试想一下,有一个协程在进行取出变量这个操作,此时又有一个协程赋值了变量,那不是乱套了吗,结果肯定就不会每次都是正确的了。
为了解决该问题,go提供了完善的方案,我们可以使用sync下的Mutex,修改一下上面的代码
1 | package main |
锁的原理是,第一次调用Mutex.Lock的时候会打个标记,当第二次调用时会堵塞,直到它被Unlock。
使用锁时需格外谨慎,尽量只在有并发安全的地方使用,它会很明显的降低性能;此外,使用锁需检查好逻辑,死锁的情况很容易发生
(go应该也有类似java的AtomicInteger那样的原子操作包?)
这下不论如何都能输出正确结果了
协程之间的同步 Channel
那么协程之间怎么进行数据传输通信呢?直接开一个数组或者列表之类的结构,不同协程直接取用不就行了,这样是很危险的。go提倡使用 通道(Channel) 来进行内存共享
通过如下方式来声明一个通道
1 | ch := make(chan int) |
ch为一个不带缓冲的通道,指定了传输数据类型为int,无缓冲意味着每个数据的发送都得等待另一端接收,否则会发送方会发生堵塞。
而带缓冲区(ch0,声明了缓冲区大小为3)可以存在多个数据未被接收,此时发送方可继续发送而不会受到堵塞,当然未接收的数据不能超过声明的大小
这样向一个通道中传输数据
1 | ch <- i //i为int类型变量 |
接受数据
1 | receive int := <-ch //赋值给receive |
通过for range取出所有数据。(for range会一直读取一个通道,直到channel被关闭)
来看一段例子
1 | package main |
该程序由三个协程组成,a协程将 0-9 传入通道ch,b协程将ch中数据取出后进行平方计算传入ch0通道。
最后主线程打印出ch0中的所有平方后的结果
依赖管理
站在巨人的肩膀上
在实际开发项目中,需要学会站在巨人的肩膀上,使用他人已经封装好的,经过验证的工具库来提升开发效率
平常练手的玩具项目基于标准库即可完成,但在实际工程中较为复杂,不可能从0到1编码搭建
应把更多精力放在业务逻辑的实现上,其他依赖(如日志,框架,集合)可通过sdk的方式引入
Go的依赖演变
依赖管理并不是Golang的独创,早之前便有许多不同语言的依赖管理工具,如我们java程序员熟知的maven/gradle,rust的cargo。
Go语言的依赖管理经历了 GOPATH,GoVendor,GoModule 三个阶段的演变
GOPATH
在初学Go配环境的时候会注意到$GOPATH这个环境变量,它指向应该目录,所有的项目都会依赖这个目录下的源码。这将导致一个问题:
如果有不同的项目分别依赖于一个依赖库的不同版本,由于它是一个公共环境变量,并没有任何管理依赖版本的措施,将导致编译出错
GoVendor
为了解决这个问题,演进出了GoVendor
在项目目录下增加vendor文件夹,将所有的依赖包副本形式存放进去。依赖寻找时会优先从vendor下获取,未寻找到再查询GOPATH
这样也不是十全十美的,这会带来一个弊端,比如上图中,项目A依赖了PackageB和PackageC,而B和C又同时依赖了D的不同版本,它们作为同一个项目的依赖被同时存放在一个Vendor中,这样很大概率会发生依赖冲突
发生这种情况归根结底是它依旧依赖着项目源码,而不能很清晰的标注依赖的版本
GoModule
于是,GoModule应运而生了,它解决了之前依赖管理系统周多弊端,如同一个库多个版本等问题。
它会
通过 go.mod 文件管理依赖包版本
通过 go get/go mod 指令工具管理依赖包
它实现了依赖管理的终极目标: 定义版本规则和管理项目依赖关系
描述一个合法的go.mod
1 | module example/project/app |
这是一个基本的 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 标注,这代表该依赖单元并非由项目直接引入,而是通过其他依赖单元间接引入
+incompatible 是为了兼容非语义化版本依赖
依赖分发
我们的依赖可能来自于世界各地,它们使用了不同的代码托管(github.gitlab等),它们每分钟可能要接收数以百万次的使用请求,这样会增加第三方托管平台的压力
为了解决了这个问题,出现了 GoProxy,它作为一个存储站点,会缓存原站中的内容,缓存中的版本也不会改变,实现了稳定可靠的依赖分发。
可以通过设置环境变量 GOPROXY 来指定proxy服务器,在中国大陆我们可以使用以下两个站点增加稳定性。寻址的时候会优先选择proxy1
https://proxy1.cn,https://proxy2.cn
工具命令
go get
通过go get指令可以很方便的添加/移除依赖
如图example.org/pkg 为仓库地址,后面可以追加以下的值
@update 默认值,拉取最新的版本
@none 删除依赖
@v1.1.2 语义化版本
@23dfdd5 伪版本
@master 分支的最新提交
go mod
在项目创建时便需要使用 go mod init 来初始化go.mod文件,这是每一个项目开始前的必要步骤
go tidy是一个很实用的指令,在版本迭代中项目可能堆积了很多已经用不上的依赖,在提交前可以执行一遍来清除
本文标题:字节青训营2Go工程进阶与依赖管理
文章作者:meteor
发布时间:2023-01-17
最后更新:2023-01-17
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 CN 许可协议。转载请注明出处!
分享