Go并发编程以及工程实践 | 青训营笔记

69 阅读4分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天

并发编程

并发编程对于任何语言来说都是一个重要话题,Go语言支持并发编程,支持协程,为并发编程提供了更加轻量级的解决方案,可以充分发挥多核优势,高效运行程序。

Goroutine

Goroutine

协程:用户态,轻量级线程,栈KB级别,开销小,灵活

线程:内核态,线程可以跑多个协程,栈MB级别,开销大

通常而言,线程创建上千个就已经是一个比较大的开销了,但是协程可以轻松创建上万个,这就是协程的优势。

func hello(i int) {
    fmt.Println("hello goroutine: ", i)
}
func helloGoroutine() {
    for i := 0; i < 5; i++ {
        go func(j int) {		// go 关键字创建协程
            hello(j)
        }(i)
    }
    time.Sleep(time.Second)		// 等待协程执行结束
}

如上个例子所示,使用 go 关键字就可以创建协程了。

通信顺序进程-CSP

CSP(Communicating Sequential Process)顺序通信过程通常有两种通信方式:

  1. 通过通道共享内存实现通信
  2. 通过直接共享内存实现通信

通信顺序进程

在实践中,通常建议使用通道来实现内存共享,而不是直接进行内存共享。共享内存往往会造成各种各样的*数据竞态(race)*问题影响程序的性能。

Channel

channel其实比较类似于一个生产者消费者模型。channel一共有两种类型:

  • 无缓冲通道:make(chan int)

    生产者发送一个消息,接收者接收,由于没有缓存,两个协程之间通过通道实现了同步,本质上是一个同步过程,因此无缓冲通道也被称为是同步通道

  • 有缓冲通道:make(chan int, 2)

    类似于Java中的一个阻塞队列

func CalSquare() {
    src := make(chan int)
    dest := make (chan int, 3)
    // 生产 0 ~ 9 的数字
    go func() {
        for i := 0; i < 10; i++ {
            src <- i
        }
    }()
    // 消费 0 ~ 9,并生产他们的平方
    go func() {
        for i := range src {
            dest <- i * i
        }
    }()
    // 消费最终产品(复杂过程)
    go func() {
        for i := range dest {
            // 处理复杂的逻辑
            fmt.Println(i)
        }
    }()
}

Mutex

对变量执行 2000 次 +1 操作,5个协程执行,最终结果应该是 10000

var (
	x int64
    lock sync.Mutex
)
func addWithLock() {
    for i := 0; i < 2000; i++ {
        lock.Lock()
        x = x + 1
        lock.Unlock()
    }
}
func addWithoutLock() {
    for i := 0; i < 2000; i++ {
        x = x + 1
    }
}
// AddWithLock: 10000
// AddWithoutLock: 8362

分别执行后的结果,不出意料没有加锁的线程造成了误差,最后导致结果错误。因此,对于共享内存要避免不加锁的操作。

WaitGroup

由于不清楚任务的确切结束时间,使用 time.Sleep() 方法来等待并不是一个好方法。对于这种需求,Java的一个并发工具 CountDownLatch 就可以解决。Go语言的工具就是 WaitGroup 来完成。

方法描述
Add(delta int)计数器 +delta
Done()计数器 -1
Wait()阻塞等待计数器为0

上面这三个方法就可以达到线程的同步

func WaitGroupTest() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func() {
            defer wg.Done()
            // 一些耗时工作
        }()
    }
    wg.Wait()
}

依赖管理

Go依赖管理的发展

  • 环境变量 GOPATH

    bin		项目编译的二进制文件
    pkg		项目编译的中间产物,加速编译
    src		项目源代码
    

    项目代码直接依赖src,通过 go get 下载最新的包到 src 目录下。

    弊端:项目 A 和 B 依赖于某个 package 的不同版本,但是Go并不能保证两个版本之间就是相互依赖的,这样就没办法实现 package 的多版本控制

  • Go Vendor

    项目目录下增加 vendor 目录,所有依赖包以副本的形式放在下载到 ${ProjectRoot}/vendor 。项目依赖寻址,就优先从 vendor 目录下寻找,否则去 GOPATH 寻找依赖。Go Vendor 解决了多个项目之间的版本依赖问题

    弊端:虽然解决了项目之间的以来冲突问题,但是还是通过源码来管理依赖,那么项目内部的 package 版本冲突问题也还是没办法解决。

  • Go Module

    通过 go.mod 文件管理依赖包版本,通过 go get/go mod 指令工具管理依赖包,最终完成定义版本规则和管理项目依赖的关系

依赖管理三要素

  1. 配置文件,描述依赖:go.mod 文件

    module example/project/app	依赖管理基本单元
    
    go 1.19						原生库
    
    require (					单元依赖
    	example/lib1 v1.0.2
        example/lib2 v1.0.0 // indirect
        example/lib3 v1.0.0-yyyymmddhhmmss-abcdefgh1234
        example/lib4 v2.0.0-yyyymmddhhmmss-abcdefgh1234 // indirect
        example/lib5/v3 v3.0.2
        example/lib6 v3.2.0+incompatible
    )
    
    • 依赖标识:[Module Path] [Version/Pseude-version]

    • 语义化版本:${MAJOR}.${MINOR}.${PATCH}

    • 基于commit 伪版本:vx.0.0-yyyymmddhhmmss-abcdefgh1234

    • 直接依赖和间接依赖:间接依赖会标识 indirect

      A -> B -> C
      AB是直接依赖,A和C是间接依赖
      
    • 兼容性

      对于主版本大于2的模块会在路径后增加 /vN 后缀。但是对于没有 go.mod 文件并且主版本大于2的依赖会增加 +incompatible 后缀来区别

    • 依赖版本的选择:选择最低的兼容版本

      依赖选择示例

      对于上面这种情况,A1.2依赖C1.3,B1.2依赖C1.4,最后编译的时候,会选择C1.4

  2. 中心仓库管理依赖库:go proxy

    依赖分发回源:直接使用版本控制平台下载依赖,例如github SVN 等等。但是这样会带来很多问题:

    1. 无法保证构建的稳定性:代码仓库作者可以随意增加/修改/删除版本,这样很可能导致开发者没办法稳定得到某个版本的依赖
    2. 无法保证依赖可用性:如果某个作者因为个人原因,删除了代码仓库删除了软件,那就会导致这个依赖没办法再次获取到
    3. 增加第三方仓库压力:第三方仓库的初衷就是为了作为代码源码的版本控制,而不是为了作为下载站点,这样无疑会带来额外的流量压力

    综上所述,就需要一个代理来帮助我们处理这些痛点问题。

    GOPROXY="https://proxy1.cn, https://proxy2.cn"
    这里可以使用环境变量来定义中心仓库便于依赖管理
    

    依赖仓库的定义顺序,决定了优先从哪里那个中心仓库来下载依赖,如果依赖在这些代理节点都不存在,那么就会 direct 直连下载

  3. 本地工具:go getgo mod

    1. go get

      go get example.org/pkg	@update		默认
      						@none		删除依赖
      						@v1.2.3		Tag/语义版本
      						@23d45fdd	指定的commit
      						@master		分支最新
      
    2. go mod

      go mod	init		初始化,创建 go.mod
      		download	下载模块到本地
      		tidy		增加必要依赖,删除不必要依赖
      

测试

代码发生错误将会导致损失,为了避免损失,就需要在项目上线前进行充分的测试。从回归测试到系统测试再到单元测试,覆盖率逐层增大,成本却逐层减少。为了提高项目代码质量,作为程序员就需要做好单元测试。

单元测试

单元测试

单元测试的覆盖率也一定程度上能说明代码的质量。

单元测试规则

  • 所有测试文件以 xxx_test.go 结尾
  • 测试函数需要按照这样的命名规范 func TestXxx(t *test.T)
  • 初始化逻辑放到 TestMain 函数中
// "github.com/stnetchr/testify/assert"
func TestPublishPost(t *testing.T) {
    output := PublishPost()
    expectOutput := true
    // assert.Equal(t, exceptOutput, output)
    if output != exceptOutput {
        t.Errorf("Excepted %v do not match actual %v", 
                exceptOutput, output)
    }
}
func TestMain(m *testing.M) {
    // 测试前
    /* 初始化操作 */
    code := m.Run()
    // 测试后
    /* 收尾操作 */
    os.Exit(code)
}

覆盖率

覆盖率可以衡量代码是否得到充分测试,测试的水准。

执行 Go 命令 go test xxx_test.go xxx.go --cover 查看测试覆盖率。

ok command-line-arguments 1.296s coverage: 66.7% of statements

Tips

  • 一般覆盖率:50% - 60%,较高的覆盖率就是 80+%
  • 测试分支相互独立,全面覆盖
  • 测试单元粒度足够小,函数职责单一

Mock测试

monkey:github.com/bouk/monkey

快速 Mock 函数:为一个函数打桩、为一个方法打桩

func TestProsessFirstLineWithMock(t *testing.T) {
    // 打桩:ReadFirstLine需要IO操作,但是这里替换以后
    // 就可以实现和文件操作的解耦
    monkey.Patch(ReadFirstLine, func() string {
        return "line110";
    })
    defer monkey.Unpatch(ReadFirstLine)
    // 测试内容
    line := ProcessFirstLine()
    assert.Equal(t, "line110", line)
}

基准测试

基准测试(Benchmark)可以分析代码的性能情况。类似于单元测试,基测试函数命名规范 func BenchmarkXxx(t *test.B)

// 串行执行测试
func BenchmarkSelect(b *testing.B) {
    InitServerIndex()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        Select()
    }
}
// 并行执行测试
func BenchmarkSelectParallel(b *tesing.B) {
    InitServerIndex()
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.next() {
            Select()
        }
    })
}

每次在执行 Benchmark 测试的时候,如果有初始化操作那么就需要重置计时器 b.ResetTimer()

rand 和 fastrand:

rand在测试中的性能相对 fastrand 低很多,因此在使用随机数的场景中最好选择 fastrand 而不是rand。当然,fastrand的随机数是牺牲了一定的一致性来达到的高效率,这点在程序设计也需要考虑。