GO语言基础进阶 | 青训营笔记

160 阅读8分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天

一、本堂课重点内容

  • 并发编程
  • 依赖管理
  • 单元测试

二、详细知识点介绍

1.并发编程

1.1并发与并行的区别

并发:多线程程序在一个核的CPU上运行(一个核心执行多个程序)

1.png

并行:多线程程序在多个核的CPU上运行(多个核心执行多个程序)

2.png

并发针对单核 CPU 而言,它指的是 CPU 交替执行不同任务的能力;并行针对多核 CPU 而言,它指的是多个核心同时执行多个任务的能力。单核 CPU 只能并发,无法并行;换句话说,并行只可能发生在多核 CPU 中,并行也可以称作一个广义的并发。 在多核 CPU 中,并发和并行一般都会同时存在,它们都是提高 CPU 处理任务能力的重要手段。而Go可以充分发挥多核优势,高效运行,也可以说Go就是为并发而生的。

1.2Goroutine

3.png 协程(Coroutine):用户态,轻量级线程,栈MB级别

线程(Thread):内核态,线程跑多个协程,栈KB级别

协程的特点在于是一个线程执行,与多线程相比,其优势体现在:协程的执行效率极高。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。而Go中使用的协程称作Goroutine在实现并发时更加好用。

使用Gorountine--快速打印hello goroutine:0~4

4.png

由于是并行打印的输出,每次运行的结果会不一样。

5.png

1.3CSP(Communicating Sequential Processes)

6.png

CSP模型是上个世纪七十年代提出的,用于描述两个独立的并发实体通过共享的通讯channel(管道)进行通信的并发模型。不同的Goroutine通过Channel(管道/通道)实现连接就像传输队列遵循先入先出,保证收发线程的顺序。通过共享内存实现通信时不同的Goroutine会因为临界区发生数据静态的问题,在一定程度上影响程序性能。所以提倡通过通信共享内存而不是通过共享内存而实现通信,CSP讲究的是“以通信的方式来共享内存”。

1.4Channel

创建make(chan 元素类型,[缓冲大小])

  • 无缓冲通道

    make(chan int)
    
  • 有缓冲通道

    make(chan int,2)
    

无缓冲通道与有缓冲通道的区别:

7.png

其实实现无缓冲通道进行通信时发送和接收的Goroutine必须同时准备,才能完成发送和接收操作,故无缓冲通道也被称为同步通道,解决方式就是带有缓冲区的有缓冲通道,其中的缓冲区类似于容量,货架上的货物柜,有人取走才会有后续的人放进去。

发送操作:无缓冲通道上面的发送操作将会阻塞,直到另一个goroutine在对应的通道上面完成接收操作,两个goroutine才可以继续执行。

数据变量 <- 数据
ch <- x//将变量x发送到通道ch

接收操作:无缓冲通道上面的接收操作将会阻塞,直到另一个goroutine在对应的通道上面完成发送操作,两个goroutine 才可以继续执行。

<- 通道变量
<-ch //从通道ch接收一个值,并且丢弃
接收变量 <- 通道变量
x := <-ch//从通道ch接收一个值,并且赋值给变量x

Channel例子-生产消费模型

A :子协程发送0~9数字 B:子协程计算输入数字的平方,A协程与B协程连接,最终通过range遍历dest主协程输出最后的平方数。在生产实际过程中通过缓冲区来解决生产与消费的执行效率问题。

8.png

9.png

1.5并发安全Lock

由于Go保留了通过共享内存来实现通信的机制,就会出现多个Goroutine同时操作内存资源的情况,发生数据硬态。

锁(Lock)的声明:

变量名 sync.Mutex//互斥锁
变量名 sync.RWMutex//读写锁

Mutex和RWMutex都是定义在标准库sync中的结构体变量。

//Mutex  是互斥锁, 零值是解锁的互斥锁, 首次使用后不得复制互斥锁。
type Mutex struct {
   state int32
   sema  uint32
}
​
// RWMutex是一个读/写互斥锁,可以由任意数量的读操作或单个写操作持有。
// RWMutex的零值是未锁定的互斥锁。
//首次使用后,不得复制RWMutex。
//如果goroutine持有RWMutex进行读取而另一个goroutine可能会调用Lock,那么在释放初始读锁之前,goroutine不应该期望能够获取读锁定。 
//特别是,这种禁止递归读锁定。 这是为了确保锁最终变得可用; 阻止的锁定会阻止新读操作获取锁定
type RWMutex struct {
   w           Mutex  //如果有待处理的写操作就持有
   writerSem   uint32 // 写操作等待读操作完成的信号量
   readerSem   uint32 //读操作等待写操作完成的信号量
   readerCount int32  // 待处理的读操作数量
   readerWait  int32  // number of departing readers
}

加锁与不加锁例子---正确操作5个协程执行变量2000次+1操作为10000

10.png

测试结果发现不加锁输出的结果并不为期望值,所以可以通过加锁来解决并发安全,在开发过程中应该避免对共享内存安全的读写操作。

11.png

1.6WaitGroup

Go语言中的WaitGroup和Java中的CyclicBarrier、CountDownLatch非常类似。比如我们有一个主任务在执行,执行到某一点时需要并行执行三个子任务,并且需要等到三个子任务都执行完后,再继续执行主任务。那我们就需要设置一个检查点,使主任务一直阻塞在这,等三个子任务执行完后再放行。

WaitGroup暴露了三个方法:

Add方法用于设置 WaitGroup 的计数值,可以理解为子任务的数量,启动N的并发任务时计数器+N。

Add(delta int)  //计数器+delta

Done方法用于将 WaitGroup 的计数值减一,可以理解为完成一个并发任务。

Done()  //计数器-1

Wait方法用于阻塞调用者,直到 WaitGroup 的计数值为0,即所有并发任务都完成

Wait()  //阻塞直到计数器为0

使用WaitGroup优化1.1.1中打印goroutine的案例,相对于之前使用协程使程序更加有条理。

12.png

13.png

2.依赖管理

2.1依赖管理是什么

这里的依赖为各种开发包,我开发项目的过程中就使站在巨人的肩膀上,利用其他人已经封装好的已经开发认证的开发组件工具来提升自己的研发效率。

2.2背景

对于Hello World这种单体函数只需要依赖源生的SDK,而在实际开发过程中我们不可能基于标准库0~1编码搭建,比较复杂,我们关注业务逻辑的实现上,而其他的一些依赖比如框架、日志、集合等一些的依赖都可以通过SDK方式引用,这样对依赖包的管理显得比较重要。

2.3Go依赖管理演进

14.png

Go的依赖管理主要经历了三个阶段(依赖方式):GOPATH-Go Vendor-Go Module,到目前为止广泛应用的为Go Module,不同环境(项目)依赖的版本不同,控制依赖库的版本

2.3.1GOPATH
  • 环境变量$GOPATH中主要有三个:bin,pkg,src

15.png

  • 项目代码直接依赖src下的代码

  • go get下载最新版本的包到src目录下

2.3.2GOPATH-弊端

16.png

如图所示,本地的两个项目依赖于同一个package包但是它拥有两个版本,V1对应A方法,V2对应B方法,会出现无法实现package的多版本控制。为了解决这种问题Go依赖管理演进出了Go Vendor。

2.3.3Go Vendor

项目目录下增加vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor;依赖寻址方式:vendor => GOPATH,通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题,保证上述的V1和V2在不同package版本下都能构建成功。

2.3.4Go Vendor-弊端
  • 无法控制依赖的版本
  • 更新项目又可能出现依赖冲突,导致编译出错。

17.png

2.3.5Go Module
  • 通过go.mod文件管理依赖包版本
  • 通过go get/go mod指令工具管理依赖包

19.png 18.png 终极目标:定义版本规则和管理项目依赖关系。

2.4依赖管理三要素

  1. 配置文件,描述依赖 go.mod
  2. 中心仓库管理依赖库 Proxy
  3. 本地工具 go get/mod

类似于Java中Maven管理。

2.4.1依赖配置-go.mod

依赖管理基本单元:go mod init github.com/xxx/project.go

2.4.2依赖配置-version

语义化版本:${MAKOR}.${MINOR}.${PATCH}

基于commit伪版本:vX.0.0-yyyymmddhhmmss-abcdefgh1234

2.4.3依赖配置-indirect

A->B->C:A->B为直接依赖,A->C为简介依赖

2.4.4依赖配置-incompatible
  • 主版本2+模块会在模块路径增加/vN后缀
  • 对于没有go.mod文件并且主版本2+的依赖,会+incompatible
2.4.5依赖配置-依赖图

根据Go语言的版本选择的算法在使用编译项目时版本为选择最低的兼容版本。注意1.4比1.3版本低。

2.4.6依赖分发-回源

理解:表示我们的依赖去哪里下载和如何下载的问题,对于构建mod中的依赖直接可以从对应仓库中下载到指定的软件依赖来完成以来分发。

20.png

直接使用依赖的弊端:

  • 无法保证构建稳定性-增加/修改/删除软件版本
  • 无法保证依赖可用性-删除软件
  • 增加第三方压力-代码托管平台负载问题
2.4.7依赖分发-Proxy

通过Proxy来确保依赖的稳定性,在实际操作的时候类比Proxy或者适配器可以解决所有问题,一个Proxy不行,二个Proxy。

21.png

2.4.8依赖分发-变量GOPROXY

GOPROXY="proxy1.cn,https://proxy2.cn,…"服务站点URL列表,"direct"表示源站。Proxy 1->Proxy 2->Direct

2.4.9工具-go get

go get example.org/pkg

@update 默认
@none   删除依赖
@v1.1.2 tag版本,语义版本
@23dfdd5特定的commit
@master 分支的最新commit
2.4.10工具-go mod

go god

init    初始化,创建go.mod文件
download下载模块到本地缓存
tidy    增加需要的依赖,删除不需要的依赖//减少构建整个项目的时间

3.测试

  • 单元测试
  • Mock测试
  • 基准测试

测试就是系统质量的“生命”,是避免事故的最后一道屏障。测试分为三种类型回归测试、集成测试、单元测试。依次覆盖率逐步变大,成本却逐层降低。

回归测试:看自己的项目在运行时有没有什么问题,例如查看抖音评论

集成测试:主要时系统功能维度进行测试比如接口

单元测试:面对测试开发阶段面向开发者

3.1单元测试

单元测试本质上也是代码,与普通代码的区别在于它是验证代码正确性的代码。可简单做个定义:单元测试是开发人员编写的、用于检测在特定条件下目标代码正确性的代码。软件开发天生就具有复杂性,没人敢打包票说自己写的代码一点问题都没有,或者不经测试就能保证代码正确运行,可能你在这个执行路径下能够执行,殊不知还有其他路径,有一一去验证过吗,因此,要保证程序的正确性就必须要对我们代码进行严格测试。

22.png

3.1.1单元测试-规则
  • 所有测试文件一_test.go结尾
  • func TestXxx(*testing.T)
  • 初始化逻辑放到TestMain中,在测试前:数据装载、配置初始化等前置工作,测试后:释放资源等收尾工作。

单元测试例子

3.1.2单元测试-覆盖率

代码的覆盖率越完备,表示代码的正确越有保证。

3.1.3单元测试-Tips
  • 一般覆盖率:50%~60%,较高覆盖率80%+。
  • 测试分支相互独立、全面覆盖。
  • 测试单元粒度足够小,函数单一职责。
3.1.4单元测试-依赖

单元通过File、DB、Cache外部依赖去实现稳定(在任何时间任何函数实现单元测试独立)或者幂等(每次重复运行测试 的结果一样的),从而会用到Mock机制。

3.1.5单元测试-Mock

开源的monkey包:https://github.com/bouk/monkey

快速Mock函数:为一个函数打桩,为一个方法打桩,打桩可以理解为我们用以函数A去替换函数B,B为原函数,A为打桩函数。通过Mock打桩函数实现不在依赖本地文件。

3.2基准测试

解决在实际开发过程中出现代码性能问题

  • 优化代码,需要对当前代码分析
  • 内置的测试框架提供了基准测试的能力

三、项目实战

根据本节课Go语言进阶的学习,进行了社区话题页面的实战,通过展示话题,简单实现了一个本地web服务,用文件的形式存储数据,了解了项目的整个流程,分析需求,画需求ER图,三层通用模型:数据层(Model)、逻辑层(Entity)、视图层(View),Gin高性能的go web框架组件工具,实行查询操作。

四、课后个人总结

通过本次课程学习,Go语言可以通过Goroutine实现高并发编程,Go提倡通过通信来实现共享内存,Go通过标准库Sync中的Lock和WaitGroup实现并发安全操作和同步。,主要学了GOPATH到Go Vender以及到Go Module的Go依赖管理演进过程,对Go Mould的使用有了详细的了解。,学习了单元测试,在项目开发过程中或者开发完一定要进行单元测试提高代码质量,Mock测试通过外部依赖保证单元测试稳定性,通过基准测试保证在本地测试。,通过一系列进阶工程学习,让我对于Go语言的了解的更深一步,不仅在听课还是记笔记效率方面都有所改善,希望自己能够继续保持学习的上进心加油!!

五、引用

[Go语言工程实践之管理和测试]  juejin.cn/course/byte… 

[开发人员必备的技能——单元测试]  www.cnblogs.com/cr330326/p/… 

[Go语言 锁的介绍]  www.jianshu.com/p/94bdaf3ad… 

[Go 快速入门指南 - 缓冲通道和非缓冲通道]  juejin.cn/post/718060… 

[GO语言基础进阶教程:Go语言的协程——Goroutine]  zhuanlan.zhihu.com/p/77205289 

[协程与Channels (CSP: Kotlin, Golang)]  juejin.cn/post/709526…