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

70 阅读4分钟

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

Go语言进阶与依赖管理

1. 语言进阶

从并发编程的视角了解Go高性能的本质

并发vs并行

并发.png

  • 并发:多线程程序在一个核的CPU上运行,宏观上看似A和B都在同时运行,但在某个时间点上仅有一个在运行

并行.png

  • 并行:多线程程序在多个核的CPU上运行,真正意义上的同时运行

1.1 Goroutine - 协程

协程.png

  • 协程:用户态,轻量级线程,栈KB级别,协程的创建和调度由Go语言本身完成

  • 线程:内核态,线程跑多个协程,栈MB级别,线程的创建、切换、停止属于系统操作,比较消耗资源

  • 样例 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) // 保证主协程在子协程后面退出
    }
    

    函数运行结果为:

    hello goroutine :1

    hello goroutine :4

    hello goroutine :3

    hello goroutine :2

    hello goroutine :0

    该运行结果有随机性

1.2 CSP(Communicating Sequential Processes)

协程之间的通信

通过通信共享内存.png 通过共享内存实现通信.png

提倡通过通信共享内存而不是通过共享内存而实现通信。提到通信就要引出另一个重要的概念——通道(channel)。

1.3 Channel

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

  • 无缓冲通道 make(chan int)
  • 有缓冲通道 make(chan int,2) 2表示通道中能存放的元素个数
  • 样例:设置两个子协程,一个发送0~9数字,另一个计算输入数字的平方,最后通过主协程输出平方数
    func CalSquare() {
        src := make(chan int)
        dest := make(chan int, 3)
        go func() {
            defer close(src) // defer作延迟的资源关闭
            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)
          }
    }
    
    函数运行结果: 0 1 4 9 16 25 36 49 64 81

1.4 并发安全Lock

  • 通过样例(对变量执行2000次加一操作,5个协程并发执行)来理解什么是共享内存引发的非并发安全的读写操作
    var (
        x int64
        lock sync.Mutex
    )
    // 有锁
    func addWithLock() {
        for i := 0; i < 2000; i++ {
            lock.Lock()
            x += 1
            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 addWithLick()
        }
        time.Sleep(time.Second)
        println("WithLock:",x)
    }
    
    虽然上面提到了要提倡通过通信来共享内存,但在使用过程中难免会有上面例子中通过共享内存来通信的情况。这时就要用到sync.Mutex(互斥锁),它能确保在任意时刻都只有一个协程(goroutine)访问资源,而其他协程都在等待。所以上述示例的结果中,有锁的函数得到10000,而无锁的函数得到的是一个<=10000的随机数。

1.5 WaitGroup

在上述协程和锁的例子中,都用到了time.Sleep(time.Second),即通过等待一秒的方式确保子协程在主协程前面完成,这是一种暴力阻塞的解决方式,因为我们实际上并不知道子协程完成的具体时间。在Go中,sync.WaitGroup利用计数器原理实现并发安全。

方法名功能
(wg *WaitGroup) Add(delta int)计数器+delta
(wg *WaitGroup) Wait()阻塞直到计数器为0
(wg *WaitGroup) Done()计数器-1
  • 下面我们用sync.WaitGroup来对样例 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()
    }
    
    上述优化中,通过Add开启5个协程,然后在每个协程执行完成之后通过Done方法对计数器减一,表示该协程已经结束,最后通过Wait计数器进行阻塞

2. 依赖管理

了解Go依赖管理的演进路线,这里的依赖指的是各种开发包。对于hello world以及类似的单体函数只需要依赖原生SDK,而实际工程会相对复杂,我们不可能基于标准库0~1编码搭建,而更多的关注业务逻辑的实现,而其他的涉及框架、日志、driver、以及collection等一系列依赖都会通过sdk的方式引入,这样对依赖包的管理就显得尤为重要。

2.1 Go依赖管理演进

GOPATH -> Go Vendor -> Go Module

  • 不同环境(项目)依赖的版本不同
  • 控制依赖库的版本

2.1.1 GOPATH

  • 环境变量$GOPATH,是Go项目的工作区,目录有以下几个结构
  • bin:项目编译的二进制文件
  • pkg:项目编译的中间产物,加速编译
  • src:项目源码
  • 项目代码直接依赖src下的代码
  • go get 下载最新版本的包到src目录下

2.1.2 GOPATH的弊端

  • 场景:项目A和项目B依赖于某一package的不同版本
  • 问题:无法实现package的多版本控制
  • 具体分析:对于同一个pkg,有两个版本,A->A(),B->(),而src下只能有一个版本存在,那么A项目和B项目无法保证都能编译通过。即在GOPATH管理模式下,如果多个项目依赖同一个库,那么该库应该是一个版本,这很显然不能满足我们的项目依赖需求。于是乎,Go Vendor出现了。

2.1.3 Go Vendor

  • 项目目录下增加vendor文件夹,所有依赖包副本形式放在$ProjectRoot/vendor
  • 依赖寻址方式:vendor=>GOPATH
  • 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个pkg依赖的冲突问题。

2.1.4 Go Vendor的弊端

govender.png

  • 问题:无法控制依赖的版本,更新项目有可能出现依赖冲突,导致编译出错
  • 具体分析:如图项目A依赖pkg b和c,而B和C依赖了D的不同版本,通过vendor的管理模式我们不能很好的控制对于D的依赖版本,一旦更新项目,有可能出现依赖冲突导致编译错误。归根到底vendor不能很清晰的标识依赖的版本概念。于是乎,Go Module应运而生。

2.1.5 Go Module

  • 通过go.mod文件管理依赖包版本
  • 通过go get/go mod指令工具管理依赖包
  • 终极目标:定义版本规则和管理项目依赖关系

2.2 依赖管理三要素

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

2.2.1 依赖配置 - go.mod

module github.com/Moonlight-Zhao/go-project-example

go 1.16

require (
	github.com/gin-contrib/sse v0.1.0 // indirect
	github.com/gin-gonic/gin v1.3.0 // indirect
	github.com/go-playground/validator/v10 v10.10.0 // indirect
	github.com/goccy/go-json v0.9.6 // indirect
)

首先模块路径module用来标识一个模块,从模块路径可以看出从哪里找到该模块,如果是github前缀则表示可以从github仓库找到该模块,依赖包的源代码由github管理,如果项目的子包想被单独引用,则需要单独的init go。go 1.16表示依赖的原生sdk版本,require里的是单元依赖,每个依赖单元通过模块路径+版本来唯一标识。

2.2.2 工具 - go get

  • go get example.org/pkg@update默认拉取最新版本,@update可省略

2.2.3 工具 - go mod

  • go mod init —— 初始化,创建go.mod文件
  • go mod download —— 下载模块到本地缓存
  • go mod tidy —— 增加需要的依赖,删除不需要的依赖
  • 流程:最简单的go mod实现就是在go文件中import引用包写好后在项目的根目录下输入go mod init project_name然后再go mod tidy就可以了,可能有拉取超时的问题,就试试go env -w GOPROXY=https://goproxy.cn,direct,当然最好还是挂全局代理。

总结

在Go语言进阶与依赖管理中,主要有以下几点内容

  • Goroutine —— 实现高并发
  • Channel —— 实现协程间通信来共享内存
  • Sync包中的Lock,WaitGroup —— 实现并发安全操作和协程同步
  • go getgo mod的简单使用