Golang并发&测试 | 青训营笔记

295 阅读5分钟

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

一、本课重点内容

  • Golang并发编程
  • Golang测试

二、详细知识点介绍

一、语言进阶(并发编程)

协程&线程 介绍

这里就引入了一个新概念: 协程

个人理解是在线程中再细分为多个协程,供用户(程序猿)使用,依赖于这个特性golang就可以轻松的进行并发编程了。我们可以通过再主线程里调起多个协程,同时进行运行。

而协程能够提高效率的原理是,通过把一个过程细分为许多个小过程,就可以缩短每个小过程中的运行时间,使得中间的等待时间减少,从而提高了整体的运行效率。就像是流水线一样,原本是一个人干整个活,而有了流水线,每个人各专其职,减少了中间的时间浪费,提高了效率。

image-20230114110759201

协程: 用户态,轻量级线程,栈MB级别

线程: 内核态,线程跑多个协程,栈KB级别

通道通信

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

golang官方建议使用管道通信的方式进行并发编程

通道 是用于协程间交流的通信载体。严格地来说,通道就是数据传输的管道,数据通过这根管道被 “传入” 或被 “读出”。 因此协程可以发送数据到通道中,而另一个协程可以从该通道中读取数据。

image-20230114111140370

具体用法呢,昨天的作业就是一个简单的例子

waitGroup := sync.WaitGroup{}
waitGroup.Add(2)
go queryCaiyunAPI(word, &waitGroup)	//函数中调用wg.Done()
go queryBaiduAPI(word, &waitGroup)
waitGroup.Wait()

1.1 声明

Golang提供一个chan关键字去创建一个通道。每个通道只能传入一种类型的数据。

var channel chan int //声明了一个可以传入 int 类型数据的通道 channel 
fmt.Println(channel)  //程序会打印nil, 因为通道的 0 值是 nil

当然就像map和slice一样,光是一个nil通道(或者说空指针)是没有任何用处的,我们需要使用 make() 函数创建一个可以使用的通道

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

  • 无缓冲通道 make(chan int)
  • 有缓冲通道 make(chan int,2)
channel := make(chan int) //声明了一个可以传入 int 类型数据的通道 channel
fmt.Println(channel)  //程序会打印channel的地址。 0xc0000180c0

1.2 读写

在Golang中,我们使用很简洁的 -><-进行通道的数据读写

channel <- data就意味着我们把data数据推入channel通道中,数据传输方向就与箭头的方向是相同的,十分好辨认。

<- data像这样的语句也是支持的,这个语句不会把数据传输给任何变量,没有通道来接收数据,但是仍然是一个有效的语句。

以上的通道操作默认是阻塞的(如果通道没有设置缓存的话)

如果缓冲区满,直到通道里的数据被其他协程读取后,通道才能继续读取数据。通道的这些特性在不同的协程沟通的时候非常有用,它避免了我们使用锁或者一些 hack 手段去达到阻塞协程的目的。

1.3 死锁

当通道读写数据时,所在协程会阻塞并且调度控制权会转移到其他未阻塞的协程。

  • 如果当前协程正在从一个没有任何值的通道中读取数据,那么当前协程会阻塞并且等待其他协程往此通道写入值。
  • 因此,读操作将被阻塞。类似的,如果你发送数据到一个通道,它将阻塞当前协程直到有其他协程从通道中读取数据。此时写操作将阻塞 。

下面提供一个主线程因为通道造成死锁的例子

package main

import "fmt"

func main() {
	fmt.Println("start")
	// main 函数的第一个语句是打印 main start 到控制台。
	channel := make(chan string)
	// 在 main 函数中使用 make 函数创建一个 string 类型的通道赋值给 ‘ channel ’ 变量
	channel <- "GoLang"
	// 给通道 channel 传入一个数据 DEMO.
	// 此时主线程将阻塞直到有协程接收这个数据. Go 的调度器开始调度协程接收通道 channel 的数据
	// 但是由于没有协程接受,没有协程是可被调度的。所有协程都进入休眠状态,即是主程序阻塞了。
	fmt.Println("main stop")
}

/*
报错
start
fatal error: all goroutines are asleep - deadlock!  //所有协程都进入休眠状态,死锁

goroutine 1 [chan send]:
main.main()
*/

1.4 关闭通道

  • 第一个操作 c <- "Demo2" 将阻塞协程直到有其他协程从此通道中读取数据,因此 greet 会被调度器调度执行。
  • 第一个操作 <-c 是非阻塞的 因为现在通道c有数据可读。
  • 第二个操作 <-c将被阻塞因为通道c已经没数据可读.
  • 此时main协程将被激活并且程序执行close(c)关闭通道操作。
package main

import "fmt"

func RushChan(c chan string) {
	<- c
	fmt.Println("1")
	<- c
	fmt.Println("2")
}

func main() {
	fmt.Println("main start")
	c := make(chan string, 1)
	go RushChan(c)
	c <- "Demo1"
	close(c)
	/*
	不能向一个关了的channel发信息
	main start
	panic: send on closed channel
	*/
	c <- "Demo2"
	//close(c)
	/*
	close 放这里的话可以
	main start
	1
	2
	Main Stop
	*/
	fmt.Println("Main Stop")
}

1.5 长度和容量

和切片类似,一个缓冲通道也有长度和容量。 通道的长度是其内部缓冲队列未读的数据量,而通道的容量是缓冲区可最大盛放的数据量。 我们可以使用 len 函数去计算通道的长度,使用 cap 函数去获得通道的容量。和切片用法神似

1.6 单向通道

目前为止,我们已经学习到可以双向传递数据的通道,或者说,我们可以对通道做读操作和写操作。但是事实上我们也可以创建单向通道。比如只读通道只允许读操作,只写通道只允许写操作。

单向通道也可以使用 make 函数创建,不过需要额外加一个箭头语法。

roc := make(<-chan int)	// read only channel
woc := make(chan<- int) // write only channel

单向通道可以提高程序的类型安全性,使得程序不容易出错(阻塞等等)

同时Golang也提供了一个简单的语法将双向通道转化为单向通道,来便于在不同协程中起到不同作用

func greet(roc <-chan string) {
	fmt.Println("Hello " + <-roc ,"!")
}

func main() {
	fmt.Println("Main Start")
	c := make(chan string)
	go greet(c)
	c <- "Demo"
	fmt.Println("Main Stop")
}

1.7 Select

select 与 switch 很像, 不需要输入参数,仅仅是使用在通道操作上

Select语句被用来执行多个通道中的一个和其附带的case代码块

不同点是用通道读写操作代替了布尔操作。通道将被阻塞,除非它有默认的 default 块 (之后将介绍)。一旦某个 case 条件执行,它将不阻塞。

以及与for{}这样的空循环很像,空Select{} 也是有效的,但线程将会阻塞并可能会造成死锁

执行条件:

  • 如果所有的 case 语句(通道操作)被阻塞,那么 select 语句将阻塞直到这些 case 条件的一个不阻塞(通道操作),case 块执行。
  • 如果有多个 case 块(通道操作)都没有阻塞,那么运行时将随机选择一个不阻塞的 case 块立即执行。

1.8 default case块

像 switch一样,select 语句也有 default case 块。default case 块 是非阻塞的,不仅如此, default case 块可以使 select 语句永不阻塞,这意味着, 任何通道的 发送 和 接收 操作 (不管是缓冲或者非缓冲) 都不会阻塞当前线程。

如果有 case 块的通道操作是非阻塞,那么 select 会执行其 case 块。如果没有那么 select 将默认执行 default 块.

1.9 WaitGourp

WaitGroup 是一个带着计数器的结构体,这个计数器可以追踪到有多少协程创建,有多少协程完成了其工作。当计数器为 0 的时候说明所有协程都完成了其工作。

这个结构体也有三个公开方法: Add, Wait 和 Done.

  1. Add 方法的参数是一个变量名叫 deltaint 类型参数,主要用来内部计数。 内部计数器默认值为 0. 它用于记录多少个协程在运行。
  2. WaitGroup创建后,计数器值为 0,我们可以通过给 Add方法传 int类型值来增加它的数量。 记住, 当协程建立后,计数器的值不会自动递增 ,因此需要我们手动递增它。
  3. Wait 方法用来阻塞当前协程。一旦计数器为 0, 协程将恢复运行。 因此,我们需要一个方法去降低计数器的值。
  4. Done 方法可以降低计数器的值。他不接受任何参数,因此,它每执行一次计数器就减 1。

我们在协程中通过Done方法把计数器值降为 0,此时主线程将再次被调度并开始执行之后的代码。

1.10 Mutex 互斥锁

互斥是 Go 中一个简单的概念。在我解释它之前,先要明白什么是竞态条件。 goroutines 都有自己的独立的调用栈,因此他们之间不分享任何数据。但是有一种情况是数据存放在堆上,并且被多个 goroutines 使用。 多个 goroutines 试图去操作一个内存区域的数据会造成意想不到的后果.

所以我们在协程中想要对一个数据进行操作时,需要根据业务场景,对数据上一个互斥锁:

在 Go 中,多协程去操作一个值都可能会引起竞态条件。我们需要在操作数据之前使用 mutex.Lock() 去锁定它,一旦我们完成操作,比如上面提到的 i = i + 1, 我们就可以使用 mutext.Unlock() 方法解锁。

二、依赖管理

Golang 依赖管理的演进

image-20230116111450251

GOPATH

在环境变量中配置GOPATH后,项目代码直接以来src下的代码,可以使用go get将最新版本的包下载至src目录下

弊端:

  • 无法实现多版本控制
    • 如果同时有两个项目在开发,那只能使用相同的依赖

Go Vendor

在项目目录下增加了vendor文件,将所有以来的副本存放在vendor中,从而解决了多个项目需要同一个依赖的冲突问题

弊端:

  • 无法控制依赖版本
  • 更新项目可能出现依赖冲突

Go Module

  • 通过go.mod文件管理依赖包的版本
  • 通过go get/go mod 指令工具管理依赖包
  • 实现了自定义版本和项目之间的隔离

Go Module使用

就以我之前写的一个简单的接口为例吧,如图所示

image-20230116112257334

单元测试

测试是避免事故的最后一道屏障

测试主要分为以下三种,从上到下,覆盖率逐层变大,成本却逐层降低。

所以单元测试的重要性也可以由此可见。

img

单元测试

规则

  • 所有文件以 _test.go 结尾

  • 函数以 func TestXXX(*testing.T) 声明

  • 初始化逻辑放到 TestMain 中

    func TestMain(m *testing.M) {
        // 测试前: 初始化配置/数据
        code := m.Run()
        // 测试后: 释放数据,关闭流等等
        os.Exit(code)
    }
    

运行

go test [flag] [packages]

默认在当前包下寻找_test.go 文件

image-20230116113824715

assert

更便捷的比对输出结果

package main

import (
	"github.com/stretchr/testify/assert"
	"testing"
)

func TestHelloTome(t *testing.T) {
	output := HelloTom()
	expectOutput := "Tom"
	assert.Equal(t, expectOutput, output)
}

image-20230116114049104

覆盖率

  • 衡量代码测试广度
  • 评价项目测试水准

在运行时在后面加上参数--cover即可得出本次测试的覆盖率

(其实很多指令不需要记,忘了的时候go help test即可(虽然这个也很难忘掉吧..))

Tips

  • 一般覆盖率: 50%~60%,较高覆盖率80%+
  • 测试分支相互独立、全面覆盖。
  • 测试单元粒度足够小,函数单一职责

依赖

实际业务中,很多需要使用到数据库、缓存、文件等等外部依赖,为了保证测试结果的一致性和稳定性(稳定&幂等),我们需要使用mock来为这些函数进行打桩。

推荐库: github.com/bouk/moneky

Mock函数

  • func Patch(target, replacement interface{}) *PatchGuard 为一个函数打桩
  • func Unpatch(target interface{}) bool 解除打桩

我们可以使用mock来用一个自己编写的函数来代替数据库、文件等,来提供测试数据。

image-20230116114857497

基准测试

作用:

  • 优化代码,对当前代码分析
  • 用于优化性能(压测?)

使用-ben=.参数进行基准测试

三、实践练习例子

在上述详细介绍中已经用代码来解释了这些知识点,实践环节将在下一篇博客中详细介绍(Gin框架)

四、课后个人总结

  • Golang通道这个思想真的yyds
  • 但是想用好通道还是需要多加练习,用不好就容易阻塞出事情
  • 较为系统的学习了测试的环节(原本没有很重视这些,自己写项目能跑就行)

五、引用参考

官方文档

七天入门Go语言(希望我也能像他一样成为大佬)(是本校直系学长,现在已经就职谷歌了好像)