并发、依赖管理、测试
Go可以发挥多核优势,高效运行
并发
并发&并行
并发:多线程程序在一个核的 CPU 上运行
并行:多线程程序在多个核的 CPU 上运行
Goroutine
协程:用户态,轻量级线程,栈 KB 级别 线程:内核态,线程跑多个协程,栈 MB 级别
eg: 快速打印hello
(i) 将当前循环的 i 值传递给匿名函数中的参数 j。这样,每个 goroutine 得到的是 i 的一个副本(即 j),而不是共享同一个 i。这样可以确保每个 goroutine 在执行时,都能获得自己独立的 j 值
go
代码解读
复制代码
package main
import (
"fmt"
"time"
)
func hello(i int) {
fmt.Println("hello goroutine : " + fmt.Sprint(i))
}
func HelloGoRoutine() {
// 使用循环生成多个 goroutine
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i)
}
// 让主 goroutine 睡眠一秒钟,以便让其他 goroutine 有时间完成执行
time.Sleep(time.Second)
}
func main() {
HelloGoRoutine()
}
CSP(Communicating Sequential Processes)
左图:通过通信共享内存
- 在左图中,
Goroutine1和Goroutine3与Goroutine2之间通过 通道(channel) 来通信。 - 原理:每个 goroutine 都是独立的,并且有各自的内存空间,它们通过通道发送数据来进行通信。通道充当数据的“传递管道”。
- 优势:这种方式减少了内存共享引起的并发问题,比如数据竞争和锁定,增强了并发程序的稳定性和安全性。
右图:通过共享内存实现通信
- 描述:在右图中,
Goroutine1和Goroutine3与Goroutine2之间通过 临界区 来共享数据。 - 原理:这里是将内存区域作为临界区共享,允许多个 goroutine 访问相同的内存数据。为了避免冲突,通常需要使用锁(如互斥锁)来确保同一时间只有一个 goroutine 能访问临界区。
- 缺点:这种方式容易引入并发问题,比如死锁和数据竞争,增加了并发编程的复杂性。
总结:提倡“通过通信共享内存”
- 理念:Go 语言提倡通过通信(通道)来实现数据的共享,而不是直接使用共享内存。
- 好处:通过通道传递数据,避免了对同一内存的并发访问,降低了并发程序的复杂性,提高了程序的稳定性和安全性。这种方式符合 CSP 的原则,让并发编程更加直观和安全。
Channel
make(chan 元素类型, [缓冲大小])
- 无缓冲通道:make(chan int), 用于需要发送方和接收方实时同步的场景。
- 有缓冲通道:make(chan int, 2), 提供了更灵活的并发控制,允许发送方在接收方还未准备好时继续发送数据,适合需要一定缓存的并发场景。
eg: A 子协程发送 0~9 数字 B 子协程计算输入数字的平方 主协程输出最后的平方数(生产者消费者)
可以保持顺序性,并发安全,输出0 1 4 9…….
go
代码解读
复制代码
func CalSquare() {
src := make(chan int)
dest := make(chan int, 3)//有缓冲
go func() { // A
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() { // B
defer close(dest)
for i := range src {
dest <- i * i
}
}()
for i := range dest { // M
// 复杂操作
println(i)
}
}
并发安全Lock
对变量执行 2000 次 +1 操作,5 个协程并发执行 在并发环境下,不加锁的情况可能会导致不同协程对 x 的修改发生冲突(数据竞争),导致最终结果不准
go
代码解读
复制代码
var (
x int64
lock sync.Mutex//一个互斥锁(Mutex),用于保证 x 的并发安全。
)
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)
}
输出结果示例:
WithoutLock: 8382
WithLock: 10000
WaitGroup
标准库提供的一个并发原语,用于等待一组 goroutine 的执行完成。在并发编程中,可以用 WaitGroup 来确保主协程等待所有子协程完成后再继续执行。
核心方法
-
Add(delta int) :增加或减少计数器的值,
delta是增加的数量。- 例如,
wg.Add(5)表示有 5 个 goroutine 即将开始,计数器加 5。
- 例如,
-
Done() :每当一个 goroutine 完成工作后调用
Done()方法,计数器减 1。- 通常在 goroutine 中通过
defer语句确保Done()被调用。
- 通常在 goroutine 中通过
-
Wait() :阻塞当前协程,直到计数器为 0。
- 主协程通常会调用
Wait()来等待所有子协程完成。
- 主协程通常会调用
eg: 修改打印hello的方法
go
代码解读
复制代码
func ManyGoWait() {
var wg sync.WaitGroup
wg.Add(5) // 设置计数器为 5,表示将启动 5 个 goroutine
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done() // 每个 goroutine 完成后计数器减 1
hello(j)
}(i)
}
wg.Wait() // 等待所有 goroutine 完成
}
依赖管理
Go Path
GOPATH 是 Go 语言中用于管理代码的环境变量,主要包含三大目录结构:
- bin:用于存储编译后的二进制文件。
- pkg:用于存储项目编译后的中间产物,加速编译。
- src:用于存储项目的源代码文件。
在传统的 Go 工作区(workspace)中,所有项目代码都存储在 GOPATH/src 目录下。Go 工具链会从 GOPATH 路径下寻找项目的源文件和依赖项。
- 项目代码直接依赖 src 下的代码:所有项目代码都直接放置在
src目录中,依赖项也是如此。 go get:go get命令可以下载并安装依赖项,将下载的包存储到src目录下,以便在项目中引用。
问题:版本控制的局限性
在 GOPATH 工作区模式下,无法实现多版本控制。这带来了以下问题:
- 场景:假设有两个项目
Project A和Project B。Project A依赖包的版本为Pkg V1,而Project B依赖同一个包的较新版本Pkg V2。 - 问题:由于
GOPATH工作区中每个包只有一个全局版本,当Pkg被升级到V2后,Project A可能因为不兼容而出错。这种单一版本的限制使得多个项目无法依赖同一个包的不同版本。
Go Vendor
通过在项目目录中添加 vendor 文件夹,将所有依赖的包副本放入 $ProjectRoot/vendor 下。这种机制的优点是可以让每个项目拥有自己的依赖副本,避免多个项目之间的依赖冲突。
工作方式
- 依赖存储位置:项目目录下的
vendor文件夹用于存放当前项目依赖的第三方包。 - 依赖查找优先级:Go 编译器在查找依赖时会优先查找
vendor目录下的包,只有在vendor目录中未找到时才会去GOPATH中查找。
使用 vendor 的好处
- 通过在每个项目中引入依赖的副本,解决了多个项目需要同一个 package 不同版本时的冲突问题。
问题
- 版本冲突:
vendor文件夹只支持一个版本的依赖,当Project A同时依赖Package B和Package C, 时,Package B和Package C依赖不同版本的D由于Package D的两个版本不兼容,无法满足编译需求。 - 无法精确控制版本:如果某个依赖更新或不兼容,更新项目可能会出现依赖冲突,从而导致编译错误。
Go Module
它通过 go.mod 文件管理依赖包的版本, 可以使用 go get 和 go mod 等命令来管理依赖。
依赖管理三要素
- 配置文件:即
go.mod文件,记录项目的依赖模块及其版本。 - 中心仓库(Proxy) :Go 的模块代理服务器,用于存储和分发模块。
- 本地工具:
go get和go mod等命令,用于管理和更新模块依赖
依赖配置- go.mod
require:列出所有依赖的模块及其版本,标记直接或间接依赖(indirect)。
go
代码解读
复制代码
module example/project/app //依赖管理基本单元,定义模块路径
go 1.16 //原生库
require (
example/lib1 v1.0.2
example/lib2 v1.0.0 // indirect
example/lib3 v0.1.0-20190725025543-5a5fe074e612 // indirect
example/lib4 v0.0.0-20180306012644-bacd9c7ef1dd // indirect
example/lib5/v3 v3.0.2
example/lib6 v3.2.0+incompatible
)
依赖配置- version
语义化版本:如 v1.3.0、v2.3.0,表示 MAJOR.MAJOR.{MINOR}. ${PATCH}。
主版本号增加通常意味着有重大更改,通常会破坏向后兼容性, 次版本号增加表示添加了新功能,但不会破坏现有的向后兼容性,修订版本号增加表示修复了错误或进行了小改动,这些更改不会破坏向后兼容性
基于commit的伪版本:用于引用特定的提交,例如 v0.0.0-20220401081311-c38fb59326b7。
依赖配置- indirect
indirect 表示间接依赖,即项目中未直接引用,但由于其他依赖包的需求而引入的包。
例如:如果模块 A 依赖于模块 B,而 B 又依赖于模块 C,则 C 会作为 A 的间接依赖标记为 indirect。
依赖配置- incompatible
Go Modules 规定,如果一个模块的主版本号大于或等于 2(例如 v2.x.x),则该模块的路径必须在模块名称中指定版本号。例如,v2 版本的模块路径通常是 module/path/v2
如果模块的主版本号大于等于 2 且没有 go.mod 文件,则会被标记为 incompatible。
如果
X 项目(即 Main)依赖了 A 和 B 两个项目,并且 A 和 B 分别依赖了 C 项目的 v1.3 和 v1.4 版本,最终编译时所使用的 C 项目的版本会是哪一个?
- A.
v1.3 - B.
v1.4(正确答案) - C.
A用到C时用v1.3编译,B用到C时用v1.4编译
Go Modules 通过语义化版本(Semantic Versioning)来确定优先级,较高的版本通常包含较新的功能和修复,因此被优先选择。
C v1.4是最低的兼容版本中较高的版本。
依赖分发:回源问题
在传统的依赖管理中,开发者通常直接从源代码托管平台(如 GitHub、SVN 等)下载依赖代码。这种方式存在一些问题:
- 构建稳定性:源代码托管平台上的依赖代码可能会被作者删除或修改,导致依赖的某个版本不再可用,影响项目的构建和稳定性。
- 依赖可用性:一旦依赖代码从源平台删除,项目就无法找到该依赖,导致构建失败。
- 第三方压力:大量的依赖下载请求会给托管平台带来负载压力,影响其可用性。
这些问题导致了构建过程中的不确定性,因此在生产环境中可能不适合直接从源代码托管平台获取依赖。
依赖分发—Proxy
为了提高依赖管理的稳定性和可用性,Go 引入了 Proxy(代理) 机制。通过使用代理服务器来存储和分发依赖包,开发者的依赖请求不再直接发往源平台,而是先访问代理,获取缓存的依赖包。
- 稳定性:代理服务器可以缓存依赖包的各个版本,确保即使原平台发生变动,项目依然可以获取所需的依赖。
- 可靠性:代理服务器提供的高可用服务减少了依赖包无法获取的情况,提高了构建的可靠性。
依赖分发—变量 GOPROXY
proxy1—> proxy2—>direct
GOPROXY 是 Go Modules 中用于配置代理的环境变量。通过 GOPROXY,开发者可以指定依赖分发的代理服务器地址,甚至可以指定多个代理服务器。格式如下:
go
代码解读
复制代码
GOPROXY="https://proxy1.cn,https://proxy2.cn,direct"
proxy1.cn和proxy2.cn:表示优先访问的代理服务器。direct:表示在代理服务器未命中时直接访问源代码托管平台。
工具—go get
在 Go 中,使用 go get 命令可以指定获取依赖的不同版本或特定的提交。以下是常用的版本标签:
@update:默认获取最新版本。@none:表示删除该依赖。@v1.1.2:获取指定的语义化版本(如v1.1.2)。@23dfdd5:获取指定的提交(根据提交哈希)。@master:获取指定分支的最新提交(例如master分支)。
go
代码解读
复制代码
go get example.org/pkg@v1.1.2
工具—go mod
go mod 是 Go Modules 的依赖管理工具,常用的子命令包括:
init:初始化当前项目并创建go.mod文件,用于开始使用 Go Modules 管理依赖。download:将go.mod文件中声明的依赖下载到本地缓存。tidy:清理go.mod文件,增加项目中实际需要的依赖,并删除不再需要的依赖。这会确保go.mod文件和go.sum文件始终匹配项目的实际依赖关系。
测试
从底层到顶层依次是:
- 单元测试(Unit Testing)验证代码的最小单元(通常是函数或方法)是否正常工作。
- 集成测试(Integration Testing)测试不同模块或组件之间的交互,确保它们协同工作时没有问题。
- 回归测试(Regression Testing)确保在代码更改或新功能添加后,已有的功能仍然保持正常。
测试覆盖率逐层递减,而测试的执行成本则逐层增加。
规则
测试文件以_test.go结尾, 测试函数func Testxxx(*testing.T) , 如果有初始化逻辑(如数据装载或配置初始化),可以将其放在 TestMain 函数中
go
代码解读
复制代码
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)
}
}
运行单元测试的命令是 go test [flags] [packages]。示例测试运行输出显示期望值为 "Tom",实际返回 "Jerry",因此测试失败,并提供了详细的错误信息:
Assert:
go
代码解读
复制代码
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)//t是一个指向testing.T object的指针
}
计算代码覆盖率, 一般覆盖率50-60%,较高覆盖率80%
go
代码解读
复制代码
go test judgment_test.go judgment.go --cover
依赖
在单元测试中,往往会涉及外部依赖(如文件系统、数据库、缓存等)。直接测试这些依赖会导致测试不稳定,可能因为环境或依赖的波动而影响结果。因此,为了确保测试的稳定性和隔离性,我们引入Mock。
- 外部依赖:如文件(File)、数据库(DB)、缓存(Cache)等。
- 解决方案:用 Mock 替代实际依赖,确保单元测试的稳定和一致。(幂等,稳定)
文件处理
文件依赖可能会影响测试的稳定性,如文件缺失或内容改变。
go
代码解读
复制代码
func ReadFirstLine() string {
open, err := os.Open("log")
defer open.Close()
if err != nil {
return ""
}
scanner := bufio.NewScanner(open)
for scanner.Scan() {
return scanner.Text()
}
return ""
}
func ProcessFirstLine() string {
line := ReadFirstLine()
destLine := strings.ReplaceAll(line, "11", "00")
return destLine
}
func TestProcessFirstLine(t *testing.T) {
firstLine := ProcessFirstLine()
assert.Equal(t, "line00", firstLine)
}
Mock
在这个测试中,ReadFirstLine 被 Mock 掉,直接返回 "line110",不依赖实际文件。通过这种方式,可以独立测试 ProcessFirstLine 的逻辑。
go
代码解读
复制代码
func TestProcessFirstLineWithMock(t *testing.T) {
monkey.Patch(ReadFirstLine, func() string {
return "line110"
})
defer monkey.Unpatch(ReadFirstLine)
line := ProcessFirstLine()
assert.Equal(t, "line000", line)
}
基准测试
测试程序运行性能和cpu的损耗
go test -bench=.
go
代码解读
复制代码
import (
"math/rand"
)
var ServerIndex [10]int
func InitServerIndex() {
for i := 0; i < 10; i++ {
ServerIndex[i] = i + 100
}
}
func Select() int {
return ServerIndex[rand.Intn(n: 10)]
}
------------------------------------------测试
func BenchmarkSelect(b *testing.B) {
InitServerIndex()
b.ResetTimer()//重置计时器
for i := 0; i < b.N; i++ {
Select()
}
}
func BenchmarkSelectParallel(b *testing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Select()
}
})
}
**BenchmarkSelect-12 61563442 18.77 ns/op
BenchmarkSelectParallel-12 19034816 79.42 ns/op
----------------------------------------优化**
func FastSelect() int {
return ServerIndex[fastrand.Intn(n: 10)]
}
**BenchmarkSelectParallel-12 16946781 61.80 ns/op
BenchmarkFastSelectParallel-12 1000000000 0.6951 ns/op**