Go语言特性 | 青训营笔记

84 阅读9分钟

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

1. go高性能的原因

多线程: 线程 切换需要内核态,栈为MB级别(ulimit,默认为8M,线程需要消耗一定的系统资源)

协程:全程工作在用户态,栈为KB级别

协程是基于线程工作的,因此,如果是单线程多协程,仍然无法做到并行。只要不是并行,就没法利用多核cpu的资源。不过go的模型为 多线程且多协程的。因此go支持并行,并且由于协程的轻量级,可以支持百万级的协程创建。

// 创建协程只需要用go关键字,就可以把一段例程(表现为匿名函数) 以协程方式运行
func main() {
    for i := 0; i < 5; i++ {
        go func(j int) {
            fmt.Println(j)
        }(i)
    }
    time.Sleep(1 * time.Second)
}
1.2 goroutine间的通信

go使用通信实现共享内存,而不是通过共享内存实现通信

共享内存:多数语言使用的,基于内存的模型。

通信:面向消息的模型,go使用的内存模型。

被共享的内存称为临界区,为了避免诸如cpu/编译器打乱顺序/非原子操作 这样的undefined behavior,需要加锁避免数据竞争。

1.3 channel

分为

无缓冲队列:也称同步队列,顾名思义,生产者消费者会阻塞到同时进行生产/消费 动作。无消费者,生产者会阻塞。无生产者,消费者会阻塞。

缓冲队列:允许生产者先行生产,只要缓冲区不满,生产者就可以生产:应用场景

生产者生产速度快,消费者在处理生产内容时,往往会涉及到业务逻辑,消费速度慢。为了平衡生产快消费慢的现象,加入缓冲

可以对缓冲/非缓冲队列使用range操作,类似

for j := range src 
for {
 j := <- src 
 if src.isClose() {
     break 
 }
}

因此对管道使用range操作时,实际上就是对管道的循环读,直到管道被关闭,则退出

func main() {
​
    src := make(chan int, 1)
    dst := make(chan int, 3)
    go func() {
        defer close(src)
        for i := 0; i < 10; i++ {
            src <- i
        }
    }()
​
    go func() {
        defer close(dst)
        for j := range src {
            dst <- j * j
        }
    }()
​
    for i := range dst {
        println(i)
    }
}

注意,一定要养成释放资源的习惯。

如果第一个goroutine不释放掉src,那么第二个goroutine就会一直等待src的数据,而主goroutine则等待dst的数据,这样就死锁了。这时候程序会报错。

如果src使用完就释放,dst也跟着释放,那么主goroutine的for i := range dst 就会自动退出

1.4 互斥锁

sync.Mutex{} 对象提供了互斥操作。

虽然go是基于CSP的,但是go也有访问共享内存的情况。比如一个全局指针,虽然指针是值拷贝的,但是指针的值不变。这就意味着依旧是多个goroutine操作同一个地址,这时候不得不加锁了

lock := sync.Mutex{}
lock.Lock()
defer lock.Unlock()

这个玩意相当于初始值为1的信号量。

1.5 waitgroup

这个玩意相当于一个信号量。

func main() {
    wg := sync.WaitGroup{}
    wg.Add(5) // 调用五次P() 加锁五次// 一共解锁五次
    for i := 0; i < 5; i++ {
        go func() {
            time.Sleep(1 * time.Second)
            defer wg.Done() // 调用一次V() 解锁一次
        }()
    }
​
    wg.Wait() // 当信号量为0时 才解除阻塞
    fmt.Println("执行结束")
}
​

2. 依赖管理

sdk:sofeware develop kit 软件开发工具包。

无工具,不工程。我们开发web时,需要应用到各类框架:mvc,orm,rpc,go-redis(在go中创建redis模板,调用go方法可以直接操作redis)。 这些框架以开发工具包(第三方包)的方式导入我们的项目中。

如果手动管理这些项目的依赖,那么就麻烦死了。因此我们必须借助依赖管理工具

2.1 最初的依赖管理:GOPATH

实际上,GOPATH是一个环境变量。

这个环境变量只是提供了一个 go工作区的位置。

也就是说,我们的go程序会去GOPATH指定的目录下,寻找模块名。

  • GOPATH可以有好多个
  • 默认的查找路径为$GOPATH/src
  • 查找方式为就近查找,假如我们要查找名为foo的模块,而GOPATH="/home/jb/demo:/home/jb/aaa"。我们在demo和aaa的src目录下都有foo,那么先查找到demo下具有foo,就不去查找aaa的foo了。

缺点也很明显。

假如位于GOPATH路径下的包pkg升级了,其中方法A被删除,多了一个方法B(实际上,升级后的包应该保证兼容性。但是确实有把方法直接删除的情况)

本地具有项目M,项目N,M依赖于旧版本,N依赖新版本。GOPATH只会根据环境变量顺序寻找到包,不会继续查找。因此M和N都只会找到同一个pkg,要满足M就无法满足N,反之同理。这就无法满足 多版本控制

2.2 进化版 govender

既然只靠GOPATH无法解决多版本控制的问题。那就引入一个govender。

在定位依赖时,会优先查找govender,最后查找GOPATH

但是govender仍然有缺陷

Go 包依赖管理工具 —— govendor - Jioby - 博客园 (cnblogs.com)

2.3 终极版本gomod

go1.11 推出的gomod。不仅解决了依赖包的定位,还解决了版本的控制。

类比java的maven

  • 配置文件,描述依赖和版本 pom.xml
  • 中心仓库管理依赖库:mvnrepo 前面的GOPATH就类似一个本地仓库,只不过没有版本管理,导致无法处理特殊情况
  • 本地工具: mvn

go.mod 由三部分组成

module 表示当前模块单元,由域名和版本组成。比如开源到github上的一个模块,可以命名为github.com/${ver}

go 1.19 表示当前go sdk的版本。也就是go核心库的版本

require 表示当前模块单元依赖于哪些其他的模块单元。由其他模块单元的模块单元名 + 版本组成。

go mod对版本的表示方法具备了兼容性。

版本号可以表示为 主次补丁版本表示法,或者git commit的版本表示法

go mod可以指定模块的直接依赖关系

如果A依赖B,B依赖C,那么A间接依赖C。

但是在require模块,A可以注明依赖于C,但是在依赖条目后可以加上// indirect 表示间接依赖

这样的go mod就这么写的

require (
    A 1.2
    B 1.2 
    C 1.3 // indirect 
    C 1.4 // indirect 
)

那么选择哪个版本的C ?

将选择最低兼容的版本。选择C1.3,无法满足B1.2的依赖,选择C1.4,可以满足A1.2和B1.2的依赖,同时是满足A和B的最小版本,故选择C1.4,这就i意味着,就算有C1.5也不会选择。

2.4 依赖分发 回源

为什么需要依赖分发?

我们依赖管理的一切都是基于 中心仓库的。

但是中心仓库有没有不确定的地方?在go中,中心仓库就是一个个的go包托管平台

  • 软件版本的 增删改
  • 软件的删除,把仓库删了。
  • 增加托管平台的压力
  • 由于防火墙,访问不到托管仓库

为了保证依赖管理的可靠性和稳定性,引入了go proxy

go proxy实际上是开发者到托管平台的一层代理。而代理的主要功能就是提供软件包的缓存

我们会跟着代理链,依次查找。

比如Direct源站为github。但是我们是境内主机。访问不到github,那么我们就无法获取托管平台上的源代码。

此时我们选择Proxy1,Proxy1保证了

  • 提供了源站的镜像,方便访问
  • 保证了可靠性,避免软件包被删除或者版本错误

如果Proxy1没有,则继续去Proxy2访问数据包,如果都没有,才直接访问源站。

实际上,我们通过go get把源站代码直接下载到本地,可以认为本地也是一层go proxy。

2.5 工具 go get

可以根据软件包的版本/分支 拉取对应的代码包。

将代码包拉取到本地,相当于采取本地缓存代理,将大幅度提高依赖的可靠性和稳定性。

2.6 工具 go mod

查看go帮助文档即可

3. go测试

go以一种方便的方式引入了单元测试。

为什么要单元测试?

  • 如果开发部署后再进行测试,那么测试成本将大大增加。
  • 单元测试有利于CI/CD,在微服务的世界里极为方便

如何进行单元测试

  • 测试文件以_test 结尾

  • 测试方法签名为 TestXXX(t *testing.T)

    • 测试方法要保证单一职责,即一个测试方法尽量只测试一种可能
    • 测试方法要设计足够全,满足一定指标的覆盖率
  • 全局代码写在TestMain(m *testing.M)

3.1 go断言

go没有内置的断言语句,可以用第三方包实现

3.2 go测试标准

如何判断自己的测试是否达标,可以用覆盖率来量化

执行所有的测试方法后,不一定测试了目标方法的每一条语句。因此覆盖率 = 已测试的语句 / 总语句 数量。

在测试时,加上参数--cover 就可以计算覆盖率。

3.3 单元测试:依赖

在进行单元测试时,预测试的方法依赖外部内容,可能是外部的库,也可能是外部的文件。

  • 有必要依赖这些东西吗?

    答案:没有必要,因为不管依赖什么,这个函数的内容都是业务逻辑。我们需要测试这个业务逻辑是否可以让所有输入有正确 的输出。但是有时侯,如果业务逻辑依赖一个文件作为输入,或者业务逻辑中有数据库操作,我们可以把这些东西化简掉。

这时候,我们需要使用Mock测试。

Mock也就是模拟测试,顾名思义就是由测试者自行构造数据,并进行测试。这样有效地避免了业务逻辑依赖外部数据的情况。

go get github.com/bouk/monkey 运行后,我们将引入桩函数库的依赖。go get bou.ke/monkey

func ATest() string{
    // 业务逻辑中依赖这个input
    return input()
}
​
// 假如这个方法,通过读取外部文件/数据库获取数据
func input() string {
    return "外部文件"
}

如上,业务逻辑依赖一个从外部文件中读取数据的方法。

如果外部文件没有了,那么测试就无法进行。

我们要保证测试的幂等性和稳定性,即对于同样的输入,要有相同输出。在一段测试中,被测试的方法要保持不会出错

我们可以通过 打桩,替换掉这个input方法。既然从外部获取输入是没必要的,我们可以直接换掉它,换成我们准备好的数据。

打桩就是替换一个方法的执行体。

编译期可以通过闭包实现。而运行时则可以通过反射实现。

image.png

可以看到,我们通过拦截运行时的Input(),使得其返回值从"外部文件" 变为 "替换掉你"

实际上,我们在运行测试时,额外添加参数-gcflags=all=-l 这是因为我们的Input() 方法过于简单,会导致go编译器的内联优化,也就是这个方法调用被直接转换为了返回值,demo := Input() 被优化为了demo := "外部文件"。而打桩是基于方法调用的,既然方法调用被优化了,就会导致调用失效,因此要取消内联优化。当然如果Input()的逻辑足够复杂,就不需要这么多了。

3.4 基准测试

目的:对算法进行压测。可以测试出算法的运行水平,可以进行进一步优化。

  • 可以串行执行
  • 可以并行执行

这里有一个很有意思的点:在并行执行时,如果调用了rand,其性能将大幅度下降。这是因为rand采取伪随机算法,多goroutine下,共享同一个seed。此时需要加锁。如果有并发环境调用rand的情况。可以牺牲掉rand的性能安全,改用无锁的fastrand实现