Go语言进阶(并发&依赖管理&benchmark)| 青训营

Go语言进阶(并发&依赖管理&benchmark)

前置

笔者环境

  • macos 10.15.7
  • Golang 1.18
  • GoLand 2022.01

读完本文可以获得

  • 并发编程的概念,并行与并发的区别
  • Goroutine和CSP的用法
  • channel的使用场景
  • 并发安全的实现方法
  • 测试的分类与方法

一、并发编程

1.1 并行和并发

并发

多线程程序在一个核心的CPU上运行(多个任务在1个核心的CPU上交替执行,在CPU单位时间内,只有一个任务在运行)

Screen Shot 2023-07-28 at 1.56.46 PM.png

并行

  • 多线程程序在多个核心的CPU上运行(多个任务在多个核心的CPU上执行,每个核心的CPU上执行的多任务即是并发)
  • 并行是实现并发的一种手段

Screen Shot 2023-07-28 at 1.59.30 PM.png

1.2 Goroutine

1.2.1 介绍

Goroutine是Go语言中的协程,一种轻量级的线程,由Go语言的运行时管理,可以实现高并发的程序设计。

Screen Shot 2023-07-28 at 2.12.40 PM.png

1.2.2 协程与线程的区别

差异点 协程 线程
语言 Go语言的特性 多语言都支持线程
调度方式 由Go运行时管理 由操作系统内核管理
切换效率 无需切换,在用户态运行 用户态切换到内核态
内存 KB级别 MB级别
数量 一个程序可以增长到上百万个协程 线程数一般不能那么多
通信 通过channel等可以直接通信 线程间通信更复杂
使用方式 go关键字可以启动 需要调用thread API创建

1.2.3 Goroutine使用

语法

go func() {
  // 并发执行的函数 
}()

使用多个协程打印hello

package main














import (




   "fmt"

   "time"
)







func hello(i int) {
   fmt.Println("hello goroutine : " + fmt.Sprint(i))
}




func HelloGoRoutine() {
   for i := 0; i < 5; i++ {
      go func(j int) {
         hello(j)
      }(i)
   }
   time.Sleep(time.Second)
}

func main() {
   HelloGoRoutine()
}

运行结果是乱序的表示是并行运行的

hello goroutine : 4
hello goroutine : 2
hello goroutine : 1
hello goroutine : 3
hello goroutine : 0

1.3 CSP

1.3.1 介绍

CSP(Communicating Sequential Processes)通信顺序进程,是Go语言中重要的并发模型。

主要特征有

  1. 顺序进程:每个进程内部按顺序执行。

  2. 通信进程:进程间通过通信(Message Passing)来协作。

  3. 数据流:程序通过在进程间传递数据来工作。

Go语言的CSP实现主要通过goroutine和channel

  • goroutine作为顺序执行的进程
  • channel用于goroutine间的通信

1.3.2 共享内存

Screen Shot 2023-07-28 at 2.34.23 PM.png

假设有两个goroutine,一个负责统计数词频,一个负责记录最高频的数。

使用共享内存的方式

var frequencies map[int]int //共享内存










go func() {

  //统计频次

  for {
    //读写frequencies map
  } 
}()

go func() {
  //记录最高频数
  for {  
    //读取frequencies map
  }
}()

两个goroutine直接读写同一份内存frequencies map,这是共享内存的方式,可能会出现资源竞争问题。

使用CSP通信的方式

ch := make(chan map[int]int) //通信channel










go func() {

  //统计频次

  frequencies := make(map[int]int)
  
  //通过channel发送frequencies
  ch <- frequencies
}() 

go func() {
  //记录最高频数
  
  //从channel接收frequencies
  frequencies := <- ch
}()

两个goroutine之间通过channel传递frequencies,相当于“共享”了数据,但实际上是复制而不是直接共享内存。

相比通过共享内存来实现通,,CSP通过通信来实现共享内存,避免了竞争风险。

1.4 Channel

1.4.1 介绍

Go语言中的channel是实现goroutine之间通信的重要方式。

  • channel类型

    chan 用于定义,标识传输的数据类型。

    ch := make(chan 数据类型,[缓冲大小])
    
  • 发送接收数据

    ch <- 10 // 发送 
    x := <- ch // 接收
    
  • 关闭channel

    close(ch)
    
  • 遍历channel

    for x := range ch {
      fmt.Println(x) 
    }
    
    
    
    
  • select可以监听channel

    通过select,我们可以方便地处理多个channel的IO操作。

    ch1 := make(chan int)
    ch2 := make(chan int)
    
    select {
    case <-ch1:
      // 收到ch1的数据
    case <-ch2: 
      // 收到ch2的数据  
    default:
      // 都未收到  
    }
    
    
    • select可以同时监听一个或多个channel,当某个channel ready时,就会执行对应的case代码块。
    • 它提供了一个可以同时响应多个channel的机制。
    • 默认的default分支可以在都未ready时执行。

1.4.2 有缓冲VS无缓冲

无缓冲channel 有缓冲channel
创建 make(chan Type) make(chan Type, Capacity)
发送 阻塞,直到接收准备好 若缓冲区未满,不阻塞
接收 阻塞,直到有数据发送 若缓冲区不为空,不阻塞
同步方式 强同步(完全对齐发送和接收) 弱同步(允许一定时间的不对齐)
使用场景 需强制同步时,保证实时性 容许短暂不对齐,换取高效率

总结:

  • 无缓冲channel强制同步
  • 有缓冲channel允许短暂异步,更灵活

1.4.3 生产消费模型

package main














import "fmt"



func CalSquare() {
   src := make(chan int)     //   生产无缓冲
   dest := make(chan int, 3) //   消费缓冲3个元素
   
   // 子协程发送0-9数字
   go func() {
      defer close(src)
      for i := 0; i < 10; i++ {
         src <- i
      }
   }()

   // 子协程计算输入数字的平方
   go func() {
      defer close(dest)
      for i := range src {
         dest <- i * i
      }
   }()


   // 主协程输出最后的平方数
   for i := range dest {
      // 复杂操作
      // ...
      fmt.Println(i)
   }

}


func main() {
   CalSquare()
}

运行结果都是顺序的,保证了线程安全,符合CSP并发模型。

0
1
4
9
16
25
36
49
64
81



1.4 并发安全 Lock

1.4.1 介绍

Go语言实现并发时需要处理好同步访问共享资源的问题,防止数据竞争。 Go提供了两种线程安全的锁机制:

  1. sync.Mutex

互斥锁,确保同时只有一个goroutine可以访问共享数据。

var mu sync.Mutex
mu.Lock()
// 访问共享资源
mu.Unlock() 
  1. sync.RWMutex

读写互斥锁,可以同时允许多个读,但写时独占。读写互斥锁实现了读写分离,相比普通的互斥锁可以提供更高的并发性能。

var mu sync.RWMutex










mu.RLock()
// 读共享资源
mu.RUnlock()

mu.Lock()
// 写共享资源
mu.Unlock()

使用这些锁机制可以确保同步访问共享资源,实现并发安全。

需要注意的是:

  • 锁粒度控制,不要锁定过多代码
  • 避免死锁,锁的顺序要一致
  • 优先选择读写锁,提高效率

合理利用锁机制,可以构建高效健壮的 Go 并发程序。

1.4.2 sync.Mutex

对变量执行2000次+1的操作,5个协程并发执行,通过对共享资源(临界资源)加互斥锁,确保同一时间只有一个协程可以访问。

package main














import (




	"fmt"
	"sync"
	"time"
)

var (
	x    int64
	lock sync.Mutex
)


func addWithLock() {
	for i := 0; i < 2000; i++ {
		lock.Lock()
		x += 1
		lock.Unlock()
	}
}

func addWithoutLock() {
	for i := 0; i < 2000; i++ {
		x += 1
	}
}

func add() {
	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 main() {
	add()

}

运行结果显示,5次运行后,不加锁的结果是未知的,也表示线程不安全

withoutLock: 7417
withLock: 10000

1.4.3 sync.RWMutex

5个协程分别并发地读取和增加count(共享资源),通过对count的读取加rwmu.RLock()锁,对count的写加rwmu.Lock()锁。确保同时多个goroutine可以读取count,但增加count时会互斥,只有一个goroutine可以写入。

package main














import (




	"sync"
	"time"
)







var (
	count int64
	lock  sync.RWMutex
)

func getCount() int64 {
	lock.RLock()
	defer lock.RUnlock()
	return count
}

func increaseCount() {
	lock.Lock()
	defer lock.Unlock()
	count++
}



func main() {
	for i := 0; i < 5; i++ {
		go getCount()
	}

	for i := 0; i < 5; i++ {

		go increaseCount()
	}



	time.Sleep(time.Second)
	println(count) // 5
}


1.5 WaitGroup

1.5.1 介绍

sync.WaitGroup是Go语言中的一个常用同步工具,可以用于等待一组goroutine结束。主要的使用方式是

  1. 创建一个WaitGroup,通常以参数传入函数
var wg sync.WaitGroup










func doWork(wg sync.WaitGroup) {



}

  1. 在启动goroutine前调用Add添加计数
wg.Add(1)
go do()
  1. 在goroutine结束时调用Done减少计数
func do() {
  defer wg.Done()
}



  1. 等待goroutine结束,调用Wait阻塞
wg.Wait()
Wait会阻塞直到计数器减为0。

这样就可以实现等待一组goroutine结束。

WaitGroup内部存放了一个计数器

  1. 开启协程—>计数器+1
  2. 协程执行结束—>计数器-1
  3. 主协程阻塞到计数器为0

WaitGroup非常实用,可以避免复杂的同步逻辑。它优雅地处理了同步问题。

1.5.2 Demo

启动了3个goroutine,并通过WaitGroup等待它们完成。

package main














import (




   "fmt"

   "sync"
)







func process(i int, wg *sync.WaitGroup) {
   fmt.Println("Start goroutine", i)
   defer wg.Done()
   fmt.Printf("End goroutine%d\n", i)
}


func main() {

   var wg sync.WaitGroup

   for i := 1; i <= 3; i++ {
      wg.Add(1)
      go process(i, &wg)
   }

   wg.Wait()
   fmt.Println("All goroutines finished executing")
}

二、依赖管理

2.1 Go 依赖管理演进

Screen Shot 2023-07-28 at 4.11.48 PM.png

2.1.1 GOPATH

GOPATH是你的代码仓库所在的位置。go tool会在这个路径下查找和安装代码包。

GOPATH路径下有三个目录

  • bin 项目编译的二进制文件
  • pkg 项目编译的中间产物,加速编译
  • src 项目源码

弊端

无法实现package的多版本控制

Screen Shot 2023-07-28 at 4.16.14 PM.png

在GOPATH下,所有的package都是安装到一个全局的位置,比如:

/home/user/go/pkg/

这意味着,对于一个package,只能有一个版本存在。

但是在实际项目中,往往需要依赖不同版本的package,比如v1和v2。

GOPATH无法处理这种情况,会导致冲突。如果安装v2,v1的代码就会被覆盖掉。

2.1.2 Go Vendor

  • 项目目录下增加vendor文件,所有的依赖包副本形式放在$ProjectRoot/vendor
  • 依赖寻址方式:vendor => GOPATH

Screen Shot 2023-07-28 at 4.22.56 PM.png

弊端

Screen Shot 2023-07-28 at 4.38.26 PM.png

  1. 依赖冗余

    vendor会把所有依赖都拷贝一份,造成大量冗余。

  2. 一致性问题

    主项目依赖升级,vendor下的依赖可能没有同步升级。

  3. 维护困难

    依赖关系隐藏在vendor中,理解和维护都更困难。

2.1.3 Go Module

Go Module是Go 1.11版本以后官方推出的依赖管理系统,通过go.mod文件管理依赖包版本,通过go get/go mod指令工具管理依赖包。(类似于Java里的maven)

有以下主要特征

  1. 基于语义导入版本号,如 v1.2.3
  2. 每个模块有自己的模块路径(module path)
  3. go.mod文件声明依赖关系
  4. go.sum记录依赖树hash
  5. 依赖存储在$GOPATH/pkg/mod中
  6. 不需要设置GOPATH
  7. 支持版本选择、升级等
  8. 提供诸如go mod tidy等命令
  9. 允许项目自由布局

相比GOPATH,Go Module提供了更可靠的依赖管理,解决了版本、项目布局等问题。

通过go mod init创建module,然后go get添加依赖,可以很便捷地使用modules。

2.3 Go Module实践

2.3.1 依赖管理三要素

  1. 配置文件,描述依赖 go.mod
  2. 中心仓库管理依赖 go Proxy
  3. 本地工具 go get/ mod

2.3.2 依赖配置 – go.mod

Screen Shot 2023-07-28 at 4.40.23 PM.png

  • indirect

    在go mod列表依赖时,会显示direct和indirect两种依赖

    • direct依赖表示被项目直接导入的依赖

    • indirect依赖表示被依赖间接导入的依赖

    举个例子:

    项目
    |- 直接导入 pkgA 
    |- pkgA 
      |- 导入 pkgB
    

    那么:

    • pkgA 就是direct依赖

    • pkgB 是indirect依赖

    indirect依赖的特点是

    • 不是被项目直接导入的,是被依赖导入的

    • 是传递性的依赖

    • 版本是由直接依赖决定的

    • 项目并不直接引用这个包

    所以indirect依赖对项目来说是不可见的。它们存在于依赖树中,但项目没直接引用过。

    Go module会标记出这种被依赖的传递性依赖,以了解完整的依赖关系。

  • +incompatible

    在Go模块版本号中,+incompatible表示该版本与之前版本不兼容,是一个重要的版本参考信息。

    例如

    v1.2.3
    v1.3.0+incompatible
    

    这里v1.2.3是正常的兼容版本。但在v1.3.0加了+incompatible标记。

    这表示,相对于v1.2.3,在v1.3.0版本中,做了一些破坏兼容性的变更。导致v1.3.0与v1.2.3不再能够完全兼容。

    如果一个项目原来依赖v1.2.3,现在直接升级到v1.3.0,就可能会编译错误或者运行时错误。

    比如接口签名变化,导出类型调整等情况下,之前编译通过的代码,在v1.3.0就可能会报错。

    这就是+incompatible的含义,表示与上一个正常版本相比,此版本做了不兼容的更改。不再保证编译运行的一致性。

    那么为什么需要指出+incompatible呢?

    • Go模块的版本通常遵循语义化版本控制规范

    • 根据语义化版本,v1.x.x只有v1不同时可以兼容

    • 但是语义化版本并非强制要求,所以有时v1.3.0可能与v1.2.3就不兼容了

    • 这时加上+incompatible可以明确表示不兼容变更

    也就是说,+incompatible是一种向用户清楚表示不兼容变更的信号。

    这样用户可以根据+incompatible信息来决定是否更新依赖版本。

2.3.3 依赖分发 – go proxy

Screen Shot 2023-07-28 at 4.57.01 PM.png

  • GOPROXY是一个环境变量,它用来指定Go模块的代理服务器。

  • 当你使用go命令获取、编译或运行依赖的模块时,Go会通过GOPROXY指定的代理服务器来下载模块,而不是直接从源码仓库下载。这样可以加速模块的获取,也可以避免一些网络问题或源码仓库的限制。

  • GOPROXY=”proxy1.cn, proxy2.cn, direct”

    • 其中direct表示源站

    • 先去proxy1.cn中获取,如果没有再到proxy2.cn中获取,如果也没有,最后到direct中获取

      Screen Shot 2023-07-28 at 5.04.37 PM.png

2.3.4 工具 – go get

Screen Shot 2023-07-28 at 5.05.48 PM.png

go get是Go模块机制下的依赖管理命令,主要功能是获取远程包的依赖。

go get命令

  • 下载包的源码
  • 解压到GOPATH下
  • 解析并下载依赖
  • 缓存各版本的依赖
  • 会更新go.mod文件

基本使用格式

go get <依赖路径>

例如

go get github.com/gin-gonic/gin

这会获取gin包及其依赖,并更新go.mod。

go get常用可选参数

  • -u 更新已有依赖
  • -t 仅下载不更新go.mod
  • -d 只下载不安装

go get实现了模块化下的自动化依赖获取和版本管理。称得上是go模块的“管家”。

2.3.5 工具 – go mod

Screen Shot 2023-07-28 at 5.07.10 PM.png

go mod是Go模块的依赖管理命令,是Go 1.11以后依赖管理的主要方式。

go mod提供了丰富的子命令,可以管理整个模块依赖的生命周期,主要包含:

  1. init 初始化一个新模块

  2. tidy 整理依赖

  3. graph 打印模块依赖图

  4. vendor 把依赖复制到vendor目录

  5. edit 编辑go.mod文件

  6. why 解释为何需要依赖

  7. download 下载依赖的源码,会使用go get 下载

  8. verify 验证依赖没有被篡改

通过go mod命令,可以便捷地进行依赖解析、升级、整理、查看等操作。

三、测试

Screen Shot 2023-07-28 at 5.24.08 PM.png

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

测试分类
Screen Shot 2023-07-28 at 6.37.15 PM.png

从上倒下,覆盖率逐层变大,成本却逐层降低

3.1 单元测试

3.1.1 介绍

Screen Shot 2023-07-28 at 5.30.25 PM.png

Go语言内置有对单元测试的支持,主要通过testing包实现。

3.1.2 单元测试规则

Screen Shot 2023-07-28 at 5.32.35 PM.png

例如

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






func TestAdd(t *testing.T) {
  output:=Add(1, 2)
  expectOutput:=3
  if output != expectOutput{
    t.Error("Add failed")
  }
}

测试函数名TestAdd告诉go test这是一个测试。

运行 go test xxx.go,通过t.Error可标记测试失败。

3.1.3 assert

Go语言内置的testing包提供了assert包来进行单元测试的断言,assert能大大简化测试代码,不需要过多的if条件判断,只要最终结果不符合断言,就会自动标记为测试失败。

package Test










import (




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







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




func TestAdd(t *testing.T) {
   output := Add(1, 2)
   expectOutput := 3
   assert.Equal(t, expectOutput, output)
}

3.1.4 覆盖率

  • 当一个程序有多个分支时,就会产生多种测试案例,如果所有的测试案例都符合结果,那覆盖率就是100%。

  • 运行 go test xxx.go –cover,可以查看测试的覆盖率。

  • 一般覆盖率在50%~60%,较高覆盖率在80%+

  • 测试分支相互独立,全面覆盖

3.1.5 依赖

Screen Shot 2023-07-28 at 6.38.58 PM.png

  • 幂等:多次运行同一个测试,结果是一样的

  • 稳定:单元测试之间相互隔离,单元测试在任何时间任何环境都可以运行

    如果你测试的是一个文件功能,如果文件之后被删除,那这个单元测试就会失败,那么就说明这个测试依赖这个文件

3.2 Mock测试

Mock在Go语言单元测试中是一种非常有用的技术,主要用于模拟服务或依赖的行为。从而进行隔离测试。

monkey是一个开源的mock测试库,可以对函数或实例的参数进行mock。

打桩

为函数打桩(function stubbing)指对一个函数进行模拟替换,主要有以下几种方式:

  1. 空函数实现

函数体留空,直接返回

func ReadFile() string {

  // 留空
} 
  1. 硬编码返回值

返回预置的固定值

func ReadFile() string {

  return "hello world" 
}



  1. 自定义行为

根据输入返回不同值

func ReadFile(file string) string {
  if file == "a.txt" {
    return "aaa"
  } else {
    return "bbb"
  }
}
  1. 使用存根对象

提前实现一个存根,存根实现假数据和行为。

打桩主要目的是隔离被测代码,注入假数据来验证代码逻辑,而非真实环境。

3.3 基准测试

Go语言通过testing包内置了对基准测试的支持,主要步骤是

  1. 基准测试用例文件名以_test.go结尾

  2. 基准测试函数名以Benchmark开头

  3. 使用*testing.B作为参数

  4. 在循环体内调用需要测试的代码

  5. 运行go test -bench来执行

例如

func BenchmarkFoo(b *testing.B) {
  for i := 0; i < b.N; i++ {
    // 测试代码 
  }
}

b.N表示循环次数,由基准测试框架决定。

基准测试优点

  • 可以测试代码性能
  • 可以进行优化对比
  • 可以裁剪程序的低效区域

基准测试十分适合优化关键路径、评测不同算法等场景。

四、总结

通过学习本文,可以了解到Go语言中实现高效并发的方式,包括Goroutine、CSP模型、Channel的使用。同时掌握依赖管理的工具和演进过程,以及Go语言自带的测试工具,能够编写单元测试、Mock测试、基准测试等,测试代码的正确性、性能指标等。Go语言内置的并发原语和测试工具,可以帮助我们构建稳定、高效的后端服务。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYbFZaLR' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片