这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天
1. 并发 VS 并行
多线程程序在一个核的cpu上运行
多线程程序在多个核的cpu上运行
- go可以充分发挥多核优势,高效运行
1.1 Goroutine
- 线程:用户态,轻量级线程,栈
MB级别。 - 协程:内核态,线程跑多个协程,栈
KB级别。
实现快速打印 hello goroutine:0~hello goroutine:4
- 不考虑快速的话可以使用for循环的一个串行打印
- 但是这里要求实现
快速打印,需要我们开多个协程去打印 结果如下图:可以看到是乱序输出的,它是通过并行打印输出
- go语言中开启一个协程非常简单,只需要在调用函数的时候
func前面加go关键字,这样就为一个函数创建一个协程来运行,下面代码中用了暴力的sleep函数做了阻塞,为了保证子协程在完成之前主协程不退出,后续会有更有解
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i)
}
time.Sleep(time.Second)
}
1.2 CSP(Communicating Sequential Processes)
- 提倡通过
通信共享内存而不是通过共享内存而实现通信 - 由此引出协程之间的通信:通过通信共享内存,就需要涉及一个通道的概念即
channel,可以看上图左边的,发现左侧goroutine1通过通道与goroutine2以及goroutine3实现通信,相当于把线程做了一个连接,就像是传输队列,保持先入先出,能保证数据的收发顺序。 - 通道
channel就是可以让一个goroutine发送特定的值到另一个goroutine的通信机制 - go也保留着通过共享内存实现通信的机制,如上面右图所示,通过实现共享内存来进行数据交换,通过互斥量对内存进行加锁,这就需要我们获取临界区的一个权限,这样就容易在不同的goroutine之间发生
数据竞态的问题,一定程度上影响程序的性能,所以提倡通过通信共享内存
1.3 Channel
channel是一种引用类型,创建需要通过make关键字,语法:make(chan 元素类型,[缓冲大小])根据缓冲大小可以分为下面两种通道
- 无缓冲通道
make(chan int) - 有缓冲通道
make(chan int,2)
- 图中两者的区别:
- 使用无缓冲通道进行通信时,会导致接受的goroutine和发送的goroutine
同步化,因此无缓冲通道也叫同步通道 - 解决同步问题的一个方式就是使用带有缓冲区的
有缓冲通道,通道的容量代表了通道中能放多少元素。 下面通过一个例子(生产消费模型)来理解channel的妙处:
- A 子协程发送0~9数字
- B 子协程计算输入数字的平方
- 主协程输出最后的平方数
- 代码的逻辑如下:
- 首先定义一个
src的channel是一个无缓冲的一个队列和一个有缓冲的dest的channel - A协程里面通过for循环遍历从0~10把生产的数字
发送到src的一个channel - B协程通过range
遍历src这个channel里的数据,此时通过src这个channel就实现了A和B两个协程的通信,在B协程里计算输入数字的平方发送到一个有缓冲的dest这个channel中 - 最后主协程会通过range操作
遍历dest这个channel,这里用打印来代替复杂的消费者操作
- 其实也可以看到下图的输出结果,通过src和dest的channel传递可以保证数据的
顺序性的,也就是说它是一个并发安全的 - 在定义dest这个channel时使用了
有缓冲的通道,其主要考虑了消费者的操作会复杂相比于生产者,消费速度可能会慢,因此使用带缓冲的队列,就避免了消费者消费速度问题影响生产者的执行效率 - 在每个channel使用一个
defer来做一个资源的延迟关闭
1.4 并发安全 Lock
对变量执行2000次+1操作,5个协程并发执行
- 这里对比了加锁和不加锁的两种情况的输出,例子的预期结果时10000,下面来看加锁和不加锁的实现
- 首先设计一个
addWithLock,通过临界区控制的一个实现,这里使用了sync的Mutex的一个互斥量关键字来实现的一个加锁。在每次对x进行加一操作之前都会通过Unlock获取临界区的资源,计算成功以后再将临界区的权限释放掉 - 然而这个
addWithoutLock函数就没有对临界区进行一个保护,直接进行了一个加一操作
- 下面是一个测试函数,每个都开启五个协程并发执行,分别对加锁和不加锁进行一个测试
- 通过输出结果发现,如果不加锁输出的是8382;加锁输出的是10000通过结果发现不加锁会输出位置的一个结果,这就是并发安全问题,属于一个undefine的行为,解决方式就是加锁,对临界区的控制来并发安全的共享内存
1.5 WaitGroup
- 前面所说的例子里都用到sleep暴力阻塞,并不优雅,我们不知道子协程执行的一个确切时间,因此就无法精确的设置一个sleep的时间
- 在go语言中,可以使用WaitGroup来实现并发任务的一个同步,其也在sync包下,暴露了三个方法:
Add、Done、Wait,内部维护了一个计数器,计数器的值可以增加和减少 - 例如:如果启动了n个并发任务,Add(n)将计数器增加n,那每个任务完成时通过调用Done()将计数器减一,最后调用用Wait方法来阻塞等待着所有的并发任务执行完,当计数器为0的时候,所有的并发任务完成
下面回顾一下前面的暴力sleep用法,并将其优雅化
- 使用WaitGroup优化,因为开启五个协程所以Add(5),通过Done()方法计数器减一,最后通过Wait()进行阻塞
2.1 依赖管理
2.1.1 背景
-
依赖其实是各种开发包,我们开发项目中学会站在巨人的肩膀上,也就是说利用其他人已经封装好的,或者已经验证的开发组件和工具来提升自己的研发效率。
-
对于Hello World来说只需要依赖于原生的sdk,然而实际的开发项目会比较复杂,不可能基于标准库从0~1编码搭建,把更多的精力用在已有的逻辑上,而其他的一些依赖,都可以通过sdk的方式引入,这样对依赖包的管理就显得比较重要
2.1.2 依赖管理演进
- 经历了三个阶段:
GOPATH -> GO Vendor -> GO Module - 整个迭代的目标主要围绕下面两点
- 不同环境(项目)依赖的版本不同
- 控制依赖库的版本
2.1.3 GOPATH
- 环境变量 $GOPATH
- 项目代码直接依赖 src 下的代码
- go get 下载最新版本的包到src目录下 GOPATH-弊端
- 场景:A 和 B 依赖于某一package的不同版本
- 问题:无法实现package的多版本控制
2.1.2 GOVendor
- 项目目录下增加
vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor - 依赖寻址方式:
vendor => GOPATH
- 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package 依赖的冲突问题。
问题:
- 无法控制依赖的版本。
- 更新项目又可能出现依赖冲突,导致编译出错
2.1.3 GO Module
- 通过
go.mod文件管理依赖包版本 - 通过
go get/go mod指令工具管理依赖包
终极目标:定义版本规则和管理项目依赖关系
2.2 依赖管理三要素
- 配置文件,描述依赖 go.mod
- 中心仓库管理依赖库 Proxy
- 本地工具 go get/mod
2.3.1 依赖配置-go.mod
- 依赖标识:
[Module Path][Version/paseudo-version]
2.3.2 依赖配置-version
- 语义化版本
${MAJOR}.${MINOR}.${PATCH}
V1.3.0 V2.3.0 - 基于 commit 伪版本
vX.0.0-yyyymmddhhmmss-abcdefgh1234
v0.0.0-2o220401081311-c38fb59326b7
v1.0.0-20201130134442-10cb98267c6c
2.3.3 依赖配置-indirect
A -> B -> C : A - B 直接依赖、A -> C 间接依赖
2.3.4 依赖配置-incompatible
- 主版本
2+模块会在模块路径增加/vN后缀 - 对于没有 go.mod 文件并且主版本2+的依赖,会+
incompatible
2.3.4 依赖配置-依赖图
如果X项目以来了 A、B 两个项目,且 A、B 分别依赖了 C 项目的 v1.3、v1.4两个版本,最终编译时所使用的 C 项目的版本为如下哪个选项? (单选)
A. v1.3
B. v1.4
C. A 用到 C 时用 v1.3 编译,B 用到 C 时用 v1.4编译
- 选择最低的兼容版本
2.3.5 依赖分发-回源
- 依赖分发:表示我们的依赖去哪里下载
- GitHub是一个代码托管的系统平台,go Module定义的依赖其实都可以对应到多版本代码仓库管理系统中的某一个项目特定提交,或者说版本。对于go Module 中定义的依赖直接可以到对应的仓库中下载指定的软件依赖,完成依赖分发。但是直接使用版本管理仓库下载依赖会存在多个问题
- 无法保证构建稳定性(增加/修改/删除软件版本)
- 无法保证依赖可用性(删除软件)
- 增加第三方压力(代码托管平台负载问题)
2.3.5 依赖分发-Proxy
- Proxy是一个服务站点他会缓存原站中的软件内容,缓存的软件版本也不会改变,通过Proxy实现依赖的稳定性和可靠性的依赖分发
- 项目设计中没有任何问题Proxy无法解决的,如不一个Proxy不行那就两个Proxy
2.3.6 依赖分发-变量 GOPROXY
- 优先从Proxy1查找依赖,1中不存在会在Proxy2中查找,Proxy2任然不存在,就会回源到原站中查找
2.3.7 工具-go get
3. 测试
- 事故
- 营销配置错误,导致非预期用户享受权益,资金损失10w+
- 用户提现,幂等失效,短时间可以多次体现,资金损失20w+
- 代码逻辑错误,广告位被占用,无法出广告,收入损失500w+
- 代码指针使用错误,导致APP不可用,损失上kw+
- 测试
- 回归测试:一般就是质量保证的同学,通过手动,通过终端测试主流场景,比如说刷下抖音,看一下评论等
- 集成测试:
- 模块内的集成,主要是测试模块内各个接口间的交互集成关系;
- 子系统内的集成,测试子系统内各个模块间的交互关系;
- 系统集成,测试系统内各个子系统和模块间的集成关系;
- 集成测试的本质:都是测试接口之间的关系。
- 补充:集成测试既有白盒测试的成分,也有黑盒测试的成分,结合了白盒测试和黑盒测试的特点,一般把他归入灰盒测试。
- 单元测试:面对测试开发阶段,开发者对于单独的模块进行验证
3.1 单元测试
3.1.1 单元测试-规则
- 所有测试文件以 _test.go 结尾
- func TestXxx(*testing.T)
- 初始化逻辑放到 TestMain 中
3.1.2 单元测试-例子
- 由于代码逻辑的一些错误是的
HelloTom()返回了一个Jerry与预期的Tom不符 - 对
HelloTom进行测试,首先获取其输出值,然后与期望输出进行比较,如果不同就要打印一个error
3.1.3 单元测试-运行
- 控制台打印FAIL,测试不通过。需要进行修复
3.1.4 单元测试-assert
- 通过一系列的迭代最终定位问题并修改,此例:
"Tom" - 修改代码中使用了开源测试包帮我们实现Equal或者NotEqual,最终测试通过
3.1.5 单元测试-覆盖率
- 如何衡量代码是否经过了足够的测试?
- 如何评价项目测试水准?
- 如何评估项目是否达到了高水准测试等级?
那就是
- 举个例子
3.1.5 单元测试-Tips
- 一般覆盖率:50%~60%,较高覆盖率80%+。
- 测试分支相互独立、全面覆盖。
- 测试单元粒度足够小,函数单一职责。
3.2 单元测试-依赖
3.3 单元测试-文件处理
- 此代码首先打开一个文件,读取文件第一行,然后修改line11为line00
- 单元测试依赖于本地文本,可以通过单元测试,但是如果本地文件被删除就无法测试
3.4单元测试-Mock
快速Mock函数
- 打桩可以理解为用一个函数A去替换另一个函数B,那B就是原函数,A就是打桩函数
- 为一个函数打桩
- 为一个方法打桩
3.5基准测试
总结
一、本堂课重点内容:
- 锁Lock、线程同步、单元测试
二、详细知识点介绍:
-
- 协程Goroutine
- 通道Channel
- 锁Lock pkg.go.dev/sync
- 线程同步WaitGroup pkg.go.dev/sync
三、实践练习例子:
- 需求模型来源、组件及技术点
四、课后个人总结:
- 理论需要实践,动起手来才能理解其中的原理
五、引用参考: