Go 语言原理与实践-工程实践理论 | 青训营笔记

89 阅读10分钟

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

image-20230116124501179

1. 语言进阶

1.1 Goroutine

  • 并发:并发是多个线程在一个核上,交替执行。是同一时间段内共同交替执行。
  • 并行:并行是多个线程在多个核上,同时执行。是同一时刻,同一时间点同时执行。
image-20230116124410849
  • 线程:用户态,轻量级进程,栈MB级别
  • 协程:内核态,线程跑多个协程,栈KB级别
image-20230116125205898

1.2 CSP

Do not communicate by sharing memory; instead, share memory by communicating.

不要通过共享内存来通信,而要通过通信来实现内存共享。

Go 是第一个将 CSP 的这些思想引入,并且发扬光大的语言。仅管内存同步访问控制在某些情况下大有用处,Go 里也有相应的 sync 包支持,但是这在大型程序很容易出错。

Go 一开始就把 CSP 的思想融入到语言的核心里,所以并发编程成为 Go 的一个独特的优势,而且很容易理解。

image-20230116134457924

大多数的编程语言的并发编程模型是基于线程和内存同步访问控制,Go 的并发编程的模型则用 goroutine 和 channel 来替代。Goroutine 和线程类似,channel 和 mutex (用于内存同步访问控制)类似。

Goroutine 解放了程序员,让我们更能贴近业务去思考问题。而不用考虑各种像线程库、线程开销、线程调度等等这些繁琐的底层问题,goroutine 天生替你解决好了。

Channel 则天生就可以和其他 channel 组合。我们可以把收集各种子系统结果的 channel 输入到同一个 channel。Channel 还可以和 select, cancel, timeout 结合起来。而 mutex 就没有这些功能。

Go 的并发原则非常优秀,目标就是简单:尽量使用 channel;把 goroutine 当作免费的资源,随便用。

1.3 Channel

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

  • 无缓冲通道:
  • 有缓冲通道:

image-20230116140508410

Channel通道在使用的时候,有以下几个注意点:

  • 1.用于goroutine,传递消息的。

  • 2.通道,每个都有相关联的数据类型, nil chan,不能使用,类似于nil map,不能直接存储键值对

  • 3.使用通道传递数据:<- chan <- data,发送数据到通道。向通道中写数据 data <- chan,从通道中获取数据。从通道中读数据

  • 4.阻塞: 发送数据:chan <- data,阻塞的,直到另一条goroutine,读取数据来解除阻塞 读取数据:data <- chan,也是阻塞的。直到另一条goroutine,写出数据解除阻塞。

  • 5.本身channel就是同步的,意味着同一时间,只能有一条goroutine来操作。

最后:通道是goroutine之间的连接,所以通道的发送和接收必须处在不同的goroutine中。

1.4 并发安全Lock

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

package main

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

var (
	x    int64
	lock sync.Mutex
)

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

	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	fmt.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
	}
}

加了锁的每次输入的都是期望的结果,而不加锁的有时会出现非期望结果。这就是并发安全问题。

image-20230116142125337

1.5 WaitGroup

开启一个子协程计数器+1;子协程结束计数器-1;主协程阻塞直到计数器为0。

5b549547-dffa-409b-9a77-021ea5c43b98

通过上锁的方式,某一时间段,只能允许一个goroutine来访问这个共享数据,当前goroutine访问完毕,解锁后,其他的goroutine才能来访问。

我们可以借助于sync包下的锁操作。

举个例子,我们通过并发来实现火车站售票这个程序。一共有100张票,4个售票口同时出售。

示例代码:

package main

import (
   "fmt"
   "math/rand"
   "strconv"
   "sync"
   "time"
)

// 全局变量
var tickets int = 10

var wg sync.WaitGroup
var mutex sync.Mutex

func main() {
   winNum := 4
   wg.Add(winNum)
   for i := 0; i < winNum; i++ {
      go sellTickets("窗口" + strconv.Itoa(i+1))
   }
   wg.Wait()
}

func sellTickets(window string) {
   rand.Seed(time.Now().UnixNano())
   defer wg.Done()
   for {
      mutex.Lock()
      if tickets > 0 {
         fmt.Printf("%s:卖出了第%d张票\n", window, tickets)
         tickets--
         mutex.Unlock()
      } else {
         fmt.Println(window + ":没票了")
         mutex.Unlock()
         break
      }
      time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
   }
}
image-20230116143140886

2. 依赖管理

2.1 Go依赖管理演进

Go的依赖管理主要经历了如下三个阶段,到目前被广泛应用的是go module,整个演进路线主要围绕两个目标实现迭代发展的

  • 不同项目依赖的版本不同
  • 控制依赖库的版本

image-20230116152050497

2.1.1 GOPATH

  • 项目代码直接依赖src下的代码
  • go get 下载最新的包到src目录下

image-20230116154656515

**场景:**A和B依赖于某一package的不同版本。假设A依赖于func A(),B依赖于func B(),而V2版本删除了func A()

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

image-20230116164454288

2.1.2 Go Vendor

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

  • 所有依赖包以副本形式放在projectRoot/vendor
  • 寻址顺序:vendor→GOPATH

bc699909-e7af-491f-acdc-d361711d82ac

问题:

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

image-20230116165032524

2.1.3 Go Module

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

2.2 依赖管理三要素

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

2.3.1 依赖配置 - go.mod

依赖标识:[Moduel path] [Version/Pseudo-version]

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

image-20230116194717118

2.3.2 依赖配置 - version

  • 语义化版本${MAJOR}.${MINOR}.${PATCH}
    • V1.3.0
    • V2.3.1
  • 基于commit的版本vX.0.0-yyymmddhhmmss-abcdefgh1234
    • v0.0.0-20220401081311-c38fb59326b7
    • v1.0.0-20201130134442 10cb98267c6

2.3.3 依赖配置 - indirect

标识简介引用

image-20230116195253066

2.3.4 依赖配置 - incompatible

下一个常见是的是incompatible,主版本2+模块会在模块路径增加/vN后缀,这能让go module按照不同的模块来处理同一个项目不同主版本的依赖。由于gomodule是1。11实验性引入所以这项规则提出之前已经有一些仓库打上了2或者更高版本的tag了,为了兼容这部分仓库,对于没有go.mod文件并且主版本在2或者以上的依赖,会在版本号后加上+incompatible 后缀 前面讲语义化版本提到,对于同一个库的不同的major版本,需要简历不同的pkg目录,用不同的gomod文件管理,如下面仓库为例,V1版本gomod在主目录下,而对于V2版本,则单独简历了V2目录,用另一个gomod文件管理依赖路径,来表明不同major的不兼容性。,那对于有些V2+tag版本的依赖包并未遵循这一定义规则,就会打上**标志, 增加一个compatile的case

image-20230116200110426

2.3.5 依赖配置 - 依赖图

如果X项目依赖了A、B两个项目, 且A、B分别依赖了C项目的v1.3、v1.4两个版本, 最终编译时所使用的C项目的版本为如下哪个选项?(单选) A.v1.3 B.v1.4 c.A用到c时用v1.3编译, B用到c时用v1.4编译

36b144bd-0f11-4175-a13d-db002f8b9f06

选择最低的兼容版本

2.3.6 依赖分发 - 回源

下面讲一下gomodule的依赖分发。也就是从哪里下载,如何下载的问题~ github是比较常见给的代码托管系统平台,而Go Modules 系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交或版本,这样的话,对于go.mod中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。 但直接使用版本管理仓库下载依赖,存在多个问题,

  • 首先无法保证构建确定性:软件作者可以直接代码平台增加/修改/删除 软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本。

  • 无法保证依赖可用性:依赖软件作者可以直接代码平台删除软件,导致依赖不可用;

  • 大幅增加第三方代码托管平台 压力。

a3eb5320-0ea2-47fa-a4f1-b3a53ef0e514

2.3.7 依赖分发 - Proxy

而go proxy就是解决这些问题的方案,Go Proxy 是一个服务站点,它会缓源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供“immutability”和“available”的依赖分发;使用 Go Proxy 之后,构建时会直接从 Go Proxy 站点拉取依赖。

image

2.3.8 依赖分发 - 依赖获取顺序

下面讲一下go proxy的使用,Go Modules通过GOPROXY环境变量控制如何使用 Go Proxy;GOPROXY是一个 Go Proxy 站点URL列表,可以使用“direct”表示源站。对于示例配置,整体的依赖寻址路径,会优先从proxy1下载依赖,如果proxy1不存在,后下钻proxy2寻找,如果proxy2,中不存在则会回源到源站直接下载依赖,缓存到proxy站点中。 子标题细化 变量和proxy

GOPROXY="https://goproxy.io/zh/, https://goproxy.cn/, https://mirrors.aliyun.com/goproxy/, direct"

常用的镜像站

image-20230116201617896

2.3.9 工具 - go get

image-20230116203534822

2.3.10 工具 - go god

image-20230116203714496

3. 测试

3.1 单元测试

单元测试主要包括,输入,测试单元,输出,以及校对,单元的概念比较广,包括接口,函数,模块等;用最后的校对来保证代码的功能与我们的预期相符;单侧一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。

3.2 单元测试 - 规则

  • 所有测试文件以_test.go结尾

image (2)

  • func TestXxx(*testing.T)

image (3)

  • 初始化逻辑放到TestMain

image (4)

3.3 单元测试 - assert

image-20230116211321912

3.4 单元测试 - 依赖 & mock

工程中复杂的项目,一般会依赖文件数据库缓存等,而我们的单测需要保证稳定性和幂等性,稳定是指相互隔离,能在任何时间,任何环境,运行测试。 幂等是指每一次测试运行都应该产生与之前一样的结果。而要实现这一目的就要用到mock机制。

比如下边,我们我们想要测试ProcessFirstLine()这个函数,但是,这个函数中调用了ReadFirstLine()方法,会导致ProcessFirstLine()测试成功与否受ReadFirstLine()影响。为了排除这个影响,我们再测试时候,将这个函数替换成我们自己的桩。

image-20230116212603662

3.5 基准测试

Go 语言还提供了基准测试框架,基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。使用方法类似于单元测试,

3.5.1 基准测试 - 例子

这里举一个服务器负载均衡的例子,首先我们有10个服务器列表,每次随机执行select函数随机选择一个执行。

image (5)

3.5.2 基准测试 - 运行

基准测试以Benchmark开头,入参是testing.B, 用b中的N值反复递增循环测试 (对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么 testing.B 中的 N 值将按 1、2、5、10、20、50……递增,并以递增后的值重新进行用例函数测试。)

Resttimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围

runparallel是多协程并发测试;执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。

image (6)

而字节公司为了解决这一随机性能问题,开源了一个高性能随机数方法fastrand,下面有开源地址;我们这边再做一下基准测试,性能提升了百倍。主要的思路是牺牲了一定的数列一致性,在大多数场景是适用的,同学在后面遇到随机的场景可以尝试用一下。

image (7)

image (8)