并发编程
并发 vs 并行
举个形象点的例子
- 并发可以理解为一边吃饭,一边喝水,因为人只有一个嘴一个咽喉,所以同一时刻饭和水只能有一样进入,二者只能交替进行
- 并行可以理解为一边走路一边吃东西,因为走路是靠腿脚,吃东西是靠嘴,二者不相干,相当于两个独立的线程,因而可以同时进行
Go语言实现了并发性能提高的调度模型,通过高效的调度,可以充分发挥多核优势,高效运行,可以说Go语言就是为并发而生的
Goroutine
Go语言中实现高并发有一个重要概念叫协程
- 线程属于内核态,它的创建、切换、停止都属于很重的系统操作,比较消耗资源,消耗在MB级别
- 协程属于用户态,可以理解为轻量级的线程,协程的创建和切换由Go语言本身去完成,比线程消耗资源要少很多,消耗在KB级别
快速打印goroutine 0~4
这里我们可以通过在调用的函数前加上 go 关键字来开启一个协程来运行, 在主函数最后加上了一个 time.Sleep 函数用来保证子协程运行结束前 主线程不退出
最终输出
可以看到是乱序的,也就是说goroutine 0~4是通过并行进行输出的
CSP(Communicating Sequential Processes)
说完协程,再来说说协程之间的通信
Go语言是提倡通过通信共享内存而不是通过共享内存而实现通信
- 像左图通过channel将协程进行连接,就像是传输队列,遵循先入先出,能保证收发的顺序
- 而像右图通过共享内存实现通信,需要通过互斥量对内存进行加锁,也就是需要获取临界区的权限,这样在一定程度上会影响程序的性能。(基本上只要需要去获取锁,都会多少影响到性能)
所以通过以上两种方式,GO语言为了保证性能,选择了通过通信实现共享内存
Channel
Channel是一种引用类型,它的创建需要使用 make 关键字
Channel又分为无缓冲通道和有缓冲通道
- 无缓冲通道就像是快递员送快递到楼下,打电话叫我们来拿快递,过程是同步进行的,不见不散。但这样快递员必须等我们下楼拿完快递才会去送出下一份快递,等所有人来拿完才能完成工作
- 有缓冲通道可以理解为快递员将快递放到驿站,然后通知我们来拿,这样过程就是异步进行的了,快递员在通知完所有人来拿快递后工作就结束了,至于我们什么时候来拿就影响不到他了,效率明显提升,而这个驿站就相当于是缓冲通道,当然如果缓冲通道也就是驿站满了,快递员还是要等待驿站的快递被取走才能继续向里面添加新的快递
下面我们定义两个协程
A 子协程发生0~9数字
B 子协程计算输入数字的平方
主协程输出最后的平方数
在这里我们定义的两个通道,dest作为传输最终结果的通道采用了有缓冲通道,因为考虑到主协程作为消费者可能消费速度没有那么快,为避免消息阻塞,因而添加了缓冲
输出结果
并发安全 Lock
现在我们进行一个测试,对变量执行2000次+1操作,5个协程并发执行
首先测试不加锁的情况
可以看到结果并不一定正确(可能会正确,但那是偶然)
此时我们加上锁
可以看到,此时结果就都是正确的了
至于为什么不加锁会出现这种问题,这算是一种并发安全问题,可能会出现多个协程读取到同一个x值,然后均对其进行+1操作
例如协程1读取到x此时为50,准备将其进行+1操作,使其变为51,但在其写入51值之前,协程2也读取了x的值为50,也对50进行+1操作,这样在协程1执行完写入x=51的操作后,协程2又重复执行了写入x=51的操作。诸如此类的操作便会导致x最终的值可能会低于预期的结果
WaitGroup
前面我们为了保证在协程执行结束前主协程不退出,都采用了调用time.Sleep函数的方法
但我们并不知道子协程执行所需的一个确切时间,因此就无法精确的设定Sleep的时间
为了解决这个问题,Go语言中提供了WaitGroup
当我们启动了n个协程任务,计数器会加上n,每执行完一个协程,计数器会减1,然后调用Wait函数来阻塞,等待其他协程执行完,当计数器为0则表示所有并发任务执行完成
这里我们再回头看之前快速打印goroutine 0~4的例子,我们就可以用计数器来优化了
那么到这里有关Go语言的并发编程问题就结束了
依赖管理
这里的依赖是指各种已经封装好的包,我们在开发的工程中要学会站在巨人的肩膀上,利用他人已经开发好的或是已经经过验证的开发组件或工具来提高效率
工程项目不可能基于标准库从0~1编码搭建,我们应将更多精力放在业务逻辑上
因而我们需要注重依赖的管理
Go依赖管理演进
GOPATH
GOPATH介绍
GOPATH是Go语言支持的一个环境变量,是我们Go项目的一个工作区
该目录下主要有三个目录,分别用来存储项目的不同文件
- bin 项目编译的二进制文件
- pkg 项目编译的中间产物,加速编译
- src 项目源码
项目代码直接依赖src下的代码
go get下载最新版本的包到src目录下
GOPATH弊端
场景:A和B依赖于某一package的不同版本
问题:无法实现package的多版本控制
也就是说GOPATH同一时刻只能存储同一package的一个版本,如果不同项目需要其不同版本就会出问题
Go Vendor
Go Vendor介绍
项目目录下增加vendor文件夹,所有依赖包以副本形式放在其中
依赖会优先在vendor目录获取,如果没有会再去GOPATH下寻找
这样通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题
Go Vendor弊端
又是喜闻乐见的弊端环节
项目A依赖package B和package C,这两个package又同时依赖着package D,一旦BC其中某一个package更新,而又连带着需要依赖的package D更新,C依赖的package D变成V2版本了,而B依赖的package D还是v1版本而package D的这两个版本又不兼容彼此,然后就会又双报错了
所以Go Vendor的弊端就在于
- 无法控制依赖的版本
- 更新项目有可能出现依赖冲突,导致编译出错
Go Module
Go Module介绍
Go Module是Go语言官方推出的一个依赖管理系统,类似于Java里的Maven,在1.4以后版本都已经自动配置上了,只要安装了 Go,自然而然也就可以使用 Go moudles 了,而 Go modules 的出现也解决了在 Go1.11 前的几个常见争议问题
其主要操作
- 通过go.mod文件管理依赖包版本
- 通过go get/go mod指令工具管理依赖包
管理依赖三要素
依赖配置
go.mod
依赖标识:[Module Path][Version/Pseudo-version]
version
语义化版本由三部分组成
- Major是一个大版本,不同的Major版本可以是不同的
- Minor是一些新增函数或功能,需要保证前后兼容
- patch一般是做一些代码bug修复
基于commit的伪版本也是三部分组成
- x.0.0也是代表大版本
- yyyymmddhhmmss代表时间戳,提交commit的时间戳
- 提交commit过后随机生成的哈希码作为伪版本号
indirect
间接依赖
比如说 A -> B -> C ,即A依赖于B,B又依赖于C
那么 A对B是直接依赖, A对C是间接依赖
而对于go.mod中非直接依赖,便会用indirect标识出来,如图中所示
incompatible
在go.mod的版本规则中,主版本在V2版本及以后,模块路径需要加上版本后缀。
因为go.mod是在Go1.11以后才加入的,而在这之前可能有些模块已经有了v2及以上版本,为了兼容这部分版本,go.mod就会定义,对于没有go.mod文件并且主版本在V2及以上的依赖,会在版本号后加上incompatible后缀,作为标识
依赖图
下面来做一道题
要解决这道题,我们要了解go.mod的版本选择算法
go.mod在版本选择上会选择最低的兼容版本,也就是能同时满足两个项目依赖的最低版本 因而这里选择答案B,即v1.4
假设这里选项中有个答案为v1.5,我们仍然是选择v1.4,因为虽然v1.4和v1.5都能兼容,但go.mod默认选择能兼容的最低版本
依赖分发
通常下载依赖会通过Proxy来进行拉取
Goproxy其实就是url列表,按照配置,会优先从proxy1中寻找依赖,如果没有则取proxy2中寻找,如果还没有则会回源到direct
工具
go get
go mod
每次提交代码前都可以执行一次tidy指令,比如之前go.mod用过一些依赖包,经过代码的修改,这些依赖包不再必须,通过tidy就可以将那些非必要依赖包删除掉,减少整体的构建时间