Go并发相关知识学习笔记 | 青训营
- 青训营第二次课笔记
并发编程视角下的go语言
并发与并行
狭义的并发与并行
- 并发主要是指多线程程序在一个核心的cpu上运行,主要通过时间片的切换来实现
- 并行主要是指多线程程序在多个核心的cpu上运行,主要通过一个核心负责一个程序来实现
广义的并发与并行
- 广义的并发是指系统对外的一种特征和能力
- 并行可以理解是实现并发的一种手段
go语言的并发
- 实现了并发性能极高的调度模型
- 可以最大限度发挥多核cpu的性能
go语言并发的运行机制Goroutine
协程与线程
- 协程是用户态,轻量级线程,栈大小KB级别,是go实现并发的重要概念
- 线程是系统态,线程中运行多个协程,栈大小MB级别
- 线程的调度:创建、切换、停止,操作比较消耗系统资源的
- 线程的调度:是由go语言本身来完成,一次可以创建上万个协程
go语言创建协程实现并发的案例
使用协程的案例:快速打印hello goruntine
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 10; i++ {
go fmt.Println("hello goruntine", i) // 创建协程来执行
}
time.Sleep(2 * time.Second) // 防止线程提前结束(后续可以用更好的方式来实现)
}
/*
运行结果
hello goruntine 9
hello goruntine 0
hello goruntine 1
hello goruntine 2
hello goruntine 3
hello goruntine 4
hello goruntine 5
hello goruntine 6
hello goruntine 7
hello goruntine 8
*/
协程间的通信
两种通信方式
- 协程间通信可以通过通信通道(CSP-Communicating Sequential Processes)来实现,类似一个消息队列
- 也可以通过内存的临界区来实现,就是一片共享的内存,需要对它加锁
- go提倡通过通信通道(CSP)来实现通信,而不是通过共享内存来实现
通过通信通道实现协程间通信
通信通道Channel
-
使用channel能保证传递的顺序,是并发安全的
-
分类:
- 无缓冲通道(也叫同步通道)
- 只有等消费数据方消费完数据,生产数据方才能把数据放入缓存区
- 有缓冲通道
- 如果生产数据比较快,消费数据比较慢,生产者就可以直接把数据放入缓冲队列,不必等待消费者消费完数据
- 无缓冲通道(也叫同步通道)
-
创建语法
channel := make(chan 类型[,缓存区大小]) channelA := make(chan int) channelB := make(chan int,2) -
channel多线程通信案例
package main
import "fmt"
func main() {
srcA := make(chan int) // 无缓冲的通道
resB := make(chan int, 3) // 有缓冲的通道
// 协程A 生成数据
go func() {
defer close(srcA) // 函数执行完后关闭通道A
for i := 1; i < 10; i++ {
srcA <- i // 向通道A中写入数据
}
}()
// 协程B 处理数据
go func() {
defer close(resB) // 函数执行完后关闭通道B
for i := range srcA { // 从通道A中读取数据,直到通道被关闭
resB <- i * i // 将结果写入通道B
}
}()
// 主协程 消费数据
for k := range resB { // 从通道B中读取数据,直到通道关闭
fmt.Println(k)
}
}
通过共享内存实现协程间通信
线程通信:
多线程的两个程序要操作同一块内存必须保证线程安全,需要通过锁来实现
go语言协程通信
在go语言的协程通信中,同样支持共享内存来实现协程通信,也存在锁的概念
package main
import (
"fmt"
"sync"
"time"
)
var x int64 = 0 // x
var xLocker sync.Mutex // x的锁
func addWithLock() { // 带锁的自增
xLocker.Lock() // 上锁
x++
xLocker.Unlock() // 解锁
}
func addWithOutLock() { // 不带锁的自增
x++
}
func main() {
x = 0
for i := 0; i < 10000; i++ {
go addWithLock()
}
time.Sleep(2 * time.Second)
fmt.Println(x) // 10000
x = 0
for i := 0; i < 10000; i++ {
go addWithOutLock()
}
time.Sleep(2 * time.Second)
fmt.Println(x) // 9748
}
Go语言的并发控制:WaitGroup
目测WaitGroup本质应该就是带锁的线程安全的计数器
- Add(x) 就是带锁的
cnt+=x- Done() 就是带锁的
cnt--- Wait() 就是
while(cnt!=0);
WaitGroup的三个公开方法
.Add(10) // 设置其内部计数器为10.Done() // 每执行一次,计数器都自减.Wait() // 阻塞,直到计数器为0
package main
import (
"fmt"
"sync"
)
func main() {
var flag sync.WaitGroup
flag.Add(10) // 设置其内部计数器为10
for i := 0; i < 10; i++ {
go func(idx int) { // 创建协程来执行
fmt.Println("hello goruntine", idx)
flag.Done() // 每执行一次,计数器都自减
}(i)
}
flag.Wait() // 阻塞,直到计数器为0
}
/*
运行结果
hello goruntine 9
hello goruntine 3
hello goruntine 1
hello goruntine 0
hello goruntine 2
hello goruntine 6
hello goruntine 7
hello goruntine 4
hello goruntine 8
hello goruntine 5
*/
依赖管理
背景
- 如果一个项目是根据go的标准库从0到1来开发的,那么其不需要依赖管理
- 实际的工程项目中,存在依赖第三方项目的情况,那么就要对这些依赖管理
Go的依赖管理方案
GOPATH
什么是GOPATH
- GOPATH是Go语言支持的环境变量 是一个目录
- 使用
go get A下载的第三方库的源代码(不区分版本)将存放在$GOPATH\src目录下 - 开发者使用
import "A"引入第三方依赖时,会到$GOPATH\src中去寻找
GOPATH缺点
- 无法控制依赖项的版本,因为开发者的项目可能要依赖某个第三方包的特定版本
GO Vendor
什么是GO Vendor
- GO vendor是项目目录下的一个文件夹,存放项目依赖的包的副本,类似于
node_modules文件夹 - go语言寻找依赖的顺序为 vendor => GOPATH
- 解决了要依赖第三方包特定版本的问题
GO Vendor缺点
- 无法解决依赖项所依赖包的版本冲突问题(间接依赖项的冲突)
- 原因就是vendor文件夹里的项目文件依然是不区分版本的
GO Module
什么是GO Module
- go官方从1.1开始实验性引入的依赖管理系统,从1.6开始作为默认依赖管理工具
- 通过go.mod文件来管理依赖包的版本
- 通过go get/go mod命令管理依赖
- 实现了定义版本规则和依赖关系的管理
依赖管理三要素
- 配置文件,描述依赖 go.mod
- 中心仓库,保存依赖 proxy.cn
- 本地工具,管理依赖 go get/mod
go.mod文件
- 用于描述项目的依赖
- 依赖项的版本描述
- 语义化版本描述
${大版本,前后不保证兼容}.${小版本,保证大版本下前后兼容}.${补丁版本} - 基于git-commit的伪版本描述
v0.0.0 yyyymmddhhmmss-hashhashhashhash // indirect表示间接依赖- 如果依赖项的主版本号是2以上,会在模块路径下增加
/vN后缀 - 如果依赖项的主版本号是2以上,且没有
go.mod文件,会增加+incompatible表示可能不兼容
- 语义化版本描述
题目
解析:
B,因为go的算法会选择大家都兼容的最低版本GOPROXY
- go的第三方库一般是存放在代码托管平台的,但是代码托管平台的代码是不稳定的,第三方库的作者拥有随意增删改其代码的权利,将导致依赖项的代码的不稳定。
- goProxy服务器会缓存这些第三方库,能保证第三方库的代码的稳定性,即使第三方库作者将源代码被删除
- GOPROXY是一个环境变量,保存的是URL的列表
- 配置GOPROXY:
go env -w GOPROXY=https://goproxy.cn,https://url1.cn,https://url2.cn,direct- direct表示如果无法从前面的proxy服务器地址获取,就直接访问源站
go get/mod工具
测试
- 单元测试:
- mock测试
- 基准测试
回归测试:回归测试是指修改了旧代码后,重复以前的全部或部分的相同测试以确认修改没有引入新的错误或导致其他代码产生错误
单元测试
什么是单元测试
- 最小设计单元(模块)的验证,确保模块被正确编码,对重要控制路径进行测试以发现模块内错误
go单元测试的编写规则
go测试案例:
hello.go
package main
func hello() string {
return "hello test"
}
hello_test.go
package main
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert" // go get github.com/stretchr/testify/assert
)
func TestMain(m *testing.M) {
fmt.Println("测试开始") // 测试前:初始化阶段
code := m.Run() // 执行测试阶段
fmt.Println("测试结束") // 测试后:释放资源等收尾操作
os.Exit(code)
}
func TestHello_Version1(t *testing.T) {
res := hello()
exp := "hello test"
if res != exp {
t.Errorf("期待值为:%v,实际值为:%v,测试未通过", exp, res)
}
}
func TestHello_Version2(t *testing.T) {
res := hello()
exp := "hello test"
assert.Equal(t, res, exp)
}
执行测试
> go.exe test -timeout 30s -run ^(TestHello_Version1|TestHello_Version2)$ main/_04_Test/_01_unitTest
测试开始
=== RUN TestHello_Version1
--- PASS: TestHello_Version1 (0.00s)
=== RUN TestHello_Version2
--- PASS: TestHello_Version2 (0.00s)
PASS
测试结束
测试覆盖率
- 可以认为是代码被测试的程度的一个指标
- 一般覆盖率为:50~60%
- 80%+为较高覆盖率
- 提高测试覆盖率的技巧
- 对函数的分支分别测试
- 保证单元测试的粒度小,一个单元测试只测试一个函数
- 也就要求一个函数的职责足够小
案例
- 未充分测试:
- 充分测试:
mock测试
测试的要求
- 幂等:重复运行同一个测试,结果应该是一致的。
- 稳定:不同的测试间不应该相互影响。
- 如果单元测试依赖外部资源,就有可能破坏这两个要求
- 比如:
- 如果测试删除了一条数据库的数据,第二次测试再次执行删除操作就会失败,这就破坏了幂等,因为重复测试,但结果却不一样
- 如果测试执行了数据库添加操作,那么另一个测试查询数据库数据条数就将破坏预期,这就会破坏稳定性,因为两个测试相互影响了
monkey
- monkey的Patch() 可以实现将一个函数替换为另一个函数
- 可以用来Mock函数
Mock函数的案例
- 案例中待测试函数A依赖另一个函数B
- 而函数B依赖外部文件的内容,而外部文件的内容可能是变化的
- 这就导致待测试函数A的结果是依赖函数B,从而也是变化的
- 可以通过mock这个函数B,使其返回值是可控的
- 这样函数A的返回值就也是可以确定的了
package main
import (
"bufio"
"os"
"strings"
)
// 获取外部文件的第一行
func getFileFirstLine() string {
file, err := os.Open("./file.txt")
if err != nil {
return ""
}
defer file.Close()
bufScanner := bufio.NewScanner(file)
for bufScanner.Scan() {
return bufScanner.Text()
}
return ""
}
// 函数的功能是把外部文件第一行的中的0 替换为 1
// 函数的执行结果依赖getLogFirstLine(),而该函数又依赖外部文件
func replaceAll0To1() string {
firstLine := getFileFirstLine()
result := strings.ReplaceAll(firstLine, "0", "1")
return result
}
package main
import (
"fmt"
"os"
"testing"
"bou.ke/monkey" // go get bou.ke/monkey
)
func TestMain(m *testing.M) {
code := m.Run()
os.Exit(code)
}
func TestA(t *testing.T) {
firstLine := getFileFirstLine() // 获取外部文件的第一行
fmt.Println(firstLine)
}
func TestB(t *testing.T) {
result := replaceAll0To1() // 函数的执行结果依赖getLogFirstLine(),而该函数又依赖外部文件,预期将是不定的
fmt.Println(result)
}
func TestC(t *testing.T) {
monkey.Patch(getFileFirstLine, func() string { // patch 就是将函数替换为另一个函数,让其不再依赖外部文件,使其拥有确定值
return "000111"
})
defer monkey.Unpatch(getFileFirstLine) // unpatch,恢复这个函数
result := replaceAll0To1() // 函数的测试结果不再依赖getFileFirstLine函数,也就不再外部文件,结果是确定的
expect := "111111"
if result != expect {
t.Errorf("测试未通过,预期:%v,结果:%v", expect, result)
}
}
基准测试
基准测试(benchmarking)是一种测量和评估软件性能指标的活动。
案例
- 该案例是对实现随机选择服务器实现负载均衡的函数进行基准测试
- 通过基准测试可以发现,在并发和并行的环境下,函数的执行时间极大的增加了
- 原因是原因是因为随机数生成函数为了保证全局的随机性和并发安全使用了一把全局锁,这就降低了并发的性能
- fastrand一定程度上缓解了这一问题,通过测试可以发现在并行测试条件下,基准测试结果明显变好
package main
import (
"math/rand"
"github.com/bytedance/gopkg/lang/fastrand"
)
type Service struct {
}
const maxSize = 10
var serviceIdxMap [maxSize]*Service
// 初始化服务器列表
func initServiceIdxMap() {
for i := 0; i < maxSize; i++ {
serviceIdxMap[i] = &Service{}
}
}
// 随机选择服务器实现负载均衡
func randomSelectService() *Service {
return serviceIdxMap[rand.Intn(maxSize)]
}
// fastrand随机选择服务器实现负载均衡
func fastRandomSelectService() *Service {
return serviceIdxMap[fastrand.Intn(maxSize)]
}
package main
import (
"os"
"sync"
"testing"
)
func TestMain(m *testing.M) {
code := m.Run()
os.Exit(code)
}
func BenchmarkTestA(b *testing.B) {
// fmt.Println("N:", b.N) // 执行次数
initServiceIdxMap()
b.ResetTimer() // 重置计时器
// b.N // B 是传递给基准测试函数以管理基准的类型 计时并指定要运行的迭代次数。
for i := 0; i < b.N; i++ {
randomSelectService() // 顺序执行N次,统计耗时
}
}
func BenchmarkTestB(b *testing.B) {
// fmt.Println("N:", b.N) // 执行次数
initServiceIdxMap()
var flag sync.WaitGroup
flag.Add(b.N)
b.ResetTimer() // 重置计时器
for i := 0; i < b.N; i++ { // 并发执行N次,统计耗时
go func() {
defer flag.Done()
randomSelectService()
}()
}
flag.Wait()
}
func BenchmarkTestC(b *testing.B) {
// fmt.Println("N:", b.N) // 执行次数
initServiceIdxMap()
b.ResetTimer() // 重置计时器
b.RunParallel(func(pb *testing.PB) { // 并行执行N次,统计耗时 (并行是通过并发来模拟实现的)
for pb.Next() {
randomSelectService()
}
})
}
func BenchmarkTestD(b *testing.B) {
// fmt.Println("N:", b.N) // 执行次数
initServiceIdxMap()
b.ResetTimer() // 重置计时器
// b.N // B 是传递给基准测试函数以管理基准的类型 计时并指定要运行的迭代次数。
for i := 0; i < b.N; i++ {
fastRandomSelectService() // 顺序执行N次,统计耗时
}
}
func BenchmarkTestE(b *testing.B) {
// fmt.Println("N:", b.N) // 执行次数
initServiceIdxMap()
var flag sync.WaitGroup
flag.Add(b.N)
b.ResetTimer() // 重置计时器
for i := 0; i < b.N; i++ { // 并发执行N次,统计耗时
go func() {
defer flag.Done()
fastRandomSelectService()
}()
}
flag.Wait()
}
func BenchmarkTestF(b *testing.B) {
// fmt.Println("N:", b.N) // 执行次数
initServiceIdxMap()
b.ResetTimer() // 重置计时器
b.RunParallel(func(pb *testing.PB) { // 并行执行N次,统计耗时 (并行是通过并发来模拟实现的)
for pb.Next() {
fastRandomSelectService()
}
})
}
go.exe test -benchmem -run=^$ -bench ^(BenchmarkTestA|BenchmarkTestB|BenchmarkTestC|BenchmarkTestD|BenchmarkTestE|BenchmarkTestF)$ main/_04_Test/_03_benchmark
goos: windows
goarch: amd64
pkg: main/_04_Test/_03_benchmark
cpu: Intel(R) Core(TM) i5-7300HQ CPU @ 2.50GHz
=== RUN BenchmarkTestA
BenchmarkTestA
BenchmarkTestA-4
38534160 29.49 ns/op 0 B/op 0 allocs/op
=== RUN BenchmarkTestB
BenchmarkTestB
BenchmarkTestB-4
3252032 367.7 ns/op 16 B/op 1 allocs/op
=== RUN BenchmarkTestC
BenchmarkTestC
BenchmarkTestC-4
20863542 57.32 ns/op 0 B/op 0 allocs/op
=== RUN BenchmarkTestD
BenchmarkTestD
BenchmarkTestD-4
174119311 6.879 ns/op 0 B/op 0 allocs/op
=== RUN BenchmarkTestE
BenchmarkTestE
BenchmarkTestE-4
3189530 334.8 ns/op 16 B/op 1 allocs/op
=== RUN BenchmarkTestF
BenchmarkTestF
BenchmarkTestF-4
708608461 1.736 ns/op 0 B/op 0 allocs/op
PASS
ok main/_04_Test/_03_benchmark 10.862s
项目实战
需求描述
- 展示话题(标题+内容)和回复列表
- 暂时不考虑前端页面,只需要实现接口
- 话题和回复只需要使用文件存储
需求分析
- E-R图
- 分层模型
技术选型
- Gin-go的高性能开源web框架
- Go Mod-go的项目管理工具
工程创建
- 初始化项目:
go mod init - 安装依赖:
go get gopkg.in/gin-gonic/gin.v1 1.3.0
数据层实现
package repository
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path"
"sync"
)
type Topic struct {
Id int32 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Create_time int32 `json:"create_time"`
}
type TopicDao struct {
}
var TopicMap map[int32]*Topic
var topicDaoSingleInstance *TopicDao // 单例模式,dao的实例对象
var topicOnc sync.Once // 保证执行的锁
// 用于初始化 TopicMap 对象, 该对象可以通过id快速获取topic
func initIdx2TopicMap() error {
workDir, _ := os.Getwd() // 获取工作目录
filePath := path.Join(workDir, "./data/Topic.db") // 拼接路径
file, err := os.Open(filePath) // 打开文件
if err != nil {
fmt.Println(err)
os.Exit(-1) // 文件不存在就直接报错然后退出
}
bufScaner := bufio.NewScanner(file)
temp := make(map[int32]*Topic)
for bufScaner.Scan() { // Scan会扫描到下一个token,默认的token分割函数是按照行分割,所以每次会读取一行
topic := Topic{} // 创建topic对象
err = json.Unmarshal([]byte(bufScaner.Text()), &topic) // 扫描一行数据,反序列化,存到topic
if err != nil {
return err
}
temp[topic.Id] = &topic // 存入
}
TopicMap = temp
return nil
}
func NewTopicDaoInstance() *TopicDao { // 单例模式创建TopicDao
topicOnc.Do(func() {
initIdx2TopicMap()
topicDaoSingleInstance = &TopicDao{}
})
return topicDaoSingleInstance
}
// 根据id获取一个Topic
func (*TopicDao) SelectTopicById(id int32) *Topic {
return TopicMap[id]
}
package repository
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path"
"sync"
)
type Post struct {
Id int32 `json:"id"`
Parent_id int32 `json:"parent_id"`
Content string `json:"content"`
Create_time int32 `json:"create_time"`
}
type PostDao struct {
}
var PostListMap map[int32](map[int32]*Post)
var postDao *PostDao
var psotOnc sync.Once
func initIdx2PostMap() error {
workDir, _ := os.Getwd() // 获取工作目录
filePath := path.Join(workDir, "./data/Post.db")
file, err := os.Open(filePath)
if err != nil {
fmt.Println(err)
os.Exit(-1)
return err
}
bufScaner := bufio.NewScanner(file)
temp := make(map[int32](map[int32]*Post)) // 类型是两层map
for bufScaner.Scan() { // 每次读取一行
post := Post{}
err = json.Unmarshal([]byte(bufScaner.Text()), &post)
if err != nil {
return err
}
if temp[post.Parent_id] == nil { // 初始化第二层map
temp[post.Parent_id] = make(map[int32]*Post)
}
temp[post.Parent_id][post.Id] = &post
}
PostListMap = temp
return nil
}
func NewPostDaoInstance() *PostDao {
psotOnc.Do(func() { // 懒汉单例
initIdx2PostMap() // 初始化
postDao = &PostDao{}
})
return postDao
}
// 根据父id获取回复列表
func (*PostDao) SelectPostListByFatherId(fatherId int32) []*Post {
postMap := PostListMap[fatherId]
postList := make([]*Post, 0)
for _, post := range postMap {
postList = append(postList, post)
}
return postList
}
业务逻辑层实现
package service
import (
"errors"
"main/repository"
"sync"
)
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
type PageInfoService struct {
}
// 查询页面信息
func QueryPageInfo(topicId int32) (*PageInfo, error) {
flow := QueryPageInfoFLow{topicId: topicId} // 创建一个查询页面信息的流程
pageInfo, err := flow.Do() // 执行这个流程,得到结果
if err != nil {
return nil, err
}
return pageInfo, nil
}
// 查询页面数据操作流程所需的数据结构
type QueryPageInfoFLow struct {
topicId int32
topic *repository.Topic
postList []*repository.Post
pageInfo *PageInfo
}
func (flow *QueryPageInfoFLow) Do() (*PageInfo, error) {
err := flow.checkParam() // 检查参数
if err != nil {
return nil, err
}
err = flow.prepareInfo() // 准备数据
if err != nil {
return nil, err
}
err = flow.packPageInfo() // 组装数据
if err != nil {
return nil, err
}
return flow.pageInfo, nil // 返回数据
}
// 检查参数,id不能为负
func (flow *QueryPageInfoFLow) checkParam() error {
if flow.topicId < 0 {
return errors.New("topicId coludn't be negative")
}
return nil
}
// 准备数据,使用两个协程来分别取获取topic和postlist
func (flow *QueryPageInfoFLow) prepareInfo() error {
var wg sync.WaitGroup
wg.Add(2)
go func() {
topicDao := repository.NewTopicDaoInstance()
flow.topic = topicDao.SelectTopicById(flow.topicId)
wg.Done()
}()
go func() {
postDao := repository.NewPostDaoInstance()
flow.postList = postDao.SelectPostListByFatherId(flow.topicId)
wg.Done()
}()
wg.Wait()
return nil
}
// 组装数据
func (flow *QueryPageInfoFLow) packPageInfo() error {
flow.pageInfo = &PageInfo{}
flow.pageInfo.Topic = flow.topic
flow.pageInfo.PostList = flow.postList
return nil
}
视图层实现
package controller
import (
"main/service"
"strconv"
)
// 定义页面数据
type PageData struct {
Code int32 `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
func QueryPageInfo(topicIdStr string) *PageData {
topicId, err := strconv.Atoi(topicIdStr) // 获取id
if err != nil {
return &PageData{
Code: 501,
Msg: err.Error(),
}
}
pageInfo, err := service.QueryPageInfo(int32(topicId))
if err != nil {
return &PageData{
Code: 502,
Msg: err.Error(),
}
}
return &PageData{ // 返回数据
Code: 0,
Msg: "查询成功",
Data: &pageInfo,
}
}
主函数
package main
import (
"fmt"
"main/controller"
"github.com/gin-gonic/gin"
)
func main() {
g := gin.Default()
g.GET("/community/topic/:id", func(ctx *gin.Context) {
topicId := ctx.Param("id")
data := controller.QueryPageInfo(topicId)
ctx.JSON(200, data)
})
err := g.Run("0.0.0.0:8080")
if err != nil {
fmt.Println(err)
}
}