Go语言项目实战--并发编程与依赖管理|青训营笔记

333 阅读8分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记

1. 前言

上篇笔记介绍了Go语言的基础入门,本篇内容介绍Go语言并发编程与依赖管理。主要作为后续Go语言项目实战的前置知识储备。

2. Go并发

Goroutine(协程)

  • Go语言通过高效的调度模型,来实现协程(Goroutine)的高并发的操作

  • 线程是平时开发用到比较多的,是一种比较昂贵的系统资源,属于内核态。它的创建、切换、停止都属于重量级的系统操作。栈属于MB级别

  • 协程可以理解为是一种轻量级的线程。它的创建、调度由Go语言本身去完成,比线程会轻量很多。栈属于KB级别

  • 线程上可以并发的跑多个协程,一次可以创建上万数量的协程,这就是Go语言更适合高并发场景的原因所在

实际开发过程中如何开启协程?

  • 只需要在调用函数时,在前面加上一个go关键字,这就可以为函数创建一个协程来运行
import (
   "fmt"
   "time"
)
// 快速打印hello goroutine
func hello(i int) {
   println("hello goroutine : " + fmt.Sprint(i))
}

func HelloGoRoutine() {
   for i := 0; i < 5; i++ {
      go func(j int) {
         hello(j)
      }(i)
   }
   time.Sleep(time.Second) // 保证子协程执行完毕前,主协程不退出
}

3. 协程的通信

CSP(Communicating Sequential Processes)

Go提倡通过协程通信来共享内存,而不是通过共享内存来实现通讯

1654668403550.jpg

  • 通过通信共享内存,会涉及到Channel

    Channel通道相当于为协程建立连接,相当于传输队列,遵循先入先出,能保证收发数据的顺序。

    Channel是一种允许一个Goroutine的值传递到另一个Goroutine的通信机制

  • Go也保留着通过共享内存通信的机制

    使用共享内存进行数据交换,通过互斥量对内存进行加锁,即获取临界区的权限。

    这种机制下,不同的Goroutine之间容易发生“数据竞态”的问题,在一定程序上会影响程序的性能

对比上述两种方式,Go提倡通过通信来共享内存

Channel

Go提倡通过通信来实现共享内存

Channel是Go中的一种引用类型,通过make关键字创建,参数需要传入包含的元素类型与缓冲区的大小

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

根据是否有缓冲区的大小,Channel通道又分为:

  • 无缓冲通道make(chan int)

    image.png

    • 通信时,会导致发送的Goroutine与接收的Goroutine同步化。因此,无缓冲通道也称为同步通道。解决同步问题的一个方式就是使用带有缓冲区的有缓冲通道
  • 有缓冲通道make(chan int,2)

    image.png

    • 通道的容量代表了,通道中能存放多少元素。类比于货架的格子,满了就装不下了,会阻塞发送。知道有人取走货物,才能够放入新的货物,是典型的生产消费模型

实际开发中Channel的使用

func CalSquare() {
   src := make(chan int) // 无缓冲通道
   dest := make(chan int, 3) // 有缓冲通道
   // A生产:发送0-9数字
   go func() {
      defer close(src) // 延迟的资源关闭
      for i := 0; i < 10; i++ {
         src <- i
      }
   }()
   // B消费:计算输入数字的平方
   go func() {
      defer close(dest) // 延迟的资源关闭
      for i := range src {
         dest <- i * i
      }
   }()
   // M:输出最后的平方数
   for i := range dest {
      //复杂操作
      println(i)
   }
}
  • 通过src和dest的传递,其实能够保证顺序性,即是并发安全的

  • dest使用到了有缓冲的通道,考虑到消费者的消费速度可能因为复杂的逻辑,比起生产速度稍慢一些。使用带缓冲的通道,就不会因为消费者的速度问题影响生产者的执行效率,也就是说带缓冲的Channel可以解决生产和消费速度不均衡带来的效率问题

  • 代码中每个channel都是用defer做了延迟的资源关闭

4. Go并发安全与协程同步

Sync

实现并发安全操作以及协程间的同步

  • Lock

    • Go也保存着通过共享内存来实现通信的机制

    • 这种机制下会存在多个Goroutine同时操作同一块内存的情况,也就是“数据竞态”

    实际开发中Lock的使用

    • 对变量执行2000次+1操作,5个协程并发执行

      var (
         x    int64
         lock sync.Mutex // 通过互斥量的关键字来实现加锁
      )
      
    • lock sync.Mutex 通过互斥量的关键字来实现加锁

      func addWithLock() {
         for i := 0; i < 2000; i++ {
            lock.Lock()
            x += 1
            lock.Unlock()
         }
      }
      
    • 通过Lock()获取临界区的资源,Unlock()解锁,释放临界区的资源

      func addWithoutLock() { // 没有对临界区保护
         for i := 0; i < 2000; i++ {
            x += 1
         }
      }
      ​
      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 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()
      }
      
    • 不加锁会出现并发安全问题。加锁通过对临界区的控制来避免并发安全问题

实际开发中,并发安全问题有一定概率会引起错误出现的,难以定位。在开发中应该避免对于共享内存做一些非并发安全的读写操作

  • WaitGroup

    • 之前协程和Lock的例子都使用了sleep实现了暴力的阻塞,这不优雅

    • 我们不知道子协程确切的执行时间,无法精确的设置sleep的时间。Go语言中可以使用WaitGroup来实现并发任务的同步。

    image.png

    • WaitGroup有三个方法

      • Add
      • Done
      • Wait

    内部维护了一个计数器:开启协程+1;执行结束-1;主协程阻塞知道计数器为0

    例如启动了n个并发任务,Add(n);每个任务完成时可以调用Done()方法,使得计数器-1;Wait()阻塞,知道所有的并发任务执行完

    优化之前打印hello goroutine的例子

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

5.Go依赖管理

依赖就是各种开发包,应用开发好的,经过验证的工具/组件来提升开发效率

背景

实际的开发中,相对复杂:

  • 工程项目不可能基于标准库0~1编码搭建
  • 管理依赖库

还需要关注业务逻辑的实现上,其他的一些依赖(涉及框架、日志、driver、集合等),通过SDK的方式引入,此时对依赖包的管理就显得非常重要

Go依赖管理演进

主要经历了三个阶段:Go Path --> Go Vendor --> Go Module

  1. Go Path

    Go语言支持的环境变量,是Go项目的工作区

    image.png

    • 环境变量 $GOPATH

    • 项目代码直接依赖src下的代码,所有依赖的源代码都会放在src下

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

    弊端

    场景:A和B依赖于某一package的不同版本

    image.png

    问题:无法实现package的多版本控制

    • package有V1和V2两个版本,V1实现了项目A中依赖的A方法,V2实现了项目B中依赖的B方法。

    • V2没有做到前后的一个兼容,可能删除了A函数。这对于本地项目来说,他们依赖的是同一个src的源码,对于项目A和B就无法同时构建成功

  2. Go Vendor

    改进GOPATH的问题,在项目目录新增vendor的文件夹

    image.png

    • 项目目录下增加vendor文件夹,存放当前项目依赖的副本

    • 项目的依赖会优先从vendor目录获取,如果没有就去GOPATH

    通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题。项目A下是V1版本,项目B下是V2版本,这样可以同时构建成功

    弊端

    场景:一个项目A依赖B也依赖了C,而B和C又同时依赖了D,D有v1和v2版本

    image.png

    问题:

    • 无法控制依赖版本

      通过vendor的管理模式,就无法很好的控制v1和v2的版本选择。

    • 更新项目又可能出现依赖冲突,导致编译出错

      一旦更新了项目,容易出现依赖冲入,导致编译错误

    归根结底,vendor出现弊端的原因是:vendor依赖项目源码,无法清晰标识版本

  3. Go Module

    解决了vendor依赖管理系统无法依赖多个库的版本问题

    1.11实验性引入,1.16默认开启

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

    终极目标:定义版本规则和管理项目依赖关系

迭代目的:

  • 需要实现或管理不同项目依赖的版本
  • 需要能够控制依赖库的版本

6. Go Module依赖管理方案

依赖管理三要素

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

    有一个文件能够描述我依赖了哪些包,包是如何去唯一的定位

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

    对应Go Module的Proxy

  3. 本地工具——go get/go mod

    Go Module中主要涉及两个工具,go get/go mod。类比java的maven。

配置

  • go.mod

    image.png

    组成

    • 模块路径

      • 标识了一个模块,可以看出从哪里能够找到这个模块

      • 如果项目复杂,很多包,每个包想要被单独引用的话,需要在每个包的目录下都需要建立一个go.mod文件

    • 原生库

      • 标识我们依赖的Go的原生库的版本号

      • 不同项目需要原生库的版本可能是不一样的

    • 单元依赖

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

      • 这样可以唯一定位仓库的某一版本或某次提交

  • version

    GOPATH和Go Vendor都是源码副本方式的依赖,没有版本规则的概念

    Go Module为了更方便的版本管理,定义自己的版本规则:

    1. 语义化版本

      ${MAJOR}.${MINOR}.${PATCH}

      v1.3.0
      v2.3.0 // 来源于Git中tag的概念
      
      • major是大版本,不同的major版本之间可以不兼容,即代码隔离
      • minor,通常做一些新增函数/功能,需要保持在major下,做到前后兼容
      • patch,做一些代码bug修复
    2. 基于 commit 伪版本

      vx.0.0-yyyymmddhhmmss-abcdefgh1234

      v0.0.0-20220401081311-c38fb59326b7
      v1.0.0-20201130134442-10cb98267c6c
      
      • 版本前缀(同语义化版本),
      • 时间戳:提交或commit的时间戳
      • 12位hash码的前缀(hash校验码):每次提交,Go都会默认生成伪版本号
  • indirect

    image.png

    • require单元中有一些关键字,首先看indirect关键字

    场景:项目A依赖B,B又依赖C

    • 其中,A对B是直接依赖;A对C是间接依赖。go.mod中对没有直接导入的依赖模块,就会标识为非直接依赖,用indirect标识出来
  • incompatible

    image.png

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

      • 在Go Model的版本规则中,认为主版本v2及以上版本,这类模块的路径都需要增加后缀(见lib5)

      • 允许不同的major版本之间不相互兼容

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

      • go.mod也是1.11版本实验性引入,之前的仓库已经v2或更高版本的tag了,为了兼容,go.mod会定义对于没有go.mod文件,并且主版本在v2及其以上依赖,它会在版本号的后面加incompatible标识,标识可能会存在一些不兼容的代码逻辑
  • 依赖图

    image.png

    问题:最终编译时所使用的C项目的版本为?(v1.4)

    Go通过MVS(Minimal Version Selection,最小版本选择)选择最低的兼容版本

分发

依赖分发:表示依赖可以从哪里下载,以及如何下载

  • 回源

    image.png

    • Github代码托管平台,go.mod中定义的依赖最终都可以对应到代码仓库管理系统中的某一个项目/版本特定提交

    • 对于go.mod中的依赖可以直接从对应仓库中下载到指定的某个依赖,来完成依赖分发

    • 但是,直接使用版本管理仓库下载依赖有问题:

      • 无法保证构建稳定性

        • 增加/修改/删除软件版本

        • 对于Github或者其他第三方代码套管平台来说,其实软件作者可以直接在代码平台增加/修改/删除软件版本。

        • 这会导致一个问题,下一次构建项目的时候发现之前依赖的某个版本找不到了

      • 无法保证依赖可用性

        • 作者可以对代码仓库删除,这样就无法保证依赖的可用性
      • 增加第三方压力

        • 直接去第三方拉取依赖的话,会增加第三方的压力

        • 第三方代码管理系统只是做代码管理的。如果去依赖,就相当于大流量场景了,不符合系统建立初衷

  • Proxy

    解决了回源的问题

    image.png

    • Proxy是一个服务站点,会缓存原站中的软件内容,缓存的版本也不会该变

    • 如果作者删除某个版本、仓库,也可以通过Proxy保证稳定性,从Proxy中拉取依赖以实现稳定可靠的依赖分发

    场景

    不一定能满足需求下游的接口需求,项目设计过程中,可以通过引入一层Proxy的形式,或者说通过适配器的方案来解决问题。

    也就是说,没有一层Proxy解决不了的问题,如果有那就两层。

  • 变量 GOPROXT

    go.mod是通过变量 GOPROXY环境变量进行proxy的配置。

    GOPROXY是url列表,逗号分割

    GOPROXY="https://proxy1.cn,https://proxy2.cn,direct" // direct表示源站
    

    image.png

    • 如果Proxy1中没有依赖就会进入Proxy2,如果Proxy2仍然不存在依赖,就会回源到第三方平台

    • direct表示,如果前面站点都没有依赖的话,会回源到第三方代码平台

    这种模式与设计缓存的场景一致,比如加本地缓存----分布式缓存---最终依赖DB

工具

  • go get

    image.png

    • 默认go get会拉取majro最新的版本(同@update)
  • go mod

    image.png

    • init

      • 初始化项目时,需要init来创建一个go.mod文件
    • download

      • 下载模块到本地缓存,即把所有的依赖拉下来
    • tidy

      • 常用,主要作用就是增加需要的依赖,删除不需要的依赖

      在每次提交代码之前都可以执行go mod tidy指令,比如说go.mod文件中之前用过依赖包,经过代码修改,有些依赖就不是必须得了,可以通过go mod tidy删除,节省项目编译的时间