go并发及依赖管理 | 青训营笔记

186 阅读10分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 2天,这次主要学习的是go中能够的并发、单元测试、依赖管理以及实战

1、并发编程

在开始学习并发之前先来聊聊协程,协程:coroutine。也叫轻量级线程。

与传统的系统级线程和进程相比,协程最大的优势在于“轻量级”。可以轻松创建上万个而不会导致系统资源衰竭。而线程和进程通常很难超过1万个。这也是协程别称“轻量级线程”的原因。

image.png

一个线程中可以有任意多个协程,但某一时刻只能有一个协程在运行,多个协程分享该线程分配到的计算机资源

ps:

进程占用内存:GB
线程占用内存:MB
协程占用内存:KB

多数语言在语法层面并不直接支持协程,而是通过库的方式支持,但用库的方式支持的功能也并不完整,比如仅仅提供协程的创建、销毁与切换等能力。如果在这样的轻量级线程中调用一个同步 IO 操作,比如网络通信、本地文件读写,都会阻塞其他的并发执行轻量级线程,从而无法真正达到轻量级线程本身期望达到的目标。

   在协程中,调用一个任务就像调用一个函数一样,消耗的系统资源最少!但能达到进程、线程并发相同的效果。

在一次并发任务中,进程、线程、协程均可以实现。从系统资源消耗的角度出发来看,进程相当多,线程次之,协程最少。

goroutine

Go 在语言级别支持协程,叫goroutine。Go 语言标准库提供的所有系统调用操作(包括所有同步IO操作),都会出让CPU给其他goroutine。这让轻量级线程的切换管理不依赖于系统的线程和进程,也不需要依赖于CPU的核心数量。即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

接下来说说go中的调度器 在底下是N个线程,M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。形成激烈的锁竞争,有了新的调度模型

老调度器有几个缺点:

  1. 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争
  2. M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M'。
  3. 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

image.png

我们用G来标识go协程,M为内核线程,P为调度器,简称GMP

Processor,它包含了运行goroutine的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的G队列。

image.png 如图所示,为go的调度过程

p会保存每个进程或者线程的资源,包括栈,堆等,他的数量可以由宏变量GOMAXPROCS进行设置,每个p都有一个本地队列localp,其中保存着待执行的协程,此外还有一个全局队列,当所有的本地队列全部满时,新来的协程会在全局队列中保存。

  1. 全局队列(Global Queue):存放等待运行的G。
  2. P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
  3. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
  4. M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列一批G放到P的本地队列,或从其他P的本地队列一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

image.png

调度器的设计策略 复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

1)work stealing机制

当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。

2)hand off机制

当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。

利用并行GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。

抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。

全局G队列:在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。

go func() 调度流程

1、我们通过 go func()来创建一个goroutine;

2、有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;

3、G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行;

4、一个M调度G执行的过程是一个循环机制;

5、当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;

6、当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。

goroutine创建

package main

import (
   "fmt"
   "time"
)

func main() {
   HelloGoroutine()
}
func hello(j int) {
   fmt.Println("hello goroutine", j)
}
func HelloGoroutine() {
   for i := 0; i < 5; i++ {
      go func(j int) { //匿名函数
         hello(j)
      }(i) //加括号代表调用,括号中的值为传递参数
   }
   time.Sleep(time.Second) //加延时
}

image.png

runtime.Goexit() 退出

协程之间的通信

goroutine 奉行通过通信来共享内存,而不是共享内存来通信。

channel是Go语言中的一个核心类型,可以把它看成管道。并发核心单元通过它就可以发送或者接收数据进行通讯,引⽤类型 channel可用于多个 goroutine 通讯。其内部实现了同步,确保并发安全。

image.png

定义一个channel时,也需要定义发送到channel的值的类型。channel可以使用内置的make()函数来创建:

chan是创建channel所需使用的关键字。Type 代表指定channel收发数据的类型。

make(chan Type)  //等价于make(chan Type, 0) 无缓冲通道
make(chan Type, capacity) //有缓冲通道

channel非常像生活中的管道,一边可以存放东西,另一边可以取出东西。channel通过操作符 <- 来接收和发送数据,发送和接收数据语法:

    channel <- value      //发送value到channel
    <-channel             //接收并将其丢弃
    x := <-channel        //从channel中接收数据,并赋值给x
    x, ok := <-channel    //功能同上,同时检查通道是否已关闭或者是否为空
    

默认情况下,channel接收和发送数据都是阻塞的,除非另一端已经准备好,这样就使得goroutine同步变的更加的简单,而不需要显式的lock。

package main

func main() {
   src := make(chan int)
   dest := make(chan int, 5)
   go func() {
      for i := 0; i < 10; i++ {
         src <- i
      }
      defer close(src)
   }()

   go func() {
      for i := range src {
         dest <- i * i
      }
      defer close(dest)
   }()
   for i := range dest {
      println(i)
   }
}

image.png

无缓冲的channel(同步)

无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何数据值的通道。

这种类型的通道要求发送goroutine和接收goroutine同时准备好,才能完成发送和接收操作。否则,通道会导致先执行发送或接收操作的 goroutine 阻塞等待。

这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。

阻塞: 由于某种原因数据没有到达,当前go程(线程)持续处于等待状态,直到条件满足,才解除阻塞。

同步: 在两个或多个go程(线程)间,保持数据内容一致性的机制。

有缓冲的channel(异步)

有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个数据值的通道。

这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也不同。

只有通道中没有要接收的值时,接收动作才会阻塞。

只有通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。

这导致有缓冲的通道和无缓冲的通道之间的一个很大的不同:无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换;有缓冲的通道没有这种保证。

关闭通道

如果发送者知道,没有更多的值需要发送到channel的话,那么让接收者也能及时知道没有多余的值可接收将是有用的,因为接收者可以停止不必要的接收等待。这可以通过内置的close函数来关闭channel实现。

waitgroup

计数器,开启协程加一,结束减一,主线程阻塞直到计数器为0

image.png

func ManyGo() {
   var wg sync.WaitGroup
   for i := 0; i < 5; i++ {
      wg.Add(1)
      go func(j int) {
         defer wg.Done()
         hello(j)
      }(i)
   }
   wg.Wait()
}

基于共享内存的通信机制

go中也保留了基于共享内存的通信机制,可以使用sync下的mutex锁,对临界区进行加锁和解锁

package main

import (
   "fmt"
   "sync"
   "time"
)

func main() {
   x = 0
   for i := 0; i < 5; i++ {
      go addwithLock()
   }
   time.Sleep(time.Second)
   fmt.Println("加锁", x)

   x = 0
   for i := 0; i < 5; i++ {
      go addwithUnlock()

   }
   time.Sleep(time.Second)
   fmt.Println("无锁", x)
}

var (
   x    int64
   lock sync.Mutex
)

func addwithLock() {
   for i := 0; i < 2000; i++ {
      lock.Lock()
      x++
      lock.Unlock()
   }
}
func addwithUnlock() {
   for i := 0; i < 2000; i++ {
      x++
   }
}

image.png

2、依赖管理

单体函数只需要依赖原生的SDK。实际的工程会相对复杂,不可能基于标准库 0~1 编码搭建,更多我们会关注业务逻辑的实现。而其他的一些依赖像涉及框架、日志、driver 以及 collection 等一系列依赖都会通过 SDK 的方式引入,这样我们对依赖包的管理就非常重要了

image.png

go的依赖管理分为3个阶段,gopath、govender、gomodule

image.png

gopath

gopath是环境变量,他的值是我们的工作区,如在我们的配置中

image.png

image.png

gopath弊端

image.png

假如有同一个pkg,但是有两个版本,A->A(),B->B(),但是src下只能有一个版本存在,则AB项目不可能全部通过,在 GOPATH 管理模式下,如果多个项目依赖同一个库,则依赖该库是同一份代码,所以不同项目不能依赖同一个库的不同版本,这不能满足我们的项目依赖需求,为了解决这个问题,出现了govendor

govender

Vendor 是当前项目中的一个目录,其中存放了当前项目依赖的副本。在 Vendor 机制下,如果当前项目存在 Vendor 目录,会优先使用该目录下的依赖,如果依赖不存在,会从 GOPATH中查找。 但 Vendor 无法很好解决依赖包的版本变动问题一个项目依赖同一个包的不同版本的问题

image.png

govender弊端

image.png 如图中所示,如果项目A依赖 pkg b和c,而 B 和 C 依赖了 D 的不同版本,通过 Vendor 的管理模式我们不能很好的控制对于D的依赖版本,一旦更新项目,有可能带来依赖冲突,导致编译出错。 Vendor 不能很清晰的标识依赖的版本概念。所以出现了go module

gomodule

Go Modules 是 Go 官方推出的依赖管理系统,解决了之前依赖管理系统存在的像无法依赖同一个库的多个版本等问题。Go Module 从 Go 1.16默认开启;我们一般都读为 go mod。类似于java中的maven

image.png

三要素

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

依赖配置

模块路径用来标识一个模块,从模块路径可以看出从哪里可以找到该模块,如果是 github 前缀则表示可以从 Github 仓库中找到该模块,依赖包的源代码由 github 托管,如果项目的子包想被单独引用,则需要通过单独的 init go.mod 文件进行管理。

中间的则是依赖的原生库 sdk 版本。

最下面的是单元依赖,每个依赖单元由模块路径+版本来唯一标识。

image.png

image.png

gopath 和 govendor 都是源码副本方式依赖,没有版本规则的概念,而 gomod 为了方便管理则定义了版本规则,分为语义化版本和基于 commit 伪版本;

其中 语义化版本包括 ${MAJOR}.${MINOR}.${patch}

不同的 MAJOR 版本标识不兼容的API,所以即使是同一个库,MAJOR版本不同也会被认为是不同的模块;

MINOR 版本通常是新增函数或功能,向后兼容;

而 patch 版本一般是修复bug;

而基于commit的版本包括 vx.0.0-yyyymmddhhmmss-abcdefgh1234基础版本前缀是和语义化版本一样的;时间戳(yyyymmddhhmmss),也就是提交 Commit 的时间,最后是校验码(abcdef),包含12位的哈希前缀;每次提交 commit 后 GO 都会默认生成一个伪版本号。

image.png indirect 后缀标识 go.mod 对应的当前模块,没有直接导入该依赖模块的包,也就是非直接依赖,表示间接依赖

image.png

主版本 2+ 模块会在模块路径增加 /vN 后缀,这能让 go module 按照不同的模块来处理同一个项目不同主版本的依赖。由于 go module 是1.11实验性,引入这个规则提出之前已经有一些仓库打上了2或者更高版本的tag。为了兼容这部分仓库,对于没有 go.mod 文件并且主版本在 2 或者以上的依赖,会在版本号后面加上 + incompatible后缀。

之前讲语义化版本中,对于同一个库的不同 major 版本,需要不同的pkg目录,用不同的gomod文件管理。例如:V1版本在gomod在主目录下,而对于V2版本,则单独有V2目录,用另一个gomod文件管理依赖路径,来表名不同 major 的不兼容性。对于有些 V2+tag 版本的依赖包并未遵循这一定义规则,就会打上 incompatible 标志。

依赖配置-依赖图

image.png

依赖分发

依赖分发就是从哪里下载,如果下载的问题

github 是比较常见的代码托管系统平台,而 go modules 系统中定义的依赖,可以对应到多版本代码管理系统中某一项目的特定提交或版本。此时对于gomod中定义的依赖,可以直接从对应仓库中下载指定软件依赖,从而完成依赖分发。

image.png

proxy

image.png

image.png

变量goproxy

image.png

image.png

工具go-get

image.png

工具go-mod

image.png

注意,在提交之前执行下 go tidy,减少构建时无效依赖包的拉取。