GO语言入门工程实践部分笔记分享 | 青训营笔记

293 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第2篇笔记

9. 并发编程

9.1 并发与并行

多线程程序在一个核的cpu上运行,就是并发。 多线程程序在多个核的cpu上运行,就是并行。

9.2 进程、线程、协程

①进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。 ②线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。 ③一个进程可以创建和撤销多个线程,同一个进程中的多个线程之间可以并发执行。 协程(GORoutine):独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。 线程:一个线程上可以跑多个协程,协程是轻量级的线程

9.3 GORoutine(协程)

9.3.1 GORoutine的概念

在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。那么能不能有一种机制,程序员只需要定义很多个任务,让系统去帮助我们把这些任务分配到CPU上实现并发执行呢?

Go语言中的goroutine就是这样一种机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

9.3.2 GORoutine的优点

1、每个goroutine所占的堆栈内存大小能动态调整,而不是像线程那样固定内存,使得同时开启的线程数有限,所以在Go语言中一次创建十万左右的goroutine也 是可以的

2、Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的, 其一大特点是goroutine的调 度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc 函数(除非内存池需要改变),成本比调度OS线程低很多

9.3.3 GORoutine的使用

Go语言中使用goroutine非常简单,只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine。

一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数

func hello() {
    fmt.Println("Hello Goroutine!")
}
func main() {//在程序启动时,Go就会为main()函数创建一个默认的goroutine。
    go hello() //启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
    time.Sleep(time.Second)//用于等一等其他goroutine执行完
}

PS:要注意当main()结束,其他所有在main()函数中启动的goroutine会一同结束,不管执行完没有

9.4 Runtime调度器

我们可以利用runtime包的方法来调度协程对cpu的占用

9.4.1 runtime.GOMAXPROCS

通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数

func a() {
    for i := 1; i < 10; i++ {
        fmt.Println("A:", i)
    }
}
func b() {
    for i := 1; i < 10; i++ {
        fmt.Println("B:", i)
    }
}
func main() {
    runtime.GOMAXPROCS(1)//设置本次程序只用一个cpu核
    go a()
    go b()//除去main()这里一共有两个goroutine,因此只能并发执行(执行完一个再执行另一个),若设置两个cpu核则可以并行执行
    time.Sleep(time.Second)
}

9.4.2 runtime.Gosched()

让当前线程让出 cpu核 以让其它线程运行,它不会挂起当前线程,因此当前线程未来会继续执行

func init() {
    runtime.GOMAXPROCS(1)//设置只有一个cpu核
}
func say(s string){
    for i := 0; i < 2; i++ {
        runtime.Gosched()//让出cpu核
        fmt.Println(s)
    }
}
func main() {
    go say("world")
    say("hello")//输出结果是hello world hello,因为是main()先拿到cpu核,当第二个hello被输出,main()就结束了
}

9.4.3 runtime.Goexit()

退出当前 goroutine(但是defer语句会照常执行)

func exit()  {
    defer fmt.Println("BBBBBBBBBB")//在函数退出前执行
    runtime.Goexit()//退出所在子协程
    fmt.Println("CCCCCCCCCCC")
}
​
func main() {
    go func() {
        fmt.Println("AAAAAAAAAAAA")
        exit()
        fmt.Println("DDDDDDDDDDDDD")
    }()
    go func() {
        fmt.Println("zzzzzzzzzzzzz")
    }()
    time.Sleep(time.Second*2)
    fmt.Println("EEEEEEEEEEEEE")
}//输出结果是A z B E,C和D不会被输出

9.5 channel

9.5.1 为什么使用channel

单纯地将函数并行执行是没有意义的,协程与协程间需要交换数据才能体现并行的意义,为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,但这种做法势必造成性能问题。

如果说goroutine是Go程序并行的执行体,channel就是它们之间的连接。*channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。*可以抽象成一个队列,遵循先进先出

线程安全,多 goroutine 访问同一块数据时,不需要加锁,就是说 channel 本身就是线程安全的

如上图所示,使用channel就是通过通信共享内存,加锁就是通过共享内存实现通信

9.5.2 channel的定义

make(chan 元素类型, [缓冲大小])//缓冲大小可以理解成容量cap
var ch chan int = make(chan int)//无缓冲channel

9.5.3 channel的操作

1、发送一个值到channel中

ch <- 10 // 把10发送到ch中

2、从channel接收一个值

x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,忽略结果

3、关闭channel,不是必须的,因为gc会自动回收,不过一旦关闭channel,就不能再往里面发送值,但仍可以取值

close(ch)

4、channel的遍历(也就是接收channel的所有值),推荐使用range

channel 支持 for--range 的方式进行遍历, 请注意两个细节 ​ ①在遍历时, 如果 channel 没有关闭, 则回出现 deadlock 的错误 ​ ②在遍历时, 如果 channel 已经关闭, 则会正常遍历数据, 遍历完后, 就会退出遍历

for i := range ch2 { 
}

9.5.4 无缓冲的channel

ch := make(chan int),想向channel发送值必须要同时有goroutine从channel接收值,不然就会报死锁panic,反之想要接收必须同时有goroutine发送

就像没有快递柜的年代,寄/收快递只能和快递员同步交接

9.5.5 有缓冲的channel

ch := make(chan int, 1),理解成只有一个格子的快递柜

9.5.6 单向channel

todo

9.6 GORoutine池

todo

9.7 select

在某些场景下我们需要同时从多个通道接收数据,select的使用类似于switch语句,它有一系列case分支和一个default分支。每个case会对应一个通道的通信(接收或发送)过程。select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句。

select {
    case <-chan1:
       // 如果chan1成功读到数据,则进行该case处理语句
    case chan2 <- 1:
       // 如果成功向chan2写入数据,则进行该case处理语句
    default:
       // 如果上面都没有成功,则进入default处理流程
}

select可以同时监听一个或多个channel,直到其中一个channel准备好被操作,如果多个channel同时ready,则随机选择一个执行

func main() {
    // 创建2个channel
    intChan := make(chan int, 1)
    stringChan := make(chan string, 1)
    // 开启两个goroutine向两个channel发送数据
    go func() {
        intChan <- 1
    }()
    go func() {
        stringChan <- "hello"
    }()
    
    select {
    case value := <-intChan:
        fmt.Println("int:", value)
    case value := <-stringChan:
        fmt.Println("string:", value)
    }
    //结果是随机执行其中一个case
}

9.8 Sync

9.8.1 sync.WaitGroup

此前为了保证所有goroutine执行完,main才结束,都是使用time.sleep,这样很不方便

因此我们改用sync.WaitGroup,sync.WaitGroup是一个结构体,传递的时候要传递指针。

方法名功能
(wg * WaitGroup) Add(delta int)计数器+delta
(wg *WaitGroup) Done()计数器-1
(wg *WaitGroup) Wait()阻塞直到计数器变为0

sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了N 个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成

var wg sync.WaitGroup
​
func hello() {
    defer wg.Done()
    fmt.Println("Hello Goroutine!")
}
func main() {
    wg.Add(1)
    go hello()
    fmt.Println("main goroutine done!")
    wg.Wait()
}

9.8.2 sync.Once

在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。如下面的例子:

/*
    前提:我们有一个map,key是名字,value是对应的图片。我们一般通过调用Icon()接口来根据名字获得对应的图片,当然,因为不一定每次都调用这个方         法,所以不需要程序一开始执行,就马上初始化这个map,就将这四张图片存入这个map中,因为这样会浪费我们的内存。
    所以我们的目的是:当我们调用这个方法的时候,才初始化这个map,将图片存入map中
    问题:Icon()不是并发安全的,同时被多个goroutine调用的时候可能会导致map初始化错乱
    解决思路:因为初始化map的方法只需要调用一次
    解决方法:使用sync.Once.Do(loadIcons)确保初始化只执行一次且并发安全也不影响效率
*/
var icons map[string]image.Image
​
//初始化将图片添加到map中
func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}
 
//改进前的Icon(),根据名字获取图片
func Icon(name string) image.Image {
    if icons == nil {
        loadIcons()
    }
    return icons[name]
}
​
//改进后的Icon(),根据名字获取图片
var loadIconsOnce sync.Once
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

9.8.3 sync.Map

Go语言中内置的map不是并发安全的

Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。

var m = sync.Map{}
​
func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 20; i++ {
        wg.Add(1)
        go func(n int) {
            key := strconv.Itoa(n)
            m.Store(key, n)
            value, _ := m.Load(key)
            fmt.Printf("k=:%v,v:=%v\n", key, value)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

\

2.8 依赖管理go module

工程项目不可能基于标准库从0开始写,我们可以以sdk的方法引入如框架、driver、日志等的依赖

推荐使用go module的方式:可以类比maven

go.mod可以类比maven的pom文件,写明依赖的库地址和版本号

2.8.1 go.mod配置文件

依赖管理基本单元:模块路径,也就是去哪儿可以找到这个模块,可以是 module github.com/wangkechun/go-by-example

原生库:go的版本号

单元依赖:模块路径+版本号

indirect:指间接依赖。A依赖B,B依赖C,那么A就间接依赖C

版本号:不同的major版本指有大改动,它们之间不一定兼容;minor版本一般是新增函数和功能;patch版本一般是修改bug

2.8.2 proxy依赖代理

proxy:缓存源站的依赖,哪怕我们依赖的一个github仓库被作者删了或者旧版本被作者删了也没问题

GOModule通过配置GOPROXY:= "proxy1.cn, proxy2.cn, direct"来实现proxy,如这样配置了之后,会优先从proxy1寻找依赖,若没有则去proxy2寻找依赖,若也没有则回到源站找依赖,然后缓存到proxy1和2

GOPROXY我配置的是goproxy.cn,七彩牛,是一个中国的模块代理

2.8.3 go get和go mod命令

命令在ide的终端输入

常用的go mod命令:

go mod init //初始化,创建go.mod文件
go mod tidy //根据代码自动增加需要的依赖,删除不需要的依赖
go mod download //下载依赖到本地缓存,默认地址为$GOPATH/pkg/mod

go get命令用来下载依赖,并且可以下载指定版本/提交

go get github.com/kataras/iris@update //下载默认版本
go get -u=patch //将会让依赖升级到最新的修订版本
如果下载所有依赖可以使用go mod download命令

2.8.4 为什么要使用go module

2.8.5 使用go module的步骤

1、set GO111MODULE=on

2、设置GOPROXY=goproxy.cn, direct

3、go mod init [包名] ,如果是初始化项目可以直接 go mod init,生成go.mod 文件

4、go mod tidy ,生成go.sum⽂件,并根据代码自动增加需要的依赖,删除不需要的依赖

2.8.6 go module模式下如何导入本地其他项目的包

参考资料:blog.51cto.com/u_15076215/…

我们启用go mod后,很明显的由GOPATH路径变成了go mod路径,也就是说,之前的基于GOPATH的路径访问方式不管用了,并且go mod不支持相对路径的引入方式,这个时候如果要引入本地其他项目的包,怎么做呢?

解决方法如下:

一般来说我们以go.mod来划分是不是同属一个项目内:也就是说一个项目对应一个go.mod,在ByteDanceCamp这个项目中我是一个Class文件夹就设置一个go.mod,等于说每个Class文件夹都是不同的项目

1、若要调用本地本项目中其他包的方法,直接用就行了,然后goland会自动帮我们在import中导入该方法的路径,如下图,我在demo.go中直接调用common.go的Hello()方法,import就自动帮我导入Common包的路径,当然我们可以在路径"ByteDanceCamp/TestGoModule/Common"前加上自定义名 Common,这样我们就可以用 自定义名.Xxx() 来调用这个包的方法了,如果import报红,那就用一下命令go mod tidy

2、若要调用本地其他项目的包的方法,比如我在TestGoModule1项目的main.go想导入TestGoModule项目的api.go文件,须先配置TestGoModule1的go.mod,配置如下

然后便可在TestGoModule1项目的main.go直接调用TestGoModule项目的api.go文件的Pppp1()了,如果import报红,那就用一下命令go mod tidy

\

7.3 单元测试

7.3.1 单元测试的规则

①测试文件须以_test.go结尾,且与业务文件放在一起

②测试函数的命名须为 func TestXxx(t *testing.T),不符合命名规则的话ide不能在左侧显示运行按钮

③若进行测试之前需要初始化操作(例如打开连接),测试结束后,需要做清理工作(例如关闭连接)等等,推荐使用标准库提供的一个测试方法 func TestMain(m *testing.M)

7.3.2 单元测试代码举例

add.go

package main
func Add(a,b int) int {
    return a+b
}

add_test.go

package main
import(
    "fmt"
    "testing"
)
 
func TestAdd(t *testing.T) {
    r := Add(1, 2)
    if r !=3 {
        t.Errorf("Add(1, 2) failed. Got %d, expected 3.", r)//报测试失败
    }
}
 
func TestMain(m *testing.M) {
    fmt.Println("begin")//测试前要做的工作
    m.Run()//执行测试函数
    fmt.Println("end")//测试后要做的工作
}

7.3.3 测试覆盖率

如上图,在go test后加上 --cover可以得到JudgePassLine()的测试覆盖率为66.7%,原因是该TestJudgePassLine()只测试了JudgePassLine()的if score ≥ 60return true,没有测试到return false。需要再写一个Test方法测试<60的情况

7.4 Mock测试

7.4.1 为什么需要Mock测试

单元测试需要保证稳定性、幂等性。稳定性:每一次测试都是相互隔离的,能在任何时间任何环境进行;幂等性:重复测试运行一段代码结果都该是一致的。但是我们的业务代码往往需要依赖一些本地文件、数据库、cache,这些依赖可能因为本地文件被篡改、连接数据库失败等原因使单元测试不够稳定,因此提出了mock测试

感觉有点像将函数的某些对本地文件、数据库、cache的依赖写死了结果

7.4.2 怎么使用Mock测试

①导入monkey依赖 github.com/bouk/monkey

②在TestXxx函数中使用monkey依赖的 Patch和 Unpatch,而不直接调用要测试的函数

/*
    打桩函数
    target,原来要测试的函数
    replacement,要替换成的函数
*/
func Patch(target, replacement interface{}) *PatchGuard {}
/*
    解除打桩函数,一般在monkey.Patch语句下面写defer monkey.Unpatch
    target,原来要测试的函数
*/
func Unpatch(target interface{}) bool {}

7.5 基准测试

7.5.1 为什么需要基准测试

用来测试代码的性能和对CPU的损耗

7.5.2 基准测试例子

规则:测试函数的命名须为 func BenchmarkXxx(b *testing.B)

一个模拟服务器负载均衡的例子:我们要测试并行地随机选择两个(多个)服务器的性能

loadServer.go

import (
    "math/rand"
)
var ServerIndex [10]int//初始化10个服务器
func InitServerIndex(){
    for i:=0; i<10; i++{
        ServerIndex[i] = i+100
    }
}
​
//随机选择一个服务器
func Select() int {
    return ServerIndex[rand.Intn(10)]
}

loadServer_test.go

//串行地执行Select()
func BenchmarkSelect(b *testing.B){
    InitServerIndex()
    b.ResetTimer()//定时器重置,因为从下面开始才是我们需要测性能的地方
    for i:=0; i<b.N; i++{
        Select()
    }
}
​
//并行地执行Select()
func BenchmarkSelectParallel(b *testing.B){
    InitServerIndex()
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB){
        for pb.Next(){
            Select()
        }
    })
}

两个函数测试的性能如下,红框指执行一次BenchmarkXxx()cpu的耗时,并行的耗时反而大的原因是rand.Intn()在并行执行时为了安全会加全局锁,导致性能下降

将Select()进行优化,改用fastrand

导入依赖:github.com/bytedance/g…

//随机选择一个服务器
func FastSelect() int{
    return ServerIndex[fastrand.Intn(10)]
}

再执行loadServer_test.go,得到测试性能如下

\