Go语言进阶与依赖管理 | 青训营笔记

79 阅读5分钟

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

一、本堂课重点内容

  1. 语言进阶:从并发编程的视角了解Go高性能的本质
  2. 依赖管理:了解Go语言依赖管理的演进路线

二、详细知识点介绍

并发编程

01 并发 vs 并行

其实从多线程程序运行的角度来看,并发指的是多线程指的是程序在一个核的CPU上运行,它是通过时间片的一个切换来实现同时运行的一个状态。并行则指的是直接利用多个核直接实现多线程的同时运行。广义的并发可以理解为系统对外的特征或者能力。实际中并行可以理解为实现并发的一种手段。

GO语言实现了并发的一个调度模型。可以最大限度的利用资源。

1.1   线程

线程是系统中比较昂贵的一种资源,它是属于内核态,它的创建切换停止都是属于很重的内核操作,一般在KB级别。协程可以理解为轻量级的线程。协程的创建和调度由GO语言本身去完成,一般在MB级别。所以GO语言一次可以创建上万左右的协程。这就是GO语言适合高并发场景的原因所在。

func hello(i int) {  
   println("hello 1 goroutine : " + fmt.Sprint(i))  
}  
  
func HelloGo() {  
   for i := 0; i < 5; i++ {  
      go func(j int) {  
         hello(j)  
      }(i)  
   }  
   time.Sleep(time.Second)  // 子协程执行外之前主协程不退出
}

复习

这里的例子中用到了fmt.Sprintf,go实现了与C语言相似的打印效果。 *Print:   输出到控制台(不接受任何格式化,它等价于对每一个操作数都应用 %v)
Println: 输出到控制台并换行
Printf : 只可以打印出格式化的字符串。只可以直接输出字符串类型的变量
Sprintf:格式化并返回一个字符串而不带任何输出。
Fprintf:格式化并输出到 io.Writers。

此外go func表示这个函数会是以协程的方式运行,这样就可以提供程序的并发处理能力。

运行结果:

hello goroutine : 5
hello goroutine : 0
hello goroutine : 3
hello goroutine : 2
hello goroutine : 1
hello goroutine : 4

1.2   CSP - Communicating Sequential Process - 通信顺序进程

左图是一个通过通信来共享内存的一个示意图。GO Routine是一个构成并发的执行体,通道相当于把协程做了一个连接,像是传输队列遵循着先入先出的原则,能保证收发顺序。GO也保存着通过共享内存实现通信的方法。但是必须通过互斥量对内存进行加锁,也就是需要获取临界区的权限。这种情况下在一定程度上会影响程序的性能。

![[Pasted image 20230116135729.png]]

1.3 Channel

这是一种引用类型,它需要通过make关键字来创建。这里面包括了元素类型和缓冲区的大小,根据缓冲大小又可以分为无缓冲通道和有缓冲通道。

无缓冲通道和有缓冲通道的区别: 在使用有缓冲通道进行通信时,他会导致发送的GO Routine和接收的GO Routine同步化,所以无缓冲通道也被称为同步通道。对于有缓冲通道,缓冲大小代表着通道中能存放几个元素,这是一个典型的生产消费模型。

func CalSquare() {  
   src := make(chan int)  
   dest := make(chan int, 3)  
   go func() {  
      defer close(src)  
      for i := 0; i < 10; i++ {  
         src <- i  
      }  
   }()  
  
   go func() {  
      defer close(dest)  
      for i := range src {  
         dest <- i * i  
      }  
   }()  
  
   for i := range dest {  
      println(i)  
   }  
}

[!question] 为什么要用带缓冲的队列?

[!answer] 因为这样就不会因为消费者的消费速度影响生产者的执行效率。

1.4 并发安全Lock

GO也保存着通过共享内存来实现通信的方式。这种情况下就会存在多个GO Routine同时操作一块内存资源的情况。

例子:对变量执行2000次+1操作,5个协程并发执行。

var (  
   x    int64  
   lock sync.Mutex  
)  
  
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 addWithLock() {  
   for i := 0; i < 2000; i++ {  
      lock.Lock()  
      x += 1  
      lock.Unlock()  
   }  
}  
func addWithoutLock() {  
   for i := 0; i < 2000; i++ {  
      x += 1  
   }  
}

结果:可以看到如果不加锁的话输出的就不是预期值,只有加锁的情况下才会输出预期值。

WithoutLock: 8078
WithLock: 10000

1.5 WaitGroup

在刚才我们通过Sleep来实现协程的阻塞,但是这显然不是一种优雅的方法。我们不知道子协程具体的执行时间,因此我们也无法设置精确的Sleep时间。

GO语言中可以使用WaitGroup来实现并发任务的同步,它也是在sync包下,暴露了3个方法分别是Add、Done、Wait。它的内部维护了一个计数器,计数器的值可以增加也可以减少。例如我们如果启动了n个并发任务的话,计数器就加上n。每个任务完成时通过调用Done方法让计数器的值减1。

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()  
}

02 依赖管理

我们需要学会利用他人已经开发好的包或者工具去提升我们的开发效率。

2.1 Go依赖管理演进

GO的依赖其实经过了3个阶段:GOPATH Go Vendor Go Module

依赖的迭代主要围绕着2个关键:不同环境或者项目的依赖版本不同、需要能控制依赖库的版本

2.1.1 GOPATH

它是一个环境变量,实际上是一个工作区,这个目录下主要有3个关键的部分:bin(存放项目编译的二进制文件)、pkg(存放项目编译的中间产物、加速编译)、src(存放项目源码)。

原理:项目代码直接依赖src下的代码,再通过go get 下载最新版本的包到src目录下。

缺点:A和B依赖于某一package的不同版本,这时候无法实现package的多版本控制。

2.1.2 Go Vendor

项目目录下增加vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor,这个情况下项目的依赖会优先从vendor获取,如果vendor中没有,会去GOPATH下去寻找。

原理:通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题。

缺点:Project A 依赖了Project B 和 Project C。Project B依赖了Package D-V1,Project C依赖了Package D-V2。这样会出现依赖冲突从而导致编译错误。

2.1.3 Go Module

通过go.mod文件管理依赖包版本

通过go get/go mod指令工具管理依赖包

2.2.2 依赖管理三要素

1.配置文件,描述依赖 - go.mod

2.中心仓库管理依赖库 - Proxy

3.本地工具 - go get/mod

2.3.1 依赖配置 – go.mod

首先是模块路径,这个主要标识了一个模块,也就是一个依赖管理的基本单元。

Module Example/project/APP

Go 1.6

Require( // 单元依赖 它有两部分组成,一部分是模块路径,另一部分是版本号。

       Example/lib1 v1.0.2

依赖标识:[Module Path][Version/Pseudo-version]

2.3.2 依赖配置-version

Go Module为了很好地进行版本的管理,它定义了版本的规则。

一种是语义化版本,一种是基于commit伪版本。

语义化版本主要包括三个部分:MAJOR.{MAJOR}.{MINOR}.${PATCH},MAJOR是属于一个大版本,不同的MAJOR间代码隔离;MINOR通常是新增函数或功能,它需要保持在这个MINOR兼容;PATCH一般是做一些代码bug的修复。

基于commit伪版本主要包括三个部分:首先是版本前缀(和语义化版本一样)、时间戳、十二位哈希码的前缀。

2.3.3 依赖配置 – indirect

A -> B -> C

A – > B 直接依赖

A -> C 间接依赖

2.3.4 依赖配置 – incompatible

主版本2+模块会在模块路径增加/vN后缀。

对于没有go.mod文件并且主版本2+的依赖,会+incompatible

2.3.5 依赖分发-回源

直接使用版本管理仓库下载依赖的话其实它会有很多问题:

首先,无法保证构建稳定性(增加/修改/删除软件版本)

无法保证依赖可用性(删除软件)

增加第三方压力(代码托管平台负载问题)

所以为了解决这个问题就出现了一个Proxy,这个Proxy是一个服务站点,他会缓存源站中的版本内容,也就是实现了一个稳定可靠的依赖分发。

2.3.6 依赖分发 – 变量 GOPROXY

Go Modules通过GOPROXY环境变量控制如何使用Go Proxy:

GOPROXY是一个Go Proxy站点URL列表,可以使用direct"表示源站。对于示例配置, 整体的依赖寻址路径,会优先从proxy1下载依赖,如果proxy1不存在,后下钻proxy2寻找, 如果proxy2, 中不存在则会回源到源站直接下载依赖, 缓存到proxv站点中。

2.3.7 工具 – go get

Go get example.org/pkg @update @none @v1.1.2 @23dfdd5 @master

2.3.8 工具 – go mod

Init 初始化,创建go.mod文件

Download 下载模块到本地缓存

Tidy 增加需要的依赖,删除不需要的依赖

三、引用参考

  • 字节内部课:Go 语言进阶与依赖管理