Go 进阶 | 青训营笔记

20 阅读5分钟

Go 进阶

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

并发编程

并发介绍

进程和线程

  • 进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
  • 线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
  • 一个进程可以创建和撤销多个线程;同一个进程中的多个线程之间可以并发执行。

并发和并行

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

并发

image-20230121154040733.png

并行

image-20230121154049481.png

协程和线程

  • 协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
  • 线程:一个线程上可以跑多个协程,协程是轻量级的线程。

Goroutine

goroutine 只是由官方实现的超级"线程池"。

每个实例4~5KB的栈内存占用和由于实现机制而大幅减少的创建和销毁开销是go高并发的根本原因。

并发主要由切换时间片来实现"同时"运行,并行则是直接利用多核实现多线程的运行,go可以设置使用核数,以发挥多核计算机的能力。

goroutine 奉行通过通信来共享内存,而不是共享内存来通信

Goroutine

在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换。

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

image-20230121154102089.png

  • 协程:用户态,轻量级线程,栈MB级别。
  • 线程:内核态,线程跑多个协程,栈KB级别。

使用Goroutine

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

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

启动多个Goroutine

func hello(i int) {
    defer wg.Done() // goroutine结束就登记-1
    fmt.Println("Hello Goroutine!", i)
}
func main() {

    for i := 0; i < 10; i++ {
        wg.Add(1) // 启动一个goroutine就登记+1
        go hello(i)
    }
    wg.Wait() // 等待所有登记的goroutine都结束
}

Channel

单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。

虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。

Go语言的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。

image-20230121154114213.png

如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。

image-20230121154138761.png

Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。

无缓冲的通道

image-20230116114338640.png

无缓冲的通道又称为阻塞的通道,无缓冲的通道只有在有人接收值的时候才能发送值,如果没有则会出现deadlock错误。

func recv(c chan int) {
    ret := <-c
    fmt.Println("接收成功", ret)
}
func main() {
    ch := make(chan int)
    go recv(ch) // 启用goroutine从通道接收值
    ch <- 10
    fmt.Println("发送成功")
}

无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。

使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。

有缓冲的通道

image-20230117121943822.png

func main() {
    ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
    ch <- 10
    fmt.Println("发送成功")
}

只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。申请多少容量通道中就能缓冲多少个数据。

案例

我们申请一个无缓冲和一个有缓冲的通道

func main() {
    srs := make(chan int)
    dest := make(chan int, 3)
    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 {
        println(i)
    }
}

输出结果

0
1
4
9
16
25
36
49
64
81

并发安全和锁

在Go代码中可能会存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)

下面例子我们通过对x进行2000次+1操作,开启5个协程并发执行,分别使用锁和不使用锁的情况。

package main

import (
	"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)
	println("WithoutLock:", x)
	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	println("WithLock:", x)
}

func main() {
	Add()
}

输出

WithoutLock: 8094
WithLock: 10000

可以看到不加锁的情况下并发不安全,这五个goroutine在访问和修改x变量的时候就会存在数据竞争,导致最后的结果与期待的不符。

Sync

WaitGroup

在代码中生硬的使用time.Sleep肯定是不合适的,Go语言中可以使用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() // 启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
    wg.Wait()
}

需要注意sync.WaitGroup是一个结构体,传递的时候要传递指针。

依赖管理

image-20230121154202417.png

  • 工程项目不可能基于标准库 0~1 编码搭建
  • 管理依赖库

Go 依赖管理演进

image-20230121154211566.png

  • 不同环境(项目)依赖的版本不同
  • 控制依赖库的版本

GOPATH

image-20230117124824493.png

我们使用 go get 会下载最新版本的包到 src 目录下

但是GOPATH也有弊端,无法实现package的多版本控制

如 A 和 B 依赖于某一package的不同版本

image-20230121154230213.png

Go Vendor

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

image-20230121154239828.png

通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package 依赖的冲突问题。

但是Go Vendor也有弊端,无法控制依赖版本和可能会出现依赖冲突,导致编译错误。

image-20230121154248023.png

Go Moudle

  • 通过go.mod文件管理依赖包版本
  • 通过go get/go mod指令工具管理依赖包
  • 定义版本规则和管理项目依赖关系

依赖配置- go.mod

image-20230121154259982.png

依赖标识:[Module Path][Version/Pseudo-version]

依赖配置- version

image-20230121154310232.png

依赖配置- indirect

require (
	github.com/Rican7/retry v0.1.0 // indirect
	github.com/auth0/go-jwt-middleware v0.0.0-20170425171159-5493cabe49f7 // indirect
	github.com/boltdb/bolt v1.3.1 // indirect
	github.com/checkpoint-restore/go-criu v0.0.0-20190109184317-bdb7599cd87b // indirect
	github.com/codegangsta/negroni v1.0.0 // indirect
	...
)

image-20230121154325927.png

出现// indirect的标识则表明改依赖是间接依赖,没有该标识则为直接依赖

依赖配置- incompatible

image-20230121154341722.png

go mod 要求每个module从大版本2开始,模块路径必须有类似 /vN 版本号的后缀,假如module example.com/mod 从 v1.0.0发展到v2.0.0,这时它的go.mod中的模块路径应该修改为 example.com/mod/v2。go mod 认为如果一个module的两个不同版本之间引入路径相同,则它们必须是相互兼容的,而不同的大版本通常意味着是不兼容的,所以引入路径也不该相同,通过在模块路径上加上大版本后缀,这样就可以同时使用同一个模块的多个不同大版本。

对于一些比较老的项目可能当时go mod还没出现,但版本早已经迭代到v2 以上,或者有些项目没有遵循以上的原则,go mod为了能够正常使用它们,会在引入 v2 以上的版本后加上 +incompatible 以示提醒。

依赖分发

image-20230121154409195.png

之前我们通过GOPROXY设置代理,GOPROXY环境变量可以设置多个代理站,使用逗号分开,其中direct为源站

$env:GOPROXY = "https://goproxy.cn,direct"

go get

image-20230121154426037.png

go mod

image-20230121154436584.png

测试

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

image-20230121154725568.png

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

image-20230121154737202.png

单元测试

image-20230121154804772.png

单元测试 - 规则

  • 所有测试文件以_test.go结尾 image-20230121155053646.png
  • 测试方法以 func TestXxx(*testing.T)命名 image-20230121154943159.png
  • 初始化逻辑放到TestMain中 image-20230121154935076.png

单元测试 - 例子

func HelloTom() string {
    return "Jerry"
}

测试样例

func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    if output != expectOutput {
        t.Errorf("Expected % do not match actual %s", expectOutput, output)
    }
}

运行单元测试

image-20230121155422769.png

单元测试 - assert

使用第三方库assert可以更好的编写测试用例

func HelloTom() string {
    return "Jerry"
}

测试用例

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

运行单元测试

image-20230121155620171.png

单元测试 - 覆盖率

在实际项目中

  • 如何衡量代码是否经过了足够的测试?
  • 如何评价项目的测试水准?
  • 如何评估项目是否达到了高水准测试等级?

答案就是代码覆盖率

方法

func JudegePassLine(score int16) bool {
    if score >= 60 {
        return true
    }
    return false
}

测试用例

func TestJudegePassLineTrue(t *testing.T) {
    isPass := JudegePassLine(70)
    assert.Equal(t, true, isPass)
}

执行test并加上--cover属性

go test judgment_test.go judgment.go --cover

image-20230121160017004.png

可以看到代码覆盖率为 66.7% 我们测试用例中并没有命中 return false 这行代码,我们修改一个测试用例使代码覆盖率到 100%

测试用例

func TestJudegePassLineTrue(t *testing.T) {
    isPass := JudegePassLine(70)
    assert.Equal(t, true, isPass)
}

func TestJudegePassLineFail(t *testing.T) {
    isPass := JudegePassLine(50)
    assert.Equal(t, false, isPass)
}

image-20230121160223048.png

单元测试 - Tips

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

单元测试 - 依赖

工程中复杂的项目,一般会有多个依赖,而我们的单测需要保证稳定性和幂等性,稳定是指相互隔离,能在任何时间,任何环境,运行则试。幂等是指每一次测试运行都应该产生与之前一样的结果。而要实现这一目的就要用到Mock机制。

image-20230121160431033.png

这里我们以monkey作为例子,github.com/bouk/monkey

快速Mock函数

  • 为一个函数打桩
  • 为一个方法打桩

image-20230121160859359.png

简单使用,使用Mock

原始函数

image-20230121161629392.png

打桩

image-20230121161728618.png

对 ReadFirstLine 打桩测试,不在依赖本地文件

基准测试

GO语言还提供了基准测试框架,基准测试是指测试一殷程序的运行性能及耗费CPU的程度。而我们在实际项目开发中,经常会遥到代码性能瓶颈,为了定位问题经常要对代码做性能分祈,这就用到了基准测试。使用方法类以于单元测试。

原始函数

image-20230121162128879.png

基准测试

image-20230121162146048.png

运行测试

image-20230121162415319.png

  • Resttimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围;
  • runparallel是多协程并发测试;执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。

而公司为了解决这一随机性能问题,开源了一个高性能随机数方法fastrand,下面有开源地址;我们这边再做一下基准测试,性能提升了百倍。主要的思路是牺牲了一定的数列一致性,在大多数场景是适用的

github.com/bytedance/g…

image-20230121162932721.png

image-20230121162940292.png

项目实践

需求描述

  • 展示话题(标题,文字描述)和回帖列表
  • 暂不考虑前端页面实现,仅仅实现一个本地web服务
  • 话题和回帖数据用文件存储

需求用例

浏览消费用户

image-20230121183831085.png

ER图

image-20230121183859219.png

分层结构

image-20230121183915306.png

  • 数据层:数据Model,外部数据的增删改查
  • 逻辑层:业务Entity,处理核心业务逻辑输出
  • 视图层:视图view,处理和外部的交互逻辑

组件工具

gin

go mod init
go get -u github.com/gin-gonic/gin.v1@v1.3.0

Repository

image-20230121184311488.png

Repository-index

image-20230121184342955.png

image-20230121184352675.png

初始化话题数据索引

image-20230121184419109.png

Repository - 查询

image-20230121184452494.png

Service

实体

image-20230121184520057.png

流程

image-20230121184534548.png

编码

image-20230121184600493.png

话题和回帖并行处理

image-20230121184640781.png

image-20230121184649615.png

Controller

  • 构建View对象
  • 业务错误码

image-20230121184724574.png

Router

  • 初始化数据索引
  • 初始化引擎配置
  • 构建路由
  • 启动服务

image-20230121184802447.png

运行测试

go run server.go

发送请求

curl --location --request GET 'http://0.0.0.0:8080/community/page/get/2' | json