并发、依赖管理、测试| 豆包MarsCode AI 刷题

62 阅读12分钟

并发、依赖管理、测试

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)

image 1.png

左图:通过通信共享内存

  • 在左图中,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 来确保主协程等待所有子协程完成后再继续执行。

核心方法

  1. Add(delta int) :增加或减少计数器的值,delta 是增加的数量。

    • 例如,wg.Add(5) 表示有 5 个 goroutine 即将开始,计数器加 5。
  2. Done() :每当一个 goroutine 完成工作后调用 Done() 方法,计数器减 1。

    • 通常在 goroutine 中通过 defer 语句确保 Done() 被调用。
  3. 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 语言中用于管理代码的环境变量,主要包含三大目录结构:

  1. bin:用于存储编译后的二进制文件。
  2. pkg:用于存储项目编译后的中间产物,加速编译。
  3. src:用于存储项目的源代码文件。

在传统的 Go 工作区(workspace)中,所有项目代码都存储在 GOPATH/src 目录下。Go 工具链会从 GOPATH 路径下寻找项目的源文件和依赖项。

  • 项目代码直接依赖 src 下的代码:所有项目代码都直接放置在 src 目录中,依赖项也是如此。
  • go getgo get 命令可以下载并安装依赖项,将下载的包存储到 src 目录下,以便在项目中引用。

问题:版本控制的局限性

在 GOPATH 工作区模式下,无法实现多版本控制。这带来了以下问题:

  • 场景:假设有两个项目 Project A 和 Project BProject A 依赖包的版本为 Pkg V1,而 Project B 依赖同一个包的较新版本 Pkg V2
  • 问题:由于 GOPATH 工作区中每个包只有一个全局版本,当 Pkg 被升级到 V2 后,Project A 可能因为不兼容而出错。这种单一版本的限制使得多个项目无法依赖同一个包的不同版本。

Go Vendor

通过在项目目录中添加 vendor 文件夹,将所有依赖的包副本放入 $ProjectRoot/vendor 下。这种机制的优点是可以让每个项目拥有自己的依赖副本,避免多个项目之间的依赖冲突。

工作方式

  1. 依赖存储位置:项目目录下的 vendor 文件夹用于存放当前项目依赖的第三方包。
  2. 依赖查找优先级:Go 编译器在查找依赖时会优先查找 vendor 目录下的包,只有在 vendor 目录中未找到时才会去 GOPATH 中查找。

使用 vendor 的好处

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

问题

  1. 版本冲突vendor 文件夹只支持一个版本的依赖,当 Project A 同时依赖 Package B 和 Package C , 时, Package B 和 Package C依赖不同版本的D由于 Package D 的两个版本不兼容,无法满足编译需求。
  2. 无法精确控制版本:如果某个依赖更新或不兼容,更新项目可能会出现依赖冲突,从而导致编译错误。

Go Module

它通过 go.mod 文件管理依赖包的版本, 可以使用 go get 和 go mod 等命令来管理依赖。

依赖管理三要素

  1. 配置文件:即 go.mod 文件,记录项目的依赖模块及其版本。
  2. 中心仓库(Proxy) :Go 的模块代理服务器,用于存储和分发模块。
  3. 本地工具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.0v2.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

image 2.png 如果 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最低的兼容版本中较高的版本。

依赖分发:回源问题

image.png 在传统的依赖管理中,开发者通常直接从源代码托管平台(如 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 文件始终匹配项目的实际依赖关系。

测试

从底层到顶层依次是:

  1. 单元测试(Unit Testing)验证代码的最小单元(通常是函数或方法)是否正常工作。
  2. 集成测试(Integration Testing)测试不同模块或组件之间的交互,确保它们协同工作时没有问题。
  3. 回归测试(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**