这是我参与「第五届青训营 」笔记创作活动的第4天
1. 语言进阶
1.1 Goroutine
- 并发:并发是多个线程在一个核上,交替执行。是同一时间段内共同交替执行。
- 并行:并行是多个线程在多个核上,同时执行。是同一时刻,同一时间点同时执行。
- 线程:用户态,轻量级进程,栈MB级别
- 协程:内核态,线程跑多个协程,栈KB级别
1.2 CSP
Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来通信,而要通过通信来实现内存共享。
Go 是第一个将 CSP 的这些思想引入,并且发扬光大的语言。仅管内存同步访问控制在某些情况下大有用处,Go 里也有相应的 sync 包支持,但是这在大型程序很容易出错。
Go 一开始就把 CSP 的思想融入到语言的核心里,所以并发编程成为 Go 的一个独特的优势,而且很容易理解。
大多数的编程语言的并发编程模型是基于线程和内存同步访问控制,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 元素类型 [, 缓冲大小])
- 无缓冲通道:
- 有缓冲通道:
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
}
}
加了锁的每次输入的都是期望的结果,而不加锁的有时会出现非期望结果。这就是并发安全问题。
1.5 WaitGroup
开启一个子协程计数器+1;子协程结束计数器-1;主协程阻塞直到计数器为0。
通过上锁的方式,某一时间段,只能允许一个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)
}
}
2. 依赖管理
2.1 Go依赖管理演进
Go的依赖管理主要经历了如下三个阶段,到目前被广泛应用的是go module,整个演进路线主要围绕两个目标实现迭代发展的
- 不同项目依赖的版本不同
- 控制依赖库的版本
2.1.1 GOPATH
- 项目代码直接依赖
src下的代码 go get下载最新的包到src目录下
**场景:**A和B依赖于某一package的不同版本。假设A依赖于func A(),B依赖于func B(),而V2版本删除了func A()。
**问题:**无法实现package的多版本控制
2.1.2 Go Vendor
通过每个项目引入一份依赖的副本,解决多个项目需要同一个package依赖的冲突问题
- 所有依赖包以副本形式放在
projectRoot/vendor下 - 寻址顺序:vendor→GOPATH
问题:
- 无法控制依赖的版本
- 更新项目又可能出现依赖冲突,导致编译出错。
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版本最下面是单元依赖,每个依赖单元用模块路径+版本来唯一标示。
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
标识简介引用
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
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编译
选择最低的兼容版本
2.3.6 依赖分发 - 回源
下面讲一下gomodule的依赖分发。也就是从哪里下载,如何下载的问题~ github是比较常见给的代码托管系统平台,而Go Modules 系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交或版本,这样的话,对于go.mod中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。 但直接使用版本管理仓库下载依赖,存在多个问题,
-
首先无法保证构建确定性:软件作者可以直接代码平台增加/修改/删除 软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本。
-
无法保证依赖可用性:依赖软件作者可以直接代码平台删除软件,导致依赖不可用;
-
大幅增加第三方代码托管平台 压力。
2.3.7 依赖分发 - Proxy
而go proxy就是解决这些问题的方案,Go Proxy 是一个服务站点,它会缓源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供“immutability”和“available”的依赖分发;使用 Go Proxy 之后,构建时会直接从 Go Proxy 站点拉取依赖。
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"
常用的镜像站
2.3.9 工具 - go get
2.3.10 工具 - go god
3. 测试
3.1 单元测试
单元测试主要包括,输入,测试单元,输出,以及校对,单元的概念比较广,包括接口,函数,模块等;用最后的校对来保证代码的功能与我们的预期相符;单侧一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。
3.2 单元测试 - 规则
- 所有测试文件以
_test.go结尾
func TestXxx(*testing.T)
- 初始化逻辑放到
TestMain中
3.3 单元测试 - assert
3.4 单元测试 - 依赖 & mock
工程中复杂的项目,一般会依赖文件、数据库、缓存等,而我们的单测需要保证稳定性和幂等性,稳定是指相互隔离,能在任何时间,任何环境,运行测试。 幂等是指每一次测试运行都应该产生与之前一样的结果。而要实现这一目的就要用到mock机制。
比如下边,我们我们想要测试ProcessFirstLine()这个函数,但是,这个函数中调用了ReadFirstLine()方法,会导致ProcessFirstLine()测试成功与否受ReadFirstLine()影响。为了排除这个影响,我们再测试时候,将这个函数替换成我们自己的桩。
3.5 基准测试
Go 语言还提供了基准测试框架,基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。使用方法类似于单元测试,
3.5.1 基准测试 - 例子
这里举一个服务器负载均衡的例子,首先我们有10个服务器列表,每次随机执行select函数随机选择一个执行。
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为了保证全局的随机性和并发安全,持有了一把全局锁。
而字节公司为了解决这一随机性能问题,开源了一个高性能随机数方法fastrand,下面有开源地址;我们这边再做一下基准测试,性能提升了百倍。主要的思路是牺牲了一定的数列一致性,在大多数场景是适用的,同学在后面遇到随机的场景可以尝试用一下。