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

并行

# 协程和线程
- 协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
- 线程:一个线程上可以跑多个协程,协程是轻量级的线程。
# Goroutine
goroutine 只是由官方实现的超级 "线程池"。
每个实例 4~5KB 的栈内存占用和由于实现机制而大幅减少的创建和销毁开销是 go 高并发的根本原因。
并发主要由切换时间片来实现 "同时" 运行,并行则是直接利用多核实现多线程的运行,go 可以设置使用核数,以发挥多核计算机的能力。
goroutine 奉行通过通信来共享内存,而不是共享内存来通信。
# Goroutine
在 java/c++ 中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换。
Go 语言中的 goroutine 就是这样一种机制,goroutine 的概念类似于线程,但 goroutine 是由 Go 的运行时(runtime)调度和管理的。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU。Go 语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

- 协程:用户态,轻量级线程,栈 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都结束
}
2
3
4
5
6
7
8
9
10
11
12
# Channel
单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。
虽然可以使用共享内存进行数据交换,但是共享内存在不同的 goroutine 中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。
Go 语言的并发模型是 CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。

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

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

无缓冲的通道又称为阻塞的通道,无缓冲的通道只有在有人接收值的时候才能发送值,如果没有则会出现 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("发送成功")
}
2
3
4
5
6
7
8
9
10
无缓冲通道上的发送操作会阻塞,直到另一个 goroutine 在该通道上执行接收操作,这时值才能发送成功,两个 goroutine 将继续执行。相反,如果接收操作先执行,接收方的 goroutine 将阻塞,直到另一个 goroutine 在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的 goroutine 同步化。因此,无缓冲通道也被称为同步通道。
# 有缓冲的通道

func main() {
ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
ch <- 10
fmt.Println("发送成功")
}
2
3
4
5
只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。申请多少容量通道中就能缓冲多少个数据。
# 案例
我们申请一个无缓冲和一个有缓冲的通道
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)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
输出结果
0
1
4
9
16
25
36
49
64
81
2
3
4
5
6
7
8
9
10
# 并发安全和锁
在 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()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
输出
WithoutLock: 8094
WithLock: 10000
2
可以看到不加锁的情况下并发不安全,这五个 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()
}
2
3
4
5
6
7
8
9
10
11
12
需要注意 sync.WaitGroup 是一个结构体,传递的时候要传递指针。
# 依赖管理

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

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

我们使用 go get 会下载最新版本的包到 src 目录下
但是 GOPATH 也有弊端,无法实现 package 的多版本控制
如 A 和 B 依赖于某一 package 的不同版本

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

通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package 依赖的冲突问题。
但是 Go Vendor 也有弊端,无法控制依赖版本和可能会出现依赖冲突,导致编译错误。

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

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

# 依赖配置 - 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
...
)
2
3
4
5
6
7
8

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

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 以示提醒。
# 依赖分发

之前我们通过 GOPROXY 设置代理,GOPROXY 环境变量可以设置多个代理站,使用逗号分开,其中 direct 为源站
$env:GOPROXY = "https://goproxy.cn,direct"
# go get

# go mod

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

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

# 单元测试

# 单元测试 - 规则
- 所有测试文件以_test.go 结尾

- 测试方法以
func TestXxx(*testing.T)命名
- 初始化逻辑放到 TestMain 中

# 单元测试 - 例子
func HelloTom() string {
return "Jerry"
}
2
3
测试样例
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
if output != expectOutput {
t.Errorf("Expected % do not match actual %s", expectOutput, output)
}
}
2
3
4
5
6
7
运行单元测试

# 单元测试 - assert
使用第三方库 assert 可以更好的编写测试用例
func HelloTom() string {
return "Jerry"
}
2
3
测试用例
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}
2
3
4
5
运行单元测试

# 单元测试 - 覆盖率
在实际项目中
- 如何衡量代码是否经过了足够的测试?
- 如何评价项目的测试水准?
- 如何评估项目是否达到了高水准测试等级?
答案就是代码覆盖率
方法
func JudegePassLine(score int16) bool {
if score >= 60 {
return true
}
return false
}
2
3
4
5
6
测试用例
func TestJudegePassLineTrue(t *testing.T) {
isPass := JudegePassLine(70)
assert.Equal(t, true, isPass)
}
2
3
4
执行 test 并加上 --cover 属性
go test judgment_test.go judgment.go --cover

可以看到代码覆盖率为 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)
}
2
3
4
5
6
7
8
9

# 单元测试 - Tips
- 一般覆盖率:50%~60%,较高覆盖率 80%+。
- 测试分支相互独立、全面覆盖
- 测试单元粒度足够小,函数单一职责
# 单元测试 - 依赖
工程中复杂的项目,一般会有多个依赖,而我们的单测需要保证稳定性和幂等性,稳定是指相互隔离,能在任何时间,任何环境,运行则试。幂等是指每一次测试运行都应该产生与之前一样的结果。而要实现这一目的就要用到 Mock 机制。

这里我们以 monkey 作为例子,https://github.com/bouk/monkey
快速 Mock 函数
- 为一个函数打桩
- 为一个方法打桩

简单使用,使用 Mock
原始函数

打桩

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

基准测试

运行测试

- Resttimer 重置计时器,我们再 reset 之前做了 init 或其他的准备操作,这些操作不应该作为基准测试的范围;
- runparallel 是多协程并发测试;执行 2 个基准测试,发现代码在并发情况下存在劣化,主要原因是 rand 为了保证全局的随机性和并发安全,持有了一把全局锁。
而公司为了解决这一随机性能问题,开源了一个高性能随机数方法 fastrand,下面有开源地址;我们这边再做一下基准测试,性能提升了百倍。主要的思路是牺牲了一定的数列一致性,在大多数场景是适用的
https://github.com/bytedance/gopkg


# 项目实践
# 需求描述
- 展示话题 (标题,文字描述) 和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地 web 服务
- 话题和回帖数据用文件存储
# 需求用例
浏览消费用户

# ER 图

# 分层结构

- 数据层:数据 Model,外部数据的增删改查
- 逻辑层:业务 Entity,处理核心业务逻辑输出
- 视图层:视图 view,处理和外部的交互逻辑
# 组件工具
go mod init
go get -u github.com/gin-gonic/gin.v1@v1.3.0
2
# Repository

# Repository-index


初始化话题数据索引

# Repository - 查询

# Service
实体

流程

编码

话题和回帖并行处理


# Controller
- 构建 View 对象
- 业务错误码

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

# 运行测试
go run server.go
发送请求
curl --location --request GET 'http://0.0.0.0:8080/community/page/get/2' | json