这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天
一、本课重点内容
- Golang并发编程
- Golang测试
二、详细知识点介绍
一、语言进阶(并发编程)
协程&线程 介绍
这里就引入了一个新概念: 协程
个人理解是在线程中再细分为多个协程,供用户(程序猿)使用,依赖于这个特性golang就可以轻松的进行并发编程了。我们可以通过再主线程里调起多个协程,同时进行运行。
而协程能够提高效率的原理是,通过把一个过程细分为许多个小过程,就可以缩短每个小过程中的运行时间,使得中间的等待时间减少,从而提高了整体的运行效率。就像是流水线一样,原本是一个人干整个活,而有了流水线,每个人各专其职,减少了中间的时间浪费,提高了效率。
协程: 用户态,轻量级线程,栈MB级别
线程: 内核态,线程跑多个协程,栈KB级别
通道通信
不要通过共享内存来通信,而是通过通信来共享内存。
golang官方建议使用管道通信的方式进行并发编程
通道 是用于协程间交流的通信载体。严格地来说,通道就是数据传输的管道,数据通过这根管道被 “传入” 或被 “读出”。 因此协程可以发送数据到通道中,而另一个协程可以从该通道中读取数据。
具体用法呢,昨天的作业就是一个简单的例子
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.
Add方法的参数是一个变量名叫delta的int类型参数,主要用来内部计数。 内部计数器默认值为 0. 它用于记录多少个协程在运行。- 当
WaitGroup创建后,计数器值为 0,我们可以通过给Add方法传int类型值来增加它的数量。 记住, 当协程建立后,计数器的值不会自动递增 ,因此需要我们手动递增它。 Wait方法用来阻塞当前协程。一旦计数器为 0, 协程将恢复运行。 因此,我们需要一个方法去降低计数器的值。Done方法可以降低计数器的值。他不接受任何参数,因此,它每执行一次计数器就减 1。
我们在协程中通过Done方法把计数器值降为 0,此时主线程将再次被调度并开始执行之后的代码。
1.10 Mutex 互斥锁
互斥是 Go 中一个简单的概念。在我解释它之前,先要明白什么是竞态条件。 goroutines 都有自己的独立的调用栈,因此他们之间不分享任何数据。但是有一种情况是数据存放在堆上,并且被多个 goroutines 使用。 多个 goroutines 试图去操作一个内存区域的数据会造成意想不到的后果.
所以我们在协程中想要对一个数据进行操作时,需要根据业务场景,对数据上一个互斥锁:
在 Go 中,多协程去操作一个值都可能会引起竞态条件。我们需要在操作数据之前使用 mutex.Lock() 去锁定它,一旦我们完成操作,比如上面提到的 i = i + 1, 我们就可以使用 mutext.Unlock() 方法解锁。
二、依赖管理
Golang 依赖管理的演进
GOPATH
在环境变量中配置GOPATH后,项目代码直接以来src下的代码,可以使用go get将最新版本的包下载至src目录下
弊端:
- 无法实现多版本控制
- 如果同时有两个项目在开发,那只能使用相同的依赖
Go Vendor
在项目目录下增加了vendor文件,将所有以来的副本存放在vendor中,从而解决了多个项目需要同一个依赖的冲突问题
弊端:
- 无法控制依赖版本
- 更新项目可能出现依赖冲突
Go Module
- 通过go.mod文件管理依赖包的版本
- 通过go get/go mod 指令工具管理依赖包
- 实现了自定义版本和项目之间的隔离
Go Module使用
就以我之前写的一个简单的接口为例吧,如图所示
单元测试
测试是避免事故的最后一道屏障
测试主要分为以下三种,从上到下,覆盖率逐层变大,成本却逐层降低。
所以单元测试的重要性也可以由此可见。
单元测试
规则
-
所有文件以 _test.go 结尾
-
函数以 func TestXXX(*testing.T) 声明
-
初始化逻辑放到 TestMain 中
func TestMain(m *testing.M) { // 测试前: 初始化配置/数据 code := m.Run() // 测试后: 释放数据,关闭流等等 os.Exit(code) }
运行
go test [flag] [packages]
默认在当前包下寻找_test.go 文件
assert
更便捷的比对输出结果
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestHelloTome(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}
覆盖率
- 衡量代码测试广度
- 评价项目测试水准
在运行时在后面加上参数--cover即可得出本次测试的覆盖率
(其实很多指令不需要记,忘了的时候go help test即可(虽然这个也很难忘掉吧..))
Tips
- 一般覆盖率: 50%~60%,较高覆盖率80%+
- 测试分支相互独立、全面覆盖。
- 测试单元粒度足够小,函数单一职责
依赖
实际业务中,很多需要使用到数据库、缓存、文件等等外部依赖,为了保证测试结果的一致性和稳定性(稳定&幂等),我们需要使用mock来为这些函数进行打桩。
Mock函数
func Patch(target, replacement interface{}) *PatchGuard为一个函数打桩func Unpatch(target interface{}) bool解除打桩
我们可以使用mock来用一个自己编写的函数来代替数据库、文件等,来提供测试数据。
基准测试
作用:
- 优化代码,对当前代码分析
- 用于优化性能(压测?)
使用-ben=.参数进行基准测试
三、实践练习例子
在上述详细介绍中已经用代码来解释了这些知识点,实践环节将在下一篇博客中详细介绍(Gin框架)
四、课后个人总结
- Golang通道这个思想真的yyds
- 但是想用好通道还是需要多加练习,用不好就容易阻塞出事情
- 较为系统的学习了测试的环节(原本没有很重视这些,自己写项目能跑就行)
五、引用参考
七天入门Go语言(希望我也能像他一样成为大佬)(是本校直系学长,现在已经就职谷歌了好像)