Go语言进阶-工程进阶 | 青训营笔记

48 阅读12分钟

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

前言

接下来我们开始从Go语言基础进行到Go语言进阶的学习,让我们从并发编程、依赖管理、测试来深入理解Go语言的工程实践

Go语言并编程

并发和并行

一个并发程序是指能同时执行通常不相关的各种任务。以一个游戏服务器为例子:它通常是有各种组件组成,每种组件都跟外部世界进行着复杂的信息交互。一个组件有可能要处理多个用户聊聊;另外一些可能要处理用户的输入,并把最新状态反馈给用户;其它的用来进行物理计算。这些都是并发处理。**

并发程序并不需要多核处理器。

相比之下,并行程序是用来解决一个单一任务的。以一个试图预估某支股票价格在下一分钟波动情况的金融组件为例,如果想最快速度的知道标普500中哪只股票应该卖出还是买进,你不能一个一个的计算,而是将这些所有的股票同时计算。这是并行。

1.并发(concurrency)

并发指在同一时刻只能有一条指令执行,但多个进程被快速轮换执行,似的在宏观上具有多个进程同时执行的效果,但在微观上不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。这就好像两个人用同一把铁锹,轮流挖坑,一小时后,两个人各挖一个小一点的坑要想挖两个大一点的坑,可能会用上两个小时。

2.并行(Parallelism)

并行指在同一时刻,有多条指令在多个处理器同时执行。就好像两个人各拿一把铁锹在挖坑,一个小时后,每人一个大坑。所以无论从微观还是宏观来看,二者都是一起执行的。 image.png

协程(Goroutine)

协程相比于线程,它是一种更加轻量级的存在,它的栈占用仅有KB级别。一个线程可以拥有多个协程,与线程相比,协程不受操作系统调度,协程调度器按照调度策略把协程调度到线程中执行,协程调度器由应用程序的runtime包提供,用户使用go关键字即可创建协程,这也就是GO在语言层面直接支持协程的含义。 image.png 由于协程运行在用户态,能够大大减少上下文切换带来的开销,并且协程调度器把可运行的协程逐个调度到线程中执行,同时及时把阻塞的协程调度出线程,从而有效的避免了线程的频繁切换,达到了使用少量的线程实现高并发的效果,但是对于一个线程来说每一时刻只能运行一个协程。

通过下面例子,我们来完成一个简单的协程编写。

package main

import (
	"fmt"
	"time"
)

func hello(i int) {
	println("hello world : " + fmt.Sprint(i))
}

func main() {
	for i := 0; i < 5; i++ {
		go func(j int) {
			hello(j)
		}(i)
	}
	time.Sleep(time.Second)
}

WaitGroup

在Go语言中,sync.WaitGroup结构体对象用于等待一组线程的结束。sync.WaitGroup结构体中有三个方法,Add()、Done()、Wait()

1.Add()方法主要为WaitGroup的等待数+1或者+n;

  • (1)Add()方法内部计数器加上delta,delta可以是负数;
  • (2)如果内部计数器变为0,则Wait()方法会将处于阻塞等待的所有goroutine释放;如果计数器小于0,则调用panic()函数;
  • (3)Add()方法加上正数的调用应在Wait()方法之前,否则Wait()方法可能只会等待很少的goroutine;
  • (4)Add()方法在创建新的goroutine或者其它等待的事件之前调用;

2.Done()方法调用的也是Add函数,主要用于-1操作。

  • Done()方法会减少WaitGroup计数器的值,一般在Goroutine最后执行;

现在我们用WaitGroup实现协程的同步阻塞,对上述协程代码进行优化。首先通过add方法,对计数器+5,然后开启协程,每个协程执行完后,通过done对计数器减1,最后wait主协程阻塞,计数器为0则退出主协程。

3.Wait()方法阻塞当前协程直到等待数归0才继续向下执行。

  • Wait()方法会阻塞,直到WaitGroup计数器减为0;
package main

import (
   "fmt"
   "sync"
)

func hello(i int) {
   println("hello world : " + fmt.Sprint(i))
}

func main() {
   var wg sync.WaitGroup
   for i := 0; i < 5; i++ {
      wg.Add(1)
      go func(j int) {
         defer wg.Done()
         hello(j)
      }(i)
   }
   wg.Wait()
}

并发安全Lock

go语言中存在多个goroutine去竞争同一个资源(临界区)。这种情况会发生竞态问题(数据竞态)。这时,我们可以引用sync.Mutex互斥锁来进行控制,它能同时保证一个Gotroutine可以访问共享资源,通过Lock方法进行上锁,Unlock方法解锁。

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 main() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second)
	println("WithoutLock:", x)//WithoutLock: 8352

	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	println("WithLock:", x)//WithLock: 10000
}

通道(Channel)

Go语言中的通道Channel是一种特殊的类型。在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。goroutine 间通过通道就可以通信。

通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。

  • (1)Channel本身是一个队列,先进先出
  • (2)线程安全,不需要加锁
  • (3)本身是有类型的,string, int 等,如果要存多种类型,则定义成 interface类型
  • (4)Channel是引用类型,必须make之后才能使用,一旦 make,它的容量就确定了,不会动态增加!!它和map,slice不一样

声明一个无缓冲Channel,指定传输类型为int。

src := make(chan int)

声明一个带缓冲区的Channel,后面3是指3个缓冲区,注意:当3个缓冲区满了,依旧会阻塞。

dest := make(chan int, 3)

以下是一个带有3协程的代码程序,通过A子协程发送0~9数字,再由B子协程接受数字进行平方计算,然后将结果发送给主协程,由主协程输出最终结果。

func CalSquare() {
    src := 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 {//range遍历通道
        println(i)
    }
}

Go依赖管理

为什么要依赖管理

我们要知道,在开发程序的过程中,你需要引用第三方的或者其他的依赖库;如Go中的ORM框架、HTTP框架等。这些依赖库来自各个不同的站点,有用不同的版本号,所以管理依赖是件很麻烦的事情。这时候我们就需要管理依赖的工具。

Go依赖管理演进

依赖管理不单单是Go的所有,在Go之前,就有其他不同语言的依赖管理工具了,例如JavaMavenJavaScriptnpm等。而Go的依赖管理只要经历了3个阶段,分别是GOPATHGo VendorGo Module这三部分。

GOPATH

GOPATH是Go语言支持的一个环境变量,目录有以下结构,src:存放Go项目的源码;pkg:存放编译的中间产物,加快编译速度;bin:存放Go项目编译生成的二进制文件。

GOPATH的弊端 如果在同一个pkg下有两个版本,项目A依赖于pkg1,项目B依赖于pkg2,然而src下只能有一个版本存在,这时候AB项目无法保证都能编译通过。即多个项目无法实现packge的的多版本控制,这显然不能满足我们的项目依赖需求,为了解决问题,Go Vendor出现了。

Go Vendor

通过在项目目录下新建vendor文件,所有依赖包副本形式放在其中。即通过每个项目引入一份依赖的副本解决了多个项目需要同一个package依赖的冲突。

在Vendor机制下,如果在vendor文件下找不到依赖,会从GOPATH中寻找。这样依然存在问题,如果项目A依赖了项目B和项目C,而后两个项目又依赖了项目D的不同版本,那么依赖又在GOPATH中出现,依旧出现了无法解决依赖包的版本变动问题和同一个项目依赖同一个包的不同版本的问题。

Go Module

为了解决vendor不能很清晰的标识依赖的版本概念,Go Module诞生了。通过go.mod文件管理依赖包版本,go.sum文件记录项目实际使用的依赖和依赖版本。我们可以通过go get/go mod命令来添加项目依赖包

下面是一个go.mod文件:

module example/project/app // 项目本身的依赖标识符

go 1.16 // 所使用的 Go 版本

require ( // 下面都是依赖
	bou.ke/monkey v1.0.2
	gopkg.in/gin-gonic/gin.v1.3.0
	gopkg.in/go-playground/validator.v10 v10.0 // indirect
)

首先,go mod为了方便管理定了版本规则,分为语义化版本和基commit伪版本,其中语义化版本包括${MAJOR}.${MINOR}.${PATH},后面跟版本号如v1.3.0

和语义化版本不同,commit有时间戳(yyyymmddhhmmss),也就是提交commit时间,最后是检验码69e39bad7dc2,也就是12位的哈希值前缀 v0.0.0-20211112202133-69e39bad7dc2

indirect特殊标识符,标识go.mod对应的当前模块,没有直接导入,而是间接导入。如下图所示

image.png

incompatible特殊标识符,在主版本2+模块会在模块路径增加/vN后缀,这能让Go Module按照不同模块来处理同一个项目不同主版本的依赖。为了兼容部分高版本的仓库,对于没有go.mod文件并且主版本在2或者以上的依赖,会在版本号后加上+incompatible后缀

依赖分发

那么我们依赖从哪下载呢?一般我们可以从github代码托管系统平台或者直接使用版本管理仓库下载。但这回出现问题,首先无法构建确定性,软件作者可以直接在代码平台增加/修改/删除软件版本,导致下次构建使用另外版本的依赖,找不到依赖版本,无法保证版本依赖可用性,大幅增加第三发代码托管平台压力。

为了解决这个问题,我们可以通过Proxy服务站点拉取依赖,它会缓存源站中的依赖内容,缓存的依赖版本不会改变,并且在源站软件删除之后依然可用。

Go Module通过GOPROXY环境变量控制如何使用Go Proxy。GOPROXY是一个Go Proxy站点URL列表,例如GOPROXY="https://proxy1.cn, https://proxy2.cn,direct",其中,direct表示源站。对于在中国的朋友,我们可以试试https://goproxy.cnhttps://goproxy.io这两个服务器,可以加快拉取依赖速度。

Go module两个常用指令工具

  • go get 指令 用于添加和删除依赖

image.png

注意,Go1.17版本后go get指令被弃用,统一用go install指令

  • go mod 指令 用于初始化依赖

image.png

Go单元测试

无论是什么项目,都要经过测试。比如医疗药品,都是通过成千上万次的测试才能投放市场,才能让人安全服用,不然就会带来巨大的安全隐患;还有企业开发,一个小小的Bug可能带来巨大损失,测试关系这系统的质量,质量决定线上系统的稳定性。所以,程序在上线之前进行测试对于企业来说是非常有必要的。

测试一般分为回归测试、集成测试、单元测试。我们这里重点了解下单元测试,单元测试就是开发者对单独的函数、模块做功能验证,层级从上至下,测试成本逐渐降低,而测试覆盖率逐步上升,所以单元测试一定程度上决定了代码的质量。

image.png

单元测试-规则

1.所有测试文件以_test.go结尾。

image.png

2.函数按照 func TestXxx(t * testing.T)格式,即函数名以Test为开头,括号包含*testing.T形参.

func TestPublishPost(t *tesing.T){
}

3.初始化逻辑方法放在TestMain中,即func TestMain(m *testing.M)。

image.png

下面是一个单元测试例子,

// from main.go:
func HelloTom() string {
	return "Jerry"
}

//from main_test.go:

import "testing"

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指令执行单元测试,得到失败输出的结果

image.png

单元测试-覆盖率

在实际项目中,衡量代码是否经过足够的测试、怎么评价项目的测试水准、项目是否达到了高水准测试等级,通过代码覆盖率,我们就可以验证上面所提的指标。通过

go test a_test.go a.go --cover

来显示代码覆盖率

单元测试-assert

通过assert库来进行单元测试。

image.png

单元测试-Mock

mokey是一个开源的mock测试库,可以对method,或者实例的方法进行mock,反射,指针赋值。如下图

image.png

基准测试

以服务器负载均衡为例,首先我们有10个服务器列表,每次执行select函数随机选择一个执行。基准测试以Benchmark开头,入参是testing.B,用b中的N值反复递增循环测试,runparallel多协程并发测试;

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(10)]
}
package main

import "testing"

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()
        }
    })
}

使用go test -bench.执行基准测试,得到以下结果

image.png

项目实践

通过gin框架来搭建web服务器,实现一个社区话题。通过go mod初始化管理配置文件,然后用go get下载gin依赖,这里使用了v1.3.0版本。

引用

本文章部分内容来自于以下课程:

  • 掘金字节内部课:Go语言进阶-工程进阶