Go 进阶
这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天
并发编程
并发介绍
进程和线程
- 进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
- 线程是进程的一个执行实体,是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都结束
}
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("发送成功")
}
无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。
有缓冲的通道
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是一个结构体,传递的时候要传递指针。
依赖管理
- 工程项目不可能基于标准库 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
...
)
出现// 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"
}
测试样例
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
if output != expectOutput {
t.Errorf("Expected % do not match actual %s", expectOutput, output)
}
}
运行单元测试
单元测试 - assert
使用第三方库assert可以更好的编写测试用例
func HelloTom() string {
return "Jerry"
}
测试用例
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}
运行单元测试
单元测试 - 覆盖率
在实际项目中
- 如何衡量代码是否经过了足够的测试?
- 如何评价项目的测试水准?
- 如何评估项目是否达到了高水准测试等级?
答案就是代码覆盖率
方法
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
可以看到代码覆盖率为 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)
}
单元测试 - Tips
- 一般覆盖率:50%~60%,较高覆盖率80%+。
- 测试分支相互独立、全面覆盖
- 测试单元粒度足够小,函数单一职责
单元测试 - 依赖
工程中复杂的项目,一般会有多个依赖,而我们的单测需要保证稳定性和幂等性,稳定是指相互隔离,能在任何时间,任何环境,运行则试。幂等是指每一次测试运行都应该产生与之前一样的结果。而要实现这一目的就要用到Mock机制。
这里我们以monkey作为例子,github.com/bouk/monkey
快速Mock函数
- 为一个函数打桩
- 为一个方法打桩
简单使用,使用Mock
原始函数
打桩
对 ReadFirstLine 打桩测试,不在依赖本地文件
基准测试
GO语言还提供了基准测试框架,基准测试是指测试一殷程序的运行性能及耗费CPU的程度。而我们在实际项目开发中,经常会遥到代码性能瓶颈,为了定位问题经常要对代码做性能分祈,这就用到了基准测试。使用方法类以于单元测试。
原始函数
基准测试
运行测试
- Resttimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围;
- runparallel是多协程并发测试;执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。
而公司为了解决这一随机性能问题,开源了一个高性能随机数方法fastrand,下面有开源地址;我们这边再做一下基准测试,性能提升了百倍。主要的思路是牺牲了一定的数列一致性,在大多数场景是适用的
项目实践
需求描述
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地web服务
- 话题和回帖数据用文件存储
需求用例
浏览消费用户
ER图
分层结构
- 数据层:数据Model,外部数据的增删改查
- 逻辑层:业务Entity,处理核心业务逻辑输出
- 视图层:视图view,处理和外部的交互逻辑
组件工具
go mod init
go get -u github.com/gin-gonic/gin.v1@v1.3.0
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