#2 Go语言进阶 | 青训营笔记

118 阅读7分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天
本次编程学习的基本环境配置如下
OS: macOS 13.1
IDE: Goland 2022.3
Go Version: 1.18

重点内容

  1. Goroutine, Channel, WaitGroup, Sync等并发和同步机制
  2. 依赖管理
  3. 单元测试

并发和同步机制

goroutine

  1. 协程是用户态的轻量级线程, 由用户自己维护, 上下文切换成本比线程低得多. goroutine是Go语言的轻量级线程实现, 可以充分发挥多核优势, 高效运行
  2. 使用go关键字可启动一个goroutine,goroutine会执行go关键字后的函数内容
  3. 下列代码展示了一个最简单的并发程序, 没有复杂的同步逻辑, 使用time.Sleep()让主oroutine延时1s以等待其他goroutine执行完成
package main

import (
   "fmt"
   "time"
)

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

func helloGoRoutine() {
   for i := 0; i < 3; i++ {
      // 使用匿名函数 创建 Goroutine
      go func(j int) {
         hello(j)
      }(i)
   }
   // 延时1s, 等待其他Goroutine执行完成
   time.Sleep(time.Second)
}

func main() {
   helloGoRoutine()
}

image.png

Communicating Sequential Processes(CSP)

通信序列程序是著名计算机科学家C.A.R.Hoare为解决并发现象而提出的代数理论,是一个专为描述并发系统中通过消息交换进行交互通信实体行为而设计的一种抽象语言。在该语言中,一个并发系统由若干并行运行的顺序进程组成,每个进程不能对其他进程的变量赋值。进程之间只能通过 一对通信原语实现协作:Q->x表示从进程Q输入一个值到变量x中;P<-e表示把表达式e的值发送给进程P。当P进程执行Q->x, 同时Q进程执行P<-e时,发生通信,e的值从Q进程传送给P进程的变量x。

提倡通过通信共享内存而不是通过共享内存实现通信

image.png

Channel

Channel的使用大致基于以下规则(应该不全)

  1. channel可以理解为Go语言中的通道类型, 用于共享数据和同步
  2. 使用make(chan <type>, <buflen>)创建一个通道, 通过控制make的第二个参数创建无缓冲通道或有缓冲通道.
  3. 使用close(channel)函数关闭channel的写, 此时channel只读
  4. 使用channel<-datachannel写数据data, 当channel被关闭后, 继续写会panic
  5. 使用data := <- channelchannel中获取数据, 当channel被关闭后, 返回类型0
  6. 使用v, ok:= <- channel试图从channel中获取数据, channel被关闭后, ok==false, 该语法可以用来检查通道是否被关闭
  7. 阻塞: 无缓冲的channel只有在receiver准备好后才能执行send操作, 如果有缓存, 并且缓存未满, 则send会被执行. 如果channel==nil, 数据的发送和接收会被一直阻塞
  8. 无缓冲的channel可以用来在goroutine中进行同步, 而不必使用显式的锁或者条件变量
  9. 使用range语法不断的从channel中获取值, 直到channel关闭
  10. channel的select-case语法可以用来处理通信

image.png

简单的生产者-消费者模型

package main

import (
   "fmt"
)

func calSquare() {
   // 创建一个无缓冲通道src, 和一个有缓冲通道dest
   src := make(chan int)
   dest := make(chan int, 3)

   // 一个 Goroutine 向src输入数据
   go func() {
      defer func() {
         close(src)
         fmt.Println("关闭通道src")
      }()

      for i := 0; i < 10; i++ {
         // channel 的 send语法, 将 i 发送到 src
         src <- i
      }
   }()

   // 另一个Goroutine从src中获取数据, 计算后输入到dest
   go func() {
      defer func() {
         close(dest)
         fmt.Println("关闭通道dest")
      }()
      for i := range src {
         dest <- i * i
      }
   }()

   // 主Goroutine从dest中接收数据 并打印
   for i := range dest {
      //time.Sleep(time.Millisecond * 10)
      fmt.Println(i)
   }
}

func main() {
   calSquare()
}

image.png

使用channel做同步

默认情况下,发送和接收会一直阻塞着,直到另一方准备好。这种方式可以用来在gororutine中进行同步,而不必使用显示的锁或者条件变量。下面这个例子展示了这一功能.

package main

import "fmt"

func sum(s []int, c chan int) {
   sum := 0
   for _, v := range s {
      sum += v
   }
   c <- sum // send sum to c
}
func main() {
   s := []int{7, 2, 8, -9, 4, 0}
   c := make(chan int)
   // 两个Goroutine, 分别计算前半部分和后半部分
   go sum(s[:len(s)/2], c)
   go sum(s[len(s)/2:], c)
   // 等待Goroutine计算完成, 下一行才会执行, 得到计算结果
   x, y := <-c, <-c // receive from c
   fmt.Println(x, y, x+y)
}

image.png

select-case

select-case语句选择一组可能的send操作和receive操作去处理。它类似switch,但是只是用来处理通讯(communication)操作。
它的case可以是send语句,也可以是receive语句,亦或者default

receive语句可以将值赋值给一个或者两个变量。它必须是一个receive操作。

最多允许有一个default case,它可以放在case列表的任何位置,尽管我们大部分会将它放在最后。

下面的例子用goroutine计算斐波那契数列

package main

import "fmt"

func fibonacci(c, quit chan int) {
   x, y := 0, 1
   for {
      select {
      // 判定c是否可写
      case c <- x:
         x, y = y, x+y
      // 判定quit是否可读
      case <-quit:
         fmt.Println("quit")
         return
      }
   }
}

func main() {
   c := make(chan int)
   quit := make(chan int)
   go func() {
      for i := 0; i < 10; i++ {
         fmt.Println(<-c)
      }
      // select 退出
      quit <- 0
   }()
   fibonacci(c, quit)
}

image.png

其他例子

在菜鸟教程上, 还给出了处理超时以及定时器的例子

并发安全Lock

  1. 可以用sync.Mutex声明锁, 使用lock.Lock()加锁, 拿不到锁就会阻塞, 使用lock.Unlock解锁. 使用lock.TryLock()尝试获取锁, 返回一个布尔值, 可用来判断锁能否获取.
  2. addTest()分成两部分, 每部分都开5个goroutine, 每个goroutine都尝试将全局变量x加12000次, 只是第一个goroutine在修改变量x前加了锁, 在修改完后释放锁. goroutine执行完成后, 通过通道flag通知主goroutine任务完成.
package main

import (
   "fmt"
   "sync"
)

var (
   x    int64
   lock sync.Mutex
)

func addTest() {
   finished := make(chan int)
   // 带锁
   x = 0
   for i := 0; i < 5; i++ {
      go func(flag chan int) {
         defer func() {
            flag <- 1
         }()
         for i := 0; i < 2000; i++ {
            lock.Lock()
            x += 1
            lock.Unlock()
         }
         // 完成之后向通道写值
      }(finished)
   }

   for i := 0; i < 5; i++ {
      // 等待执行完成
      <-finished
   }
   fmt.Println("with lock: ", x)

   x = 0
   // 同步
   for i := 0; i < 5; i++ {
      go func(flag chan int) {
         defer func() {
            flag <- 1
         }()
         // 一般情况下得不到正确结果
         for i := 0; i < 2000; i++ {
            x += 1
         }
         // 完成之后向通道写值
      }(finished)
   }
   for i := 0; i < 5; i++ {
      <-finished
   }
   fmt.Println("without lock: ", x)
}

func main() {
   addTest()
}
  1. 分析: 不加锁会导致运行结果错误, 加锁则不会. 运行结果如下所示

image.png

WaitGroup

  1. Go语言中除了可以使用通道(channel)和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步,等待组可以保证在并发环境中完成指定数量的任务.
  2. 常用方法如下. (摘抄自 c.biancheng.net/view/108.ht…)
结构体方法名功能
(wg * WaitGroup) Add(delta int)等待组的计数器 +1
(wg * WaitGroup) Done()等待组的计数器 -1
(wg * WaitGroup) Wait()当等待组计数器不等于 0 时阻塞直到变 0
  1. 下面这个例子启动了5个goroutine去打印, 主goroutine使用WaitGroup而不是time.Sleep或者channel去同步
package main

import (
   "fmt"
   "sync"
)

func main() {
   // 使用WaitGroup完成多个Goroutine的同步
   wg := sync.WaitGroup{}
   // 设置任务数
   routineNum := 5
   wg.Add(routineNum)
   // 启动goroutine, 每个Goroutine结束后使用Done()方法报告任务完成
   for i := 0; i < routineNum; i++ {
      go func(j int) {
         defer wg.Done()
         fmt.Println("hello goroutine:", j)
      }(i)
   }
   // 阻塞
   wg.Wait()
}

image.png

Go依赖管理

对于hello world以及类似的单体函数只需要依赖原生SDK,而实际工程会相对复杂,我们不可能基于标准库0~1编码搭建,而更多的关注业务逻辑的实现,而其他的涉及框架、日志、driver、以及collection等一系列依赖都会通过sdk的方式引入,这样对依赖包的管理就显得尤为重要 Go的依赖管理,经历了GOPATH, Go VendorGo Module三个阶段. 目前广泛使用的是Go Module

环境变量:GOPATH

  1. $GOPATH环境变量会指向计算机的某个文件夹下, 用来保存项目的依赖, 项目直接依赖src下的代码, 可通过go get命令下载最新版本的包到src目录下
  2. 由于src下只能有相同包的一个版本存在, 如果有多个项目分别依赖该包的不同版本, 则会出现冲突。要实现package的多版本控制, 那就不能仅仅靠GOPATH

Go Vendor

  1. 在项目目录下增加vendor文件, 其中存放了项目依赖的部分包的副本, 如果某个依赖不存在, 则会再去$GOPATH目录下找, 每个项目引入一份依赖的副本, 解决不同项目对包的不同版本的依赖冲突问题
  2. 但Vendor机制无法解决依赖包的版本变动问题和一个项目依赖一个包的不同版本的问题

Go Module

项目使用go.mod文件管理依赖包版本, 使用go get/go mod指令工具管理依赖包, 终极目标是:定义版本规则和管理项目依赖关系

image.png

依赖管理三要素:

  • 配置文件,用于描述依赖 ==> go.mod
  • 中心仓库管理依赖库 ==> Proxy
  • 本地工具 ==> go get/go mod

版本规则

  1. 语义化版本: ${MAJOR}.${MINOR}.${PATCH}, 如v1.3.0
  2. 基于commit伪版本: v1.0.0-202201161535-10cb98267c6c等

直接依赖和间接依赖

A->B->C中, A->B是直接依赖, A->C是间接依赖, 在go.modrequire块中对间接依赖的包, 需要标注//indirect

依赖分发-Proxy

github是比较常见给的代码托管系统平台,而Go Modules 系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交或版本,这样的话,对于go.mod中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。 但直接使用版本管理仓库下载依赖,存在多个问题,首先无法保证构建确定性:软件作者可以直接代码平台增加/修改/删除 软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本。无法保证依赖可用性:依赖软件作者可以直接代码平台删除软件,导致依赖不可用;大幅增加第三方代码托管平台压力。

image.png

而go proxy就是解决这些问题的方案,Go Proxy 是一个服务站点,它会缓源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供“immutability”和“available”的依赖分发;使用 Go Proxy之后,构建时会直接从 Go Proxy 站点拉取依赖。类比项目中,下游无法满足我们上游的需求

变量GOPROXY

可以通过设置GOPROXY变量设置多个Proxy站点。整体的依赖寻址路径会依次遍历这些站带你, 如果这些站点都没有该包, 则会回源到源站直接下载依赖, 再保存到Proxy站点中

go get 和 go mod

  1. go get <package_path>@...获取包
  2. go mod, init子命令初始化, 创建go.mod文件, download下载模块到本地缓存, tidy增加需要的依赖, 删除不必要的依赖

测试

测试一般分为回归测试, 集成测试以及单元测试三类.

  • 回归测试一般是程序测试员手动通过终端回归一些固定的主流程场景,
  • 集成测试是对系统功能维度做测试验证
  • 单元测试测试开发阶段,开发者对单独的函数、模块做功能验证,层级从上至下,测试成本逐渐减低,而测试覆盖率确逐步上升,所以单元测试的覆盖率一定程度上决定这代码的质量。

单元测试

image.png

单元测试规则

  1. 可以使用go test进行单元测试, 但输入文件(或项目中必须包含文件)必须满足几条规则。
  2. 首先, 该文件必须以_test.go结尾, 文件中包含多个测试函数, 测试函数名以Test开头, 形参列表为(t *testing.T), 初始化逻辑放在TestMain函数中
  3. 通过t.Errorf()输出测试错误.

简单例子

package main

import (
   "testing"
)

func HelloTom() string {
   return "Jerry"
}

func TestHelloTom(t *testing.T) {
   // 运行某函数, 和希望输出的值
   output := HelloTom()
   expectOutput := "Tom"
   // 如果不同, 则报告错误
   if output != expectOutput {
      t.Errorf("Expected %s do not match actual %s", expectOutput, output)
   }
}

image.png

使用assert

使用assert包简化测试

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

func HelloTom() string {
   return "Jerry"
}

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

单元测试覆盖率

以下代码可以衡量代码是否经过了足够的测试,一般测试率在50%~60%, 较高测试率在80%以上. 单元测试要求测试单元粒度足够小, 且函数功能单一

go test <files> --cover

image.png

单元测试-解除依赖

单元测试需要保证稳定性和幂等性,稳定是指相互隔离,能在任何时间,任何环境,运行测试。 幂等是指每一次测试运行都应该产生与之前一样的结果。而要实现这一目的就要用到mock机制(需要)。

  1. Patch replaces a function with another
  2. Unpatch removes any monkey patches on target

image.png

package main

import (
	"github.com/bouk/monkey"
	"bufio"
	"github.com/stretchr/testify/assert"
	"os"
	"strings"
	"testing"
)

func ReadFileLine() string {
	// 打开文件
	f, err := os.Open("log")
	defer func() {
		_ = f.Close()
	}()
	if err != nil {
		return ""
	}
	// 读取一行
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		return scanner.Text()
	}
	return ""
}

func ProcessFirstLine() string {
	line := ReadFileLine()
	destLine := strings.ReplaceAll(line, "11", "00")
	return destLine
}

func TestProcessFirstLine(t *testing.T) {
	firstLine := ProcessFirstLine()
	assert.Equal(t, "line00", firstLine)
}

func TestProcessFirstLineWithMock(t *testing.T) {
	// Patch replaces a function with another
	monkey.Patch(ReadFileLine, func() string {
		return "line110"
	})
	// Unpatch removes any monkey patches on target
	defer monkey.Unpatch(ReadFileLine)
	line := ProcessFirstLine()
	assert.Equal(t, "line000", line)
}

以下结果是在Cloud Studio在线环境中得到的

image.png

好像无法支持Apple M1

image.png

基准测试

Go 语言还提供了基准测试框架,基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。

  1. 文件以_test.go结尾, 函数名以Benchmark开头, 参数为b *testing.B, 可以使用b.ResetTimer()初始化计时器
  2. 在循环中对函数运行速度做测试
package benchmark

import (
   "math/rand"
   "testing"
)

var ServerIndex [10]int

func InitServerIndex() {
   for i := 0; i < 10; i++ {
      ServerIndex[i] = i + 100
   }
}

func Select() int {
   return ServerIndex[rand.Intn(10)]
}

// BenchmarkSelect
func BenchmarkSelect(b *testing.B) {
   InitServerIndex()

   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      Select()
   }
}

func BenchmarkSelectParallel(b *testing.B) {
   InitServerIndex()
   
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      b.RunParallel(func(pb *testing.PB) {
         for pb.Next() {
            Select()
         }
      })
   }
}

执行go test -bench=. -v执行基准测试

image.png

总结

今天学习了Go并发的相关知识和Go模块、Go测试的相关知识. 结合之前操作系统的学习经验以及Python的相关编程经验, 总体来说难度不大.

最后还有一个实践项目, 没有写总结。

引用

  1. 课程Go进阶1-2:juejin.cn/course/byte…
  2. 课程Go进阶3-4:juejin.cn/course/byte…
  3. 菜鸟Go Channel 详解: www.runoob.com/w3cnote/go-…