21 Go语言简易入门

LIYI约 16745 字大约 56 分钟

21 Go语言简易入门

这是一篇关于Go语言的简易入门教程。不看Go语言内部信道自旋等源码,从应用角度阐述主要概念及使用技巧。

安装

https://golang.org/dl/ 下载,笔者所用版本为1.15.6。

第一个Go程序

下面尝试在1分钟写下第一个Go程序,并在终端里以指令方式运行。

这是《Go语言简易入门》系列内容第5篇,所有内容列表见:https://yishulun.com/books/go-easy/目录.htmlopen in new window

设置环境变量GOPATHGOPATH、PATH

从官网(https://golang.org/dl/,国内可以从https://studygolang.com/dl下载)下载了Go语言安装包,并安装以后,此时在终端里执行指令:

go version

一般都可以显示版本号了。此时编写一个简单的go语言文件,也可以运行。

但此时Go语言完全运行所需的环境变量还没有完全准备就绪。一般我们在环境变量中,还需要设置一个名称为GOPATH的用户变量或系统变量,同时还需要将GOPATH/bin添加到变量GOPATH/bin添加到变量PATH的路径中。

在macOS上,我们可以在~/.bash_profile中设置用户变量,也可以在/etc/profile中设置系统变量。在Windows上,我们可以通过计算机属性->高级属性->设置变量进行设置。

$PATH路径的设置与之同理。

为什么要设置环境变量?

GOPATH变量不设置,不影响运行,它在Go语言安装包默认安装后有一个~/go的默认地址,但$GOPATH/bin必须添加到$PATH的路径中。这是所有开发者自安装的Go语言第三方类库所生成的工具指令,在系统上能够被查到的基础,如果不设置,系统不知道去哪里查找我们在终端里随意写出的指令名称。

举个例子,gin是一个Go语言编写的为Go程序提供热编译功能的工具。在使用它的时候,我们首先需要安装它:

go get github.com/codegangsta/gin

然后,假设我们本地有一个main.go,可以这样执行:

gin run main.go // 启用热编译

此时用gin代替go,运行main.go,当我们修改了后者的代码以后,gin可以帮助我们自动重新编译并启动后者。这是gin的作用。

但是如果我们没有将$GOPATH/bin放在$PATH路径中,上面的gin指令就运行不了,它会提示一个“指令找不到”的错误。

go get可以帮助我们自动拉取类库源码,及目标类库所依赖的类库源码(放在了$GOPATH/src路径下),并在本地自动编译,并将编译生成的本地二进制可执行文件拷贝到$GOPATH/bin路径下。如果我们不在$PATH路径里包含主这个地址,终端程序是找不到gin指令的,这是它工作的机制。

如何验证环境变量?

所有环境变量设置完成以后,怎么验证呢?可以在终端里,可以使用go env查看所有Go语言相关的环境变量了。当然了,也可以单独使用echo查看特定的环境变量。如下所示:

go env // 查看所有go语言环境变量
go version // 查看版本
echo $PATH | grep /go/bin
echo $GOPATH

补充:go get指令到底做了哪些事情?

一个go get指令在执行后,相当于执行了以下步骤:

  1. git clone,将所有目标类库源码,及目标类库所依赖的源码下载到本地
  2. 开始执行go install,第一步是编译目标类库导入的包文件,所有被依赖的包文件被编译后,才能编译目标类库
  3. 开始编译目标类库的主程序,并将编译之后的可执行文件,拷贝至$GOPATH/bin目录下。如果目标类库是工具,它一定有一个main函数。

在终端里运行第一个Go程序

既然我们已经了解了如何在终端里执行额外的指令,接上来我们自己用Go语言写一个简单的程序,然后在终端里执行。

首先我们创建一个文件:

mkdir -p $GOPATH/rixingyike/first/main.go

并编辑代码为:

package main

import "fmt"

func main() {
	fmt.Printf("hi,ly\n")
}

现在我们执行:

export GO111MODULE=off
go install rixingyike/first

正样的话现在$GOPATH/bin目录下已经多了一个名称为first的二进制可执行文件。在终端里可以执行它了:

first // output: hi,ly

在上面我们编译源码之前,为什么要先设置Go环境变量GO111MODULE等于off呢?可以把它设置为on,再试一下。这关系到Go语言的模式化,以下再讲。

欢迎留言。

参考链接

(2021年1月21日)

模块化:第一个Go语言类库

下面尝试启用、创建并发布第一个模块。

模块化是编程界的潮流,无论是前端Vue、微信小程序开发,还是后端Node.js、Golang开发,都讲究模块化。模块化的本质是分工协作,将功能相对独立完善的代码以模块方式发布,以便在其它程序中复用,这与汽车厂分别制造发动机、轮胎、车门等零件,然后再组装是一个道理。

GO111MODULE的由来

那么在Go语言开发中,如何进行模块化开发吗?

默认在官方教程《如何使用Go编程》中是不讲这一块的,环境变量GO111MODULE默认是关闭的,运行官方示例也不会受到影响。但模块化确实是非常重要的概念,是任何想认真使用这门语言的开发者都避不开的。

上面我们提到了GO111MODULE,什么是GO111MODULE?

这个名称中有三个数字一,不是字母“l”,是数字“1”,它表示在Go语言1.11版本中加入的环境变量。单从这个名称来看,它很有可能被干掉,但事实上一真没有。

在以前最早2009年Go语言发布的时候,源码都是通过GOPATH管理的。怎么理解呢?在代码中我们通过import关键字引入一个第三方类库,Go程序会依次向GOPATH、GOROOT这两个总目录下去查找,哪个先查到,就用哪个。

但是我们知道,位于github上的类库,master分支是最新源码,这个源码经常变动,有时候我们使用的仅是历史上的某个版本。有的开发者注意到了这一点,所以当类库重构的时候,会将旧代码打一个Release版本,这样即使源码修改了,只要我们找到历史版本,也不影响我们程序的正常运行。

但是问题起来,有的程序需要用某个类库的新版本,有的需要用旧版本,GOPATH只有一个,怎么处理这个矛盾呢?

那个时候我用的是最笨的方法,起新项目的时候,我将GOPATH目录复制一份,并修改GOPATH变量为复制后的新目录。一个项目对应一个GOPATH,这样不同项目的类库版本就不会相互掣肘了。

可能不止我一个人这么使用。Go语言在1.5版本的时候,推出了一个vendor特征,它充许我们将当前项目所用的所有第三方类库,全部自动拷贝到一个叫做vendor的子目录下。Go程序在编译的时候,会首先向vendor目录查找,如果没找到,再向GOPATH、GOROOT目录查找。

但是这种方式并没有从根本上在Go语言中解决模块化编程的问题,项目在共享和分发时,随身携带许多第三方类库的源码,既占空间,又不利于统一升级类库。如果第三方类库在新版本中修复了一个bug,而我们需要更新,在多个项目中更新将是一件麻烦事。

后来,在Go语言1.11版本中,Go语言推出了GO111MODULE环境变量,及mod子指令,基于这个变量和子指令,可以完美模块化编程了。接下来我们看看,一般是怎么做的。

创建并发布自己的第一个模块

首先我们在GOPATH路径外面创建一个目录:

rixingyike/
  first
    main.go
  str
    reserve.go

这是两个示例。first目录是测试代码,用于测试我们发布的模块。str是我们准备创建和发布的模块。模块位于多级目录下,这是我们故意为之的。go语言的类包都是单名一级引入,但在实际的项目开发中,我们的模块往往处于多级目录下,我们看看这种情况一般是怎么处理的。

先看一下模块str/reserve.go的源码:

// go-easy/rixingyike/str/reserve.go
package str

import(
	"fmt"
	"github.com/kataras/iris/v12"
)

// Reverse 将其实参字符串以符文为单位左右反转
func Reverse(s string) string {
	r := []rune(s)
	for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
		r[i], r[j] = r[j], r[i]
	}
	fmt.Println(string(r))
	return string(r)
}

// StartServer ...
func StartServer() {
	app := iris.New()
  app.Handle("GET", "/user/{id:uint64}", func(ctx iris.Context) {
		id, _ := ctx.Params().GetUint64("id")
		ctx.JSON(id)
	})
  app.Listen(":8080")
}

我们在这个文件中引入了iris框架。我们需要在这个模块中启用go mod,执行如下指令:

cd go-easy/rixingyike/str/
go mod init gitee.com/rxyk/go-easy/rixingyike/str

go mod指令后面是我们模块的名称,注意这里分部分,前面gitee.com/rxyk/go-easy是我们的仓库地址,后面/rixingyike/str是仓库中模块的相对路径。

这里有一个问题值得注意下,就是我们的module name是gitee.com/rxyk/go-easy/rixingyike/str,但是reserve.go文件中的package名称却是str,后者是引入以后在源码中使用的单名,这两个名称是不需要也不能一致的。

接下来是关键,接着执行指令:

go env -w GOPRIVATE="gitee.com"
git tag rixingyike/str/v1.0.0
git push origin rixingyike/str/v1.0.0

第一行指令,是将gitee.com这个域名添加进GOPRIVATE变量中,GOPRIVATE这个变量的值可以用逗号分隔添加多个值,但这里我们不需要添加多个。这一步的环境变量设置,是为了跳过对gitee.com域名的网络代理。这是国内网站,是不需要代理的。

第二行和第三行指令是创建一个新的tag,并提交到远端仓库里。这里的关键是tag名,前面rixingyike/str/是模块在仓库中的相对路径,后面v1.0.0才是模块的版本号。默认情况下,如果类库在根目录下是不需要这样处理的,直接写一个像v1.0.0这样的版本号就可以了。

使用自己的第一个模块并在本地调试

现在模块已经在线上发布了,接下来我们看一下怎么使用。

现在我们切换到first例目录,并进行module初始化,执行如下指令:

cd go-easy/rixingyike/first/
go mod init gitee.com/rxyk/go-easy/rixingyike/first
vim main.go ...
go get gitee.com/rxyk/go-easy/rixingyike/str@v1.0.0

第二行指令中这个module的名称,因为不需要对外发布,其实是无所谓的。接下来编辑main.go的源码:

// go-easy/rixingyike/first/main.go
package main

import (
	"fmt"
	"gitee.com/rxyk/go-easy/rixingyike/str"
	"github.com/nleeper/goment"
)

func main() {
	fmt.Printf("%s\n",str.Reverse("hi,ly"))
	var g,_ = goment.New("2021-01-23 09:30:26")
	println(g.ToString())
	str.StartServer()
}

在这个测试示例中,我们引入了goment和str这两个模块,其中后者是我们自己定义的。

我们看一下自动生成的go mod文件:

// go-easy/rixingyike/first/go.mod
module gitee.com/rxyk/go-easy/rixingyike/first
go 1.15
// replace gitee.com/rxyk/go-easy/rixingyike/str v1.0.0 => ../str
require (
	gitee.com/rxyk/go-easy/rixingyike/str v1.0.0
	github.com/nleeper/goment v1.4.0
)

输出是这样的:

yl,ih
yl,ih
2021-01-23 09:30:26 +0000 UTC
Now listening on: http://localhost:8080
Application started. Press CMD+C to shut down.

在这个文件中,第三行代码replace,是将依赖包替换。有两个作用:

  • 如果某个类库因为网络原因,不能下载,可以用这个功能
  • 我们自己开发的模块,需要在本地调试

我们将这行配置反注释一下,而main.go中的import引入代码不需要修改,再运行代码,调用的就是本地的str下的代码了。这个设置,方便我们在本地进行模块代码,调试完成后再统一上传。

关于模块化编程,以上就是全部内部了。接下来我们补充了解一些相关的概念。

更多内容

如何临时修改GO111MODULE变量?

有时候我们需要临时修改这个变量的值,但并不需要永久修改。有两个方法:

go env -w GO111MODULE=on
export GO111MODULE=on

这是两种方式,以第二种效果最佳。第一种方式go env -w *是一种Go语言提供的通用的编辑环境变量的方式。

开启go mod后,还能再使用vendor统一打包源码吗?

可以的,在项目模块目录下,例如str,执行:

go mod vendor

这样就会在str目录下生成一个vendor子目录,它里面有所有的依赖包。

GO111MODULE有哪些有效值?

有三个值:

  • GO111MODULE=off,不支持module功能,此时查找依赖包的次序是:vendor、GOPATH、GOROOT。
  • GO111MODULE=on,支持使用modules,会从vendor目录下查找,但不会去GOPATH、GOROOT目录下查。
  • GO111MODULE=auto,是默认值,自动性取决于上下文目录。GOPATH/src之中的项目继续使用GOPATH模式;GOPATH/src之中的项目继续使用GOPATH模式;GOPATH/src之外的项目使用模块化模式。

go mod指令,除init外,还有哪些子指令?

相关指令:

  • download download modules to local cache (下载依赖的module到本地cache))
  • edit edit go.mod from tools or scripts (编辑go.mod文件)
  • graph print module requirement graph (打印模块依赖图))
  • init initialize new module in current directory (在当前文件夹下初始化一个新的module, 创建go.mod文件))
  • tidy add missing and remove unused modules (增加丢失的module,去掉未使用的module)
  • vendor make vendored copy of dependencies (将依赖复制到vendor下)
  • verify verify dependencies have expected content (校验依赖)
  • why explain why packages or modules are needed (解释为什么需要依赖)

最常使用的子指令是init、download、tidy和vendor。

启用go mod后,如何查询和安装指定版本的依赖包?

和原来是一样的。可以使用:

go get github.com/kataras/iris/v12@latest

@符号后面是版本号,latest代表最新。这个版本就是git网站上的发行版标签。可以用如下指令查询所有可用标签名:

go list -m -versions github.com/kataras/iris/v12

输出:

v12.0.0 v12.0.1 v12.1.0 v12.1.1 v12.1.2 v12.1.3 v12.1.4 v12.1.5 v12.1.6 v12.1.7 v12.1.8 v12.2.0-alpha v12.2.0-alpha2

其中地址中的v12是什么?它是该仓库的一个分支。它还有另一个分支:v0.0.1。

引入国外的一些类库,如何设置代理?

使用GOPROXY变量。我的设置是这样的:

export GOPROXY="https://goproxy.io,https://mirrors.aliyun.com/goproxy/,https://goproxy.cn,direct"

三个网站的说明是这样的:

以逗号分隔。最后的direct代表到源地址下载。

欢迎留言讨论。

参考链接

(2021年1月23日)

测试:小心并发测试中的测试陷阱

四类测试三种方式

Go语言提供了testing基础类库和go test指令,不使用第三方类库就可以完成常见的测试工作。软件研发中的测试工作一般分为四类,范围从小到大排列依次是:单元测试、集合测试、链路测试和UI测试。其中链路测试、UI测试位于测试金字塔的顶端,一般划分为黑盒测试范畴,用QA人力保证;单元测试与集合测试属于白盒测试,繁杂而精细,可以依靠测试代码自动完成。

图:测试金字塔之单元测试、集合测试、端到端测试(链路测试)、UI测试

我们先看一下单元测试怎么搞。单元测试是最基本的测试,就是对软件中最基础的功能,对某个函数、某个接口、某个配置等代码进行测试。测试方法有三种:

  • 基本的单元测试:TestXxx
  • 基准测试:BenchmarkXxx
  • 示例测试:ExampleXxx

接下来分别看一下这三种方法在Go语言中怎么用,先看基本的单元测试TestXxx。

单元测试:TestXxx

在Go语言的测试哲学中,大量使用了基于命名的约定俗成的规则。例如单元测试,就是以“TestXxx”这样的格式编写,前缀是Test,后面是一个大写的单词,一般是名词。如果后面还需要附加其它说明,一般是添加一个“_Xxx”这样的后缀。

看一个示例:

// go-easy/7/case/fibonacci_test.go
package fibonacci_test

import (
	. "gitee.com/rxyk/go-easy/rixingyike/str"
	"testing"
)

func TestFibonacci(t *testing.T)  {
	// 0,1,1,2,3,5,8,13
	for _, v := range []struct{
		in,expected int
	}{
		{1,1},
		{2,1},
		{3,2},
		{5,5},
		{7,13},
	} {
		if res := Fibonacci(v.in); res != v.expected {
			t.Errorf("Fibonacci(%d) == %d, want %d", v.in, res, v.expected)
		}
	}
}

源码见:go-easy/7/case/fibonacci_test.go

执行指令:

go test case/fibonacci_test.go

将输出:

ok      command-line-arguments  0.051s

使用Table-Driven技巧

在这个示例中,使用了一种被称之为Table-Driven的编程技巧:

for _, v := range []struct{
		in,expected int
	}{
		{1,1},
		{2,1},
		{3,2},
		{5,5},
		{7,13},
	} {...}

这里匿名声明了一个结构体,并马上实体化,得到了一个结构体数组,然后再循环这个数组,依次测试。在结构体中定义了每次测试所需的输入条件和输出结果。

点引入

这个示例中还使用了一种点引入的包操作:

. "gitee.com/rxyk/go-easy/rixingyike/str"

将点放在包名前面,代表此包内的方法允许不带包名访问。例如str包中的Fibonacci函数,此时就可以直接访问了:

Fibonacci(v.in)

fibonacci_test.go这个文件没有main函数,它内部只有TestXxx这样格式的测试函数,这样的函数在执行go test指令时会自动被执行。此处,这个文件中的包名是fibonacci_test,它与我们测试的目标包名str是不一致的,这是被充许的,并且一般也这样处理。这样既可以避免相互循环引用,还方便在独立的目录中编写模块测试代码。

在我们的str包中,一共有两个实现斐波那契数列的函数:

// Fibonacci 此函数计算斐波那契数列中第 N 个数字
func Fibonacci(n int) int {
	switch n<2 {
	case true:
			return n
	default:
			return Fibonacci(n-1) + Fibonacci(n-2)
	}
}
// Fibonacci2 ...
func Fibonacci2() func() int {
	a, b := 0, 1
	return func() int {
			a, b = b, a+b
			return a
	}
}

第一个测试单元测试函数TestFibonacci,测试的是Fibonacci函数。接下来我们再于fibonacci_test.go文件中添加另一个测试函数:

func TestFibonacci2(t *testing.T)  {
	// 0,1,1,2,3,5,8,13
	for _, v := range []struct{
		in,expected int
	}{
		{1,1},
		{2,1},
		{3,2},
		{5,5},
		{7,13},
	} {
		var f = Fibonacci2()
		var res = 0
		for j:=0;j<v.in;j++ {
			res = f()
		}
		if res != v.expected {
			t.Errorf("Fibonacci2(%d) == %d, want %d", v.in, res, v.expected)
		}
	}
}

同样执行指令:

go test case/fibonacci_test.go

输出:

ok      command-line-arguments  0.083s

原来是0.051s,现在是0.083s,时间变长了,为什么?

因为在go test指令启动的测试中,各个文件之间是并发的,但每个文件中的TestXxx函数是串行的。

对于没有相互依赖关系的测试函数,能不能让它们并发?

并发执行单元测试

答案是可以的。除了把它们编写在不同的文件中,还有一种更为简单直接的方法,就是使用testing.Parallel()方法。

现在将fibonacci_test.go文件复制一份,命名为fibonacci_test2.go,修改其代码添加Parallel方法:

// go-easy/7/case/fibonacci2_test.go
package fibonacci_test

import (
	. "gitee.com/rxyk/go-easy/rixingyike/str"
	"testing"
)

func TestFibonacci_2(t *testing.T)  {
	t.Parallel()
	...
}

func TestFibonacci2_2(t *testing.T)  {
  t.Parallel()
	...
}

现在执行并发测试指令:

go test -parallel=2 case/fibonacci2_test.go 

输出:

ok      command-line-arguments  0.039s

指令中的参数-parallel=2,代表同时执行2个用于测试的Go程。这个设置还受限于GOMAXPROCS,可以使用runtime.GOMAXPROCS(runtime.NumCPU())修改最大可同时执行的Go程数,让电脑中的所有CPU最大限度同时干活。

除了在不同测试函数中标注Parallel,开启开发测试,还有没有其它更简单的方法?

如何执行子测试?如何以树状次序执行测试

答案也是有的。可以使用子测试。子测试允许在一个单元测试启动后,后续并发执行一单元测试。我们看一下示例:

// go-easy/7/case/fibonacci3_test.go
func TestFibonacci_3(t *testing.T)  {
	t.Parallel()
	// 0,1,1,2,3,5,8,13
	for _, v := range []struct{
		in,expected int
	}{
		{1,1},
		{2,1},
		{3,2},
		{5,5},
		{7,13},
	} {
		t.Run(fmt.Sprintf("name%d",v.in), func(t *testing.T) {
			t.Parallel()
			res := Fibonacci(v.in)
			t.Logf("in:%d,res=%d\n",v.in, res)
			assert.Equal(t, v.expected, res)
		})
	}
}

执行如下指令:

go test -parallel=5 case/fibonacci3_test.go 

得到输出:

ok      command-line-arguments  0.077s

加了t.Parallel(),是并发执行;不加,是串发依次执行。

看一个伪代码:

t.Run("group", func(t *testing.T) {
  t.Run("Test1", Test1Handler)
  t.Run("Test2", Test2Handler)
  t.Run("Test3", Test3Handler)
})

这是定义一个群单元测试,每个子测试又可以分化出一个组,每个组都可以串发或并发,这样就实现了树状的测试次序,对于编写有先决执行条件的测试,这个机制可以利用上。

img
img

在并发执行测试的时候,有一个问题必须注意。

一个关于并发引起的堆、栈内存的问题

我们知道,Go程序中的内存分配有堆与栈之分。一般情况下,在主程或子程中启用一个子Go程,这个子Go程里的变量是在栈上分配的。等子Go程执行完成后,栈里的变量就自动释放了。但有时间并不是这样的,规则没有这么简单。我们看一个简单的示例:

// go-easy/7/mem.go
package main 

import ("fmt")

type Cursor struct{
	X int 
}

func f() *Cursor {
	var c Cursor
	c.X = 500
	return &c
}

func main()  {
	v := f()
	fmt.Printf("c=%v\n",v)
}

输出:

c=&{500}

在函数f()中,c本来是一个函数内的局部变量,是分配在栈上的,但因为f()返回了它的内存指针,并在main()中使用了,所以它实际上又逃逸到了堆上。有一种分析变量是在堆上、还是在栈上的技术,叫逃逸分析。我们看一下如何分析,在终端执行如下指令:

go build -gcflags=-m mem.go

在这个指令中,-gcflags是给编译器传递参数,-m代表输出内存分配提示。

输出:

# command-line-arguments
./mem.go:9:6: can inline f
./mem.go:16:8: inlining call to f
./mem.go:17:12: inlining call to fmt.Printf
./mem.go:10:13: new(Cursor) escapes to heap
./mem.go:16:8: new(Cursor) escapes to heap
./mem.go:17:12: []interface {} literal does not escape
<autogenerated>:1: .this does not escape

输出结果第5、6行有“escapes to heap”的提示,这就是“逃逸到堆”。

Go语言编译器比较聪明,它知道何时该在栈上分配内存,何时该在堆上分配,以期将执行效果发挥到更大。关于堆、栈内存我们先了解到这里,接下来看关键问题,并发是如何引起堆、栈内存问题的。先看一个示例:

// go-easy/7/case/fibonacci4_test.go
package fibonacci_test

import (
	. "gitee.com/rxyk/go-easy/rixingyike/str"
	"testing"
	"fmt"
	"github.com/stretchr/testify/assert"
)

func TestFibonacci_4(t *testing.T)  {
	t.Parallel()
	// 0,1,1,2,3,5,8,13
	for _, v := range []struct{
		in,expected int
	}{
		{1,10},
		{2,10},
		{3,20},
		{5,50},
		{7,13},
	} {
		// v := v 
		t.Run(fmt.Sprintf("name%d",v.in), func(t *testing.T) {
			t.Parallel()
			res := Fibonacci(v.in)
			t.Logf("in:%d,res=%d\n",v.in, res)
			assert.Equal(t, v.expected, res)
		})
	}
}

执行:

go test case/fibonacci4_test.go

输出:

ok      command-line-arguments  0.049s

输出显示测试成功。这是Table-Driven的数据是无效的:

{1,10},
{2,10},
{3,20},
{5,50},
{7,13},

这个数列根本不是斐波那契数列。事实上在这个数组中,只要最后一组数组对,前面的expected是几根本无关紧要。

为什么会这样?

因为所有在第24行并发执行子单元测试,取到的v全部是{7,13}这一行。

而如果我们将第23行代码注释掉,在这里“脱裤子放屁”,将变量v重新声明一下,问题就解决了,该暴露的错误就会暴露出来了。

为什么?

回想一下前面我们讲的关于堆、栈内存分配的问题。如果没有第23行看以多余的代码,变量v是分配在堆上的;而有了这行代码后,临时变量v重新分配到了栈上。当变量在堆上时,每个并发的单元测试取到的都是同一个内存数据的数据,也就是for最后的循环值;而当变量在栈上时,每个Go程(一个单元测试是一个独立的Go程)都有自己的栈,相互之间不会影响。

我们可以用一个并发的Go程示例验证这个问题:

// go-easy/7/multi/multigoroutine.go
package main 

import (
	"gitee.com/rxyk/go-easy/rixingyike/str"
	"fmt"
	"time"
)

func main()  {
	for _, v := range []struct{
		in,expected int
	}{
		{1,10},
		{2,10},
		{3,20},
		{5,50},
		{7,13},
	} {
		// v := v 
		go func(){
			time.Sleep(time.Millisecond)
			res := str.Fibonacci(v.in)
			fmt.Printf("in:%d,res=%d\n",v.in, res)
		}()
	}
	// fmt.Printf("%v\n",v)
	// v是for循环退出后,被gc回收了,所以不能访问了
	time.Sleep(time.Second)
}

执行程序,将输出:

in:7,res=13
in:7,res=13
in:7,res=13
in:7,res=13
in:7,res=13

看到了吧,输出的都是结构体数组中的最后一组值。

看完了查验程序功能性的基本单元测试,再看一下另外两种测试类似:基准测试与示例测试。

使用基准测试(BenchmarkXxx)调试算法

现在我们的程序中有两个斐[fěi]波那契数列算法,到底哪个算法更好,可以使用查验代码性能的基准测试方法。

看一下基准测试示例:

// 
package fibonacci_test

import (
	. "gitee.com/rxyk/go-easy/rixingyike/str"
	"testing"
)

// 基准测试
func BenchmarkFibonacci_10(b *testing.B) {
	for n := 0; n < b.N; n++ {
		Fibonacci(10) // 运行 Fibonacci 函数 N 次
	}
}

// 基准测试2
func BenchmarkFibonacci2_10(b *testing.B) {
	for n := 0; n < b.N; n++ {
		var f = Fibonacci2()
		for j := 0; j < 10; j++ {
			f()
		}
	}
}
...

执行指令:

go test -bench=Fibonacci* ./case

输出:

goos: darwin
goarch: amd64
pkg: str/case
BenchmarkFibonacci_10-4          2576298               458 ns/op
BenchmarkFibonacci2_10-4         7319109               152 ns/op
BenchmarkFibonacci_20-4            20602             57007 ns/op
BenchmarkFibonacci2_20-4         6624660               191 ns/op
PASS
ok      str/case        6.266s

ns代表纳秒。从测试结果来看,使用了Go语言双赋值特征的Fibonacci2算法效果更佳。

基准测试函数的参数类型是*testing.B,数字属性b.N并不是我们决定的。默认情况下,每个基准测试最少运行 1 秒。如果基准测试函数返回时还不到 1 秒钟,b.N 的值会按照序列 1,2,5,10,20,50,... 增加,然后再次运行基准测试函数。

基准测试是我们调试算法的一个很不错的工具。接下来我们再看一下示例测试。

示例测试:ExampleXxx

示例测试是基于名称定义规则的典范,看一个示例:

// go-easy/7/case/example_test.go
package fibonacci_test

import (
	. "gitee.com/rxyk/go-easy/rixingyike/str"
	"fmt"
)
...

func ExampleFibonacci2() {
	var f = Fibonacci2()
	var res = 0
	for j := 0; j < 5; j++ {
		res = f()
	}
	fmt.Println(res)
	// output: 5
}

示例测试函数以ExampleXxx这样的格式编写,在函数尾部使用// output:xxx这样的格式定义输出内容。如果使用fmt类库打印的内容与定义的不一致,测试便会报错。

运行测试指令:

go test case/example_test.go 

输出:

ok      command-line-arguments  0.037s

现在在我们的子目录7下,已经有了单元测试、基准测试和示例测试。使用一个指令可以启动所有:

go test -bench=Fibonacci* ./...

参数-bench代表类包,支持正则表达式,如果不限制可以写“.”。

关于TestMain

现在我们了解了所有基本的测试技巧,也可以以并发、串发的方式组合进行复杂的测试了。还有一种情况需要了解,假设我们需要在一个单元测试启动之前做一些事情,以及在完成之后做一些事情,这种情况怎么处理?

当然这种情况也可以使用子测试解决,但Go语言提供了一种更方便的方法:TestMain。TestMain是测试文件中默认先测试的函数,函数中间要显式调用m.Run(),这时候才正式执行测试。测试之后的事情也可以在这里设置。m.Run()之前的代码Setup代码,之后的代码是Teardown代码。具体代码如下:

// go-easy/7/case/testmain_test.go
func TestMain(m *testing.M) {
	flag.Parse() // 解析可能需要的参数
	go func(){
		StartServer2()
	}()
	exitCode := m.Run()
	// 退出
	os.Exit(exitCode)
}

func ExampleGetUser123() {
	res, _ := http.Get("http://localhost:8080/user/123")
	resBody, _ := ioutil.ReadAll(res.Body)
	res.Body.Close()
	fmt.Printf("%s", resBody)
	// output:123
}

这种方式是基于Go语言提供的http包进行测试。Go语言还提供了一种httptest测试包,但这个包与iris框架不是契合的。iris另提供了一个httptest,使用这个包方便测试使用iris编写的Web代码。看一个示例:

// go-easy/7/case/testmain_test.go
// 依托iris的httptest测试
func TestServerUser(t *testing.T) {
	app := NewWebServer2()
	e := httptest.New(t, app)
	e.GET("/user/123").Expect().Status(httptest.StatusOK).Body().Equal("123\n")
	e.POST("/user/123").WithBytes([]byte(`{"name":"ly","city":"bj"}`)).Expect().Status(httptest.StatusOK).Body().Equal("{\n  \"ID\": 123,\n  \"Name\": \"ly\",\n  \"City\": \"bj\"\n}\n")
}

这个测试函数不需要TestMain协助,可以独立运行。

以上大概就是所有测试相关的技巧了,现在所有测试代码仍然可以通过一条指令统一执行:

go test -cover -bench=Fibonacci* ./...

更多相关的问题

T类型中方法

除了已经用过的Errorf,testing.T类型还有许多实用的方法:

  • Fail : 测试失败,测试继续,也就是之后的代码依然会执行 FailNow : 测试失败,测试中断
  • Log : 输出信息 Logf : 输出格式化的信息
  • SkipNow : 跳过测试,测试中断 Skip : 相当于 Log + SkipNow,跳过这个测试,并且打印出信息 Skipf : 相当于 Logf + SkipNow
  • Error : 相当于 Log + Fail,标识测试失败,并打印出必要的信息,但是测试继续 Errorf : 相当于 Logf + Fail
  • Fatal : 相当于 Log + FailNow,标识测试失败,打印出必要的信息,但中断测试 Fatalf : 相当于 Logf + FailNow

关于逃逸分析(Escape analysis)

所以逃逸分析(Escape analysis)就是识别出变量需要在堆上分配,还是在栈上分配。如果内存分配在栈中,则函数执行结束可自动将内存回收;如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理。

在使用指令go build -gcflags=-m *.go编译源码时,如何看到:

./main.go:20: moved to heap: c
./main.go:23: &c escapes to heap

类似“moved to heap”、“escapes to heap”这样的描述,表示变量发生逃逸了,变量已到堆中。

关于-gcflags编译参数

go build指令用*-gcflags是给go*编译器传入参数,也就是传给go tool compile的参数。值-m可以检查代码的编译优化情况,包括逃逸情况和函数是否内联等。

go build用-ldflags给go链接器传入参数,实际是给go tool link的参数。

关于覆盖率

go test指令中添加参数-cover,可以查看测试覆盖率。但这种方式会修改源码,如果没有权限修改,覆盖率是不显示的。

如何查看Go语言程序的汇编代码?

最简单的办法是分两部分走。第一步先编译成目标文件:

go tool compile -N -l 文件.go 

生成一个文件.o文件,第二步查看指定函数的汇编代码:

go tool objdump -s 函数 文件.o

汇编代码难于阅读,指定函数方便查看。

什么是Go语言中的闭包?举个例子

闭包是函数式语言中的概念,Go语言是支持闭包的,看一个例子:

func f(i int) func() int {
    return func() int {
        i++
        return i
    }
}
c1 := f(0)
c2 := f(0)
println(c1()) // output: 1
println(c2()) // output: 1

示例中函数f(int)返回了一个函数,返回的函数就是一个闭包。这个函数中本身是没有定义变量i的,而是引用了它所在的环境(函数f)中的变量i。

变量i是函数f中的局部变量,假设这个变量是在函数f的栈中分配的,是不可以的。因为函数f返回以后,对应的栈就失效了,f返回的那个函数中变量i就引用一个失效的位置了。所以闭包的环境中引用的变量不能够在栈上分配。

关于测试的内容有点多,欢迎留言讨论。

参考链接

(2021年1月26日)

Go程是如何创建和何时销毁的?

Go程如何创建?

通过go关键字进行创建,看一下代码,很简单:

go test(j) // test是一个函数

Go程如何销毁,何时销毁?

创建一个Go程简单,但何时销毁呢?这个问题稍微有点复杂,看个代码先:

package main
import (
	"fmt"
	"time"
	// "runtime"
)
func test(j int)  {
	fmt.Printf("  子子go程%d暂停1s\n",j)
	time.Sleep(time.Second)
	fmt.Printf("  子子go程%d结束\n",j)
}
func main() {
	go func() {
		for j := 0; j < 3; j++ {
			go test(j)
		}
		fmt.Println(" 子go程暂停1s")
		time.Sleep(time.Second)
		fmt.Println(" 子go程结束")
		// 不管是return  还是runtime.Goexit(),
		// 还是什么都没有,Go程及其栈,在函数退出时均会销毁
		// return 会让Go程马上结束,后面的代码不会再执行
		// 而什么也不写,默认执行到函数体代码的最后一行
		// runtime.Goexit()
	}()
	println("主程暂停")
	time.Sleep(time.Second*5)
	println("主程结束")
}

源码见:go-easy/Go程/main1.go

输出:

主程暂停
  子子go程0暂停1s
 子go程暂停1s
  子子go程2暂停1s
  子子go程1暂停1s
  子子go程1结束
  子子go程2结束
 子go程结束
  子子go程0结束
主程结束

从输出来看是这样的,Go程共享堆,但不共享栈。一个子Go程退出时,它的栈会销毁,但这并不会影响在它的生命周期内创建的子子Go程。Go程的栈是相互独立的。

Go程共享的堆一旦销毁,所有子Go程,及子子Go程也就退出了,不能再执行了。上面的程序最后几行如果修改为:

println("主程暂停")
// time.Sleep(time.Second)
println("主程结束")
runtime.GC()

源码见:go-easy/Go程/main2.go

输出则是:

主程暂停
主程结束
  子子go程0暂停1s
 子go程暂停1s
  子子go程2暂停1s
  子子go程1暂停1s

所有后代Go程能得执行,但是不能结束,因为堆已经被销毁了。

欢迎留言。

所有源码见:https://gitee.com/rxyk/go-easy。源码示例修改自:https://blog.csdn.net/weixin_42117918/article/details/82318508open in new window

(2021年1月19日)

下面说并发。

信道:如何通过信道完成Go程(goroutine)同步?

中文译为信道,英文是Channel,发音为[ˈtʃænl] ),在Go语言中简写为chan

chan是Go语言的基本数据类型之一,也是Go语言中为难不多三个使用make关键字进行初始化的三个类型(信道、映射和切片)之一。

var c = make(chan int, 5)

和切片的创建一样,当我们使用make关键字创建一个信道时,返回的是一个值类型,并不是引用。这个值内部又包括了指向信道里真正数据的指针和其它一些描述字段。我们在使用切片时,多数情况下也是作为值类型使用,这并不影响效率,因为切片本身结构体字段十分简单,主要数据还在切片指向的内部数组上,并不在切片本身上。在使用方式上,使用make关键字创建的这三个类型:切片、信道和映射,是类似的。

Go语言三大特色

对于初学这门语言的人来讲,Go语言有三大特色:

  1. 信道
  2. Go程
  3. 切片

第一个信道,它不是Go语言的自创,是基于CSP并发模型的一种实现,但也深具特色,因为其它语言像js、java、python等,不是这么玩的。

第二个Go程,也就是goroutine,是Go语言独创的用户微线程。原本一个os线程需要2MB的内存开销,初始的一个goroutine只使用2kb,这是同样的配备为什么Go语言拥有更高并发量的原因。

第三个切片,有人可能觉得奇怪,既然Go语言已经有了数组,为什么还有切片?在Go语言中,数组主要是作为切片的原材料被使用的。开发者直接使用数据也可以,但在大多数情况下显然使用切片更简单、效率也更高。

今天我们就先了解一下三大特色之首,信道。

信道在Go语言并发编程中占据着重要的角色,基于它,我们可以非常优雅又轻松地实现跨线程数据同步。在对信道展开了解之前,我们首先要明确一个概念:

什么是并发?

在Go语言编程中(在其它高级语言编程中也是如此),并发并不是意味着​两个或多个事情发生的时间点完全一致,而是指它们发生在一段时间内。

——— |—e1——— |——

——— |——— e2—|——

在上面图形中,如果我们用“|”作为分隔一段时间的间隔,那么事件e1与e2就是并发的。虽然实际上它们是在不同时间点发生的。

信道操作符

了解了什么是并发,接下来理解信道就简单多了。我们首先看一下信道操作符:

c <- 0
var a = <- c

一个信道操作符由一个向左的箭头+一个连字符组成,无论是信道的接取,还是发送,是读取,还是写入,使用的都是这个符号。不存在一个连字符+向右的箭头(->)这样的符号。

数据流通的方向永远是从右向左。设有信道变量c,如果信道变量在右边,例如<-c,是数据流出,这是读取;如果信道变量在左边,例如c <- 0,是数据流入,这是发送。

好,现在我们理解了信道符号的使用,接下来理解信道操作就会清晰多了。

四种读写情况

var c = make(chan int, 5)

从make创建信道的语法来看,chan与int代表通道类型,chan与int共同组成了一个类型,这点与数组类型中数组长度也是类型一部分是一致的。最后的数字5,代表的是信道的缓存容量。缓存容量默认不传为0,0表示不缓存。

现在我们根据信道的两种操作(读写)的顺序,及有无缓存,将信道操作分为以下四种情况:

有缓存先读后写 x先写后读 √
无缓存先读后写 √先写后读 √

打√代表可以实现信道间的数据同步。有三种情况能实现,一种不能。接下来我们看一下它们分别是怎么实现的。

第一种,有缓存之先写后读

有缓存的先写后读,**在特定信道上的每一次发送操作,都有与其对应的接收操作相匹配。信道上的发送操作,总在对应的接收操作完成前发生。**这句话是教科书上写的,理解起来真费劲,我们看代码说:

package main 

var c = make(chan int, 1)
var a string

func f() {
	a = "hi, ly"
	c <- 0
}

func main() {
	go f() // 这里启动了一个Go程
	<-c
	println(a)
}

源码见:go-easy/并发/chan1.go

输出:

hi, ly // 后面所有正常输出都是这个

在这个代码中,第13行<-c是信道的接收操作,第8行是信道的发送操作。发送操作总在接收操作完成之前产生,在这个代码中,第13行的信道操作没有办法完成,因为信道是空的,主线程代码走到这里的时候,必须等待信道的发送操作在某处完成才可以继续。在哪里完成呢?在第12行go f()启动的Go程里,完成这个发送操作。

如果没有第13行的信道读取代码,这个程序会一闪而过,不会有任何东西打印的。我们正是借用了信道操作的这个特点,完成了Go程间的事件同步。

我们可以理解为,第12行启动的Go程,与当前程序的主线程整个是并发的。既然是并发的,我们就没有办法确定,当第14行代码println(a)执行时,到底第7行代码a = "hi, ly"有没有执行过。

而添加了信道同步以后,两个Go程在某个时间点对齐了,也就是产生了同步事件。在这个代码中,第8行代码c <- 0与第13行代码<-c是一个同步的时间点,两个Go程在这里对齐了,如下图所示。

  ———`a=..`——————`c <- 0`

—`go f()`—————————`<-c`——--—`println(a)

事实上,在这个示例中,我们用 close(c) 代替第8行的 c <- 0 ,仍能保证该程序产生相同的行为。因为close操作会使信道返回0,0对于第13行的代码仍然是有用的。在这里我们往信道里写入什么,以及接收到什么并不重要,我们只是借用信道的读写同步机制。

在这里我们标题里说的“先写后读”,指的是读操作发生在写操作之前,不是读的代码在写的代码之前,而是指执行。

理解了第一种情况,第二种情况就好理解了。

第二种,无缓存之先读后写

**从无缓冲信道进行的接收,要发生在对该信道进行的发送完成之前。**这句话也是教科书上的,我们先看一下先读后写的代码:

package main 

var c = make(chan int)
var a string

func f() {
	a = "hi, ly"
	<-c
}

func main() {
	go f()
	c <- 0
	println(a)
}

源码见:go-easy/并发/chan2.go

这个示例与上面的第一种情况的示例,输出是一样的。代码只有三行不同,就是原第8行与第13行代码互换了一下,还有第3行代码,我们将信道的容量去掉了。现在这个信道是无缓存的。

先前第一种情况,因为信道是空的,我们无法读取,所以主线程被阻塞了。现在两行代码互换了一下,直接写入为什么也会被阻塞呢?

就是因为信道是有无缓存的。一个没有缓存的信道,我们想在一端往里写入,也必须同时有另一端往外接收才行。这就是“从无缓冲信道进行的接收,要发生在对该信道进行的发送完成之前”。在这个示例中,第13行代码的写入操作,会因为无人接收而被挂起,直到在第12行启动的Go程中,第8行与之对齐,主线程才可以继续执行。而这时候变量a已经被赋值了,所以第14行的打印才会有值。

无缓存,先读后写可以同步,那么先写后读也可以吗?

第三种,无缓存之先写后读

先看一下代码:

package main

var c = make(chan int)
var a string

func f() {
	a = "hi, ly"
	c <- 0
}

func main() {
	go f()
	<-c
	println(a)
}

源码见:go-easy/并发/chan2.go

该示例代码与第二种情况,仅是将第8行与第13行互换了一下。测试结果输出是一样的。

为什么无缓存信道,先写后读与先读后写都可以呢?

因为对于无缓存信道,因为没有缓存,读与写的操作必须两头都有接应才行。在这个示例中,第13行代码想读取信道中的值,但是此时无人发送啊,必须也必须等待。

第四种,有缓存之先读后写

上面这三种情况,都是可以进行Go程同步的。还有一种情况我们没做实验,就是有缓存先读后写的情况。我们看一下代码:

package main

var c = make(chan int, 1)
var a string

func f() {
	a = "hi, world"
	<-c
}

func main() {
	go f()
	c <- 0
	println(a)
}

源码见:go-easy/并发/chan4.go

在这个示例中,第8行代码尝试读取信道,但因为信道中没有内容,所以会被阻塞。第13行代码对信道进行写入,因为信道有缓存容量,不需要另一端有人实时接收也可以写入,所以这个地方并不会阻塞。

运行效果大概率是没有输出。从原理上讲,两个Go程是并发的,但我们无法保证第8行先于13行代码执行,很大概率是后于执行,所以第14行代码打印的是a变量的空值。

以上就是信道同步的四种情况,多个Go程同步与两个Go程同步道理是一样的。

无论有没有缓冲,信道的接收,总是在发送操作之前。我们将信道比作一个管道,有缓存容量的,充许我们在管道里暂时一些数据;没有缓存的,一端发送时,另一端必须有人接收。教科书上有这样一句话,概括了上面四种情况:

对某信道上进行的的第k次容量为C的发送,必发生在第k+C次从该信道进行的接收操作完成之前,其中k>=1,C>=0。

这就是教科书的简洁。

所有源码见:https://gitee.com/rxyk/go-easyopen in new window

(2021年1月14日)

互斥锁与读写锁:如何使用锁完成Go程同步?

image-20210116100849152
image-20210116100849152

图转自https://colobu.com/2018/12/18/dive-into-sync-mutex/open in new window

这张图容易让人产生误解,容易让人误以为goroutine1获取的锁,只有goroutine1能释放,其实不是这样的。“秦失其鹿,天下共逐之”。在这张图中,goroutine1与goroutine2竞争的是一种互斥锁。goroutine1成功获取锁以后,锁变成锁定状态,此时goroutine2也可以解锁。

Go语言中有两种锁:

  • 互斥锁 Mutex
  • 读写锁 RWMutex,也叫单写多读锁

第二个锁虽然与第一个仅有两个字母差异,但其实并非同类,稍后我们会看到。名字带有一定的迷惑性,不要被它骗了。

本来Go语言有信道已经足够了,但互斥锁是一种更为常见的多线程协作方式,在其它语言中既然都有实现,Go语言自然也需要支持。

看到锁,我首先想到了一个问题。

Go语言中的锁是怎么实现的?是基于信道实现的吗?

翻一下在官方源码src/sync/mutex.go,Mutex的结构体是这样定义的:

type Mutex struct {
	state int32
	sema  uint32
}

从这种结构体来看,互斥锁并不是基于信道实现的。

实际上,不要看这个结构体很简单,其实锁内部的实现很复杂。

在互斥锁内部有一种自旋操作,所谓自旋对应于CPU的PAUSE指令,就是让CPU空转30个时钟周期,这期间什么也不干,就是为了等着,等着看锁的状态是否能够切换成功。

麻蛋,CPU很闲吗!CPU上班的时候竟然摸鱼,电费也很贵的好吧。

锁是通过一种特殊的对象,让不同线程可以在指定的时间点实现步伐同步;与信道不同的是,信道是不阻塞Go程的,但锁却会。

具体讲,在Go语言中的两种锁中,普通锁Mutex是互斥锁,顾名思义这种锁就像十字路口的红绿灯,一方通行,一方停止,它会直接阻塞Go程;另一种读写锁RWMutex,这种锁是改进的立交桥版本,只阻塞Go程间的写写、读写,但不阻塞读读,稍后会看到这方面具体的实例,体会它们之间的差异。

所以你看,不仅锁不是基于信道实现的,并且性能还比信道差。虽然它在Go语言编程中不被推荐使用,我们还是需要了解一下,这有助于我们有时候阅读别人不太好理解的代码。

普通锁如何使用?

普通锁就是Mutex,它虽然内部复杂,但对外暴露的方法就是两个:

  • Lock,上锁
  • Unlock,解锁

什么是上锁,要锁住谁?什么是解锁,又解除对谁的控制?

我们看一段代码吧:

package main 

import ("sync")

var l sync.Mutex
var a string

func f() {
	a = "hi, ly"
	l.Unlock() // Unlock 方法解锁 m,如果 m 未加锁会导致运行时错误。
}

func main() {
	l.Lock() // 默认l是零值解锁状态,在这里先加锁
	go f()
	l.Lock() // l 已经加锁,则阻塞直到 l 解锁。
	println(a) // hi,ly
}

源码见:go-easy/并发/锁/mutex1.go

输出是这样的:

hi,ly

在该示例中,第14行的Lock什么意思,它代表main中开始锁住代码吗?那为什么下面main中没有Unlock的代码?为什么第10行的Unlock的操作却在另一个Go程f()中?

对Go语言中锁的理解,不能像SQL的事务那样,不是”开启事务—>干事—>提交或撤消“这样一个过程:

开启事务 {
	… code
	提交 
} catch(错误) {
	撤消事务
}

互斥锁的Lock、Unlock操作,只针对锁对象本身,并非针对Lock、Unlock之外的那些代码。

互斥锁就是用于同步状态的,或者说是用于同步不同Go程间的事件时间点的。就像十字路口的红绿灯的一样,当灯变成红灯后,下一步如果想让它再变成红灯,必须先把它至少变回一次绿灯;而在此之前,要等待,我们正是利用这种等待的特性,实现了Go程间的同步行为。

具体来讲,在上面示例中,第16行l.Lock发生了阻塞,因为此时l已经处于了Locked状态,除非第10行代码l.Unlock将锁的状态先改变,否则第16行的代码不能继续向下走。

而在这个示例中,并不是说我们在main()中调用了l.Lock(这是一个Go程)、在f()中就不能继续读写内存了(这是另一个Go程),事实上我们在f()中仍然可以对变量a进行自由读写。

使用普通互斥锁,同步的是事件时间点,并没有对“Go程对内存的访问”作任何限制。事实上普通互斥锁也没有这种能力。

有一句教科书式的话是这样说的:对于任何 sync.Mutex sync.RWMutex 类型的变量 l ,满足 a < b ,则我们对 l.Unlock() 的第 a 次调用,总是在对 l.Lock() 的第 b 次调用返回前发生。

简单理解这句话,就两条规则:

  • Unlock要发生在Lock之前
  • 如果尚未Lock,直接Unlock,则会抛出异常

有了这句教科书真言,算是如获至宝了,本身它也适用于RWMutex。如果我们想使用RWMutex改写上面的示例,应当如何改写呢?

看一下代码吧:

package main 

import ("sync")

var l sync.RWMutex
var a string = "hi"

func f() {
	// println(a)
	a = "hi, ly"
	l.Unlock() 
}

func main() {
	l.Lock() 
	go f()
	l.Lock() 
	println(a) // hi,ly
}

源码见:go-easy/并发/锁/mutex1-1.go

输出是一样的。

我们仅是在第5行改变了一下变量l的类型,RWMutex也可以当作普通的Mutex使用。

那么加强版本的RWMutex还有哪些其它妙用呢?

如何使用加强版本的读写锁?

普通锁并不能满足所有场景的互斥需求。看一张表格:

读读 √读写 x
写读 x写写 x

有时候我们有多个线程,譬如简单一些有两个线程,我们要限制它们同时写,但不限制它们同时读。这也很容易理解,这种场景多发生在数据库操作或文件操作中。大多数情况下,读表比写表要快,因为读表是可以并发的,而写表因为要力保数据一致,是要锁表的,会产生阻塞。

接下来我们看看一下读写锁的示例吧:

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var l sync.RWMutex
	var data = 1

	for i := 0; i < 10; i++ {
		go func(t int) {
			l.RLock()
			defer l.RUnlock()
			fmt.Printf("Read data: %d %v\n", t, data)
		}(i)
		// if i == 3 {
		// 	time.Sleep(time.Second)
		// }
		go func(t int) {
			l.Lock()
			defer l.Unlock()
			data++
			fmt.Printf("Write Data: %d %v \n", t, data)
		}(i)
	}
	time.Sleep(time.Second)
}

源码见:go-easy/并发/锁/mutex2.go

输出:

Read data: 0 1
Write Data: 1 2 
Read data: 1 2
Read data: 5 2
Read data: 7 2
Read data: 8 2
Read data: 9 2
Read data: 2 2
Read data: 3 2
Read data: 4 2
Read data: 6 2
Write Data: 0 3 
Write Data: 3 4 
Write Data: 4 5 
Write Data: 5 6 
Write Data: 7 7 
Write Data: 6 8 
Write Data: 8 9 
Write Data: 9 10 
Write Data: 2 11 

需要指出的是,这个输出并不是固定的。第一行第一次Read data输出的data有可能是1,也有很大概率是2。为什么输出不固定?当环境一致、输入条件一致时,电脑输出不应该固定吗?电脑不是最诚实的吗?

单线程时电脑确实很诚实,多线程时就不一定了。电脑是人设计的,这方面可能也承袭了人类的缺陷。人类一男一女谈恋爱比较甜蜜简单,多女同追一男,或多男同追一女就容易发生口角或战争。

回到上面的问题,其实不是的,因为本质上这些Go程它们是并发的。第25行data自增代码的执行时间点会与谁对齐,并不固定,完全看当时CPU的心情。

但有一些规范仍然是固定的,譬如:对于任何 sync.RWMutex 类型的变量 l l.RLock 的调用,存在一个这样的 n**,使得** l.RLock 在对 l.Unlock 的第 n 次调用之后发生(返回),且与其相匹配的 l.RUnlock 在对 l.Lock的第 n+1 次调用之前发生。

这句教科书的话理解起来特别费劲,画个图表就是这样的:

Lock … Unlock ….. RLock ... RUnlock … [RLock …… RUnlock …] Lock … Unlock ...

在读写锁上,先明确一下,Lock与Unlock是写的上锁与解锁,RLock与RUnlock是读的上锁与解锁。它只有这4个方法,它没有WLock与WUnlock。

读写锁在读上是不互斥的。所以它允许多个Go程同时RLock与RUnlock,这是合法的;但是一但有一个线程进行了Lock上写锁,所有的读都要停下来,此时Lock就是一个同步的时间点,走过Unlock后,RLock与RUnlock又可以开始活跃了。

读写锁的这种机制有点像中国古代的婚姻制度三妻四妾,家里妻妾成群好比读锁并飞好不热闹,男人好比写锁,男人一来所有妻妾就闭嘴了。

如果我们把mutex2.go中的第19~21行的代码反注释一下,大体输出就会变成这样了:

Read data: 0 1
Read data: 2 1
Write Data: 1 2 
Read data: 3 2
Read data: 1 2
Write Data: 2 3 
Write Data: 0 4 
Read data: 5 4
Read data: 4 4
Write Data: 4 5 
Read data: 6 5
Read data: 7 5
Read data: 8 5
Read data: 9 5
Write Data: 3 6 
Write Data: 9 7 
Write Data: 6 8 
Write Data: 7 9 
Write Data: 5 10 
Write Data: 8 11 

我们看到在这个输出里面,因为在第20行人为添加了休眠时间,将某些读线程与写线程隔开了。但从打印行为上来看,写线程成为了读线程的分隔点。在写线程改变data变量以后,读线程总是能读到改变之后的值。这和数据库的读取写入是同样的道理,改变效果总能得到及时彰显。

在这里有个问题我们思考一下,在第14行开启的读线程内,不可以向内存写入数据吗?

并不是的。我们看一个稍加改造之后的示例代码:

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var l sync.RWMutex
	var data = 1.0

	for i := 0; i < 10; i++ {
		go func(t int) {
			l.RLock()
			defer l.RUnlock()
			data += .1
			fmt.Printf("Read data: %d %v\n", t, data)
		}(i)
		// if i == 3 {
		// 	time.Sleep(time.Second)
		// }
		go func(t int) {
			l.Lock()
			defer l.Unlock()
			data++
			fmt.Printf("Write Data: %d %v \n", t, data)
		}(i)
	}
	time.Sleep(time.Second)
}

源码见:go-easy/并发/锁/mutex2-1.go

第11行将data的默认值修改为1.0,此时它不再是整形了。还有,添加了第17行代码,现在读线程也开始尝试向内存里写入数据了。输出结果是这样的:

Read data: 0 1.1
Write Data: 1 2.1 
Read data: 2 2.2
Read data: 7 2.4000000000000004
Read data: 8 2.5000000000000004
Read data: 6 2.3000000000000003
Read data: 4 2.8000000000000007
Read data: 3 2.900000000000001
Read data: 1 3.000000000000001
Read data: 5 2.7000000000000006
Read data: 9 2.6000000000000005
Write Data: 9 4.000000000000001 
Write Data: 2 5.000000000000001 
Write Data: 3 6.000000000000001 
Write Data: 4 7.000000000000001 
Write Data: 5 8 
Write Data: 6 9 
Write Data: 7 10 
Write Data: 8 11 
Write Data: 0 12 

我们看到,即使是“读”线程,也能写入数据。如果说示例mutex2.go演示的是“多读一写”场景,这个mutex2-1.go示例实际演示的却是“多写”场景。

所以我们看到,虽然“读”线程打印的data并不是严格按照从小到大的顺序打印的,譬如第5行2.5比第6行2.3还要大,因为本质上它们是并发执行的,结果是随机的。但data却是以0.1的步伐均匀递增的,看第2~11行,data从2.2按照0.1的步伐均匀递增到3.0。那一长串零最后面的数字是由于计算精度造成的,可以忽略。

这是为什么?

因为在第17行我们写内存了。第17行代码所在的Go程虽然开启的是读锁,但实际上代码进行了写入,此时的并发场景不是“读读”,而是“写写”了。我们只需要将第17行的代码注释掉,再看一看它的表现就明白了:

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var l sync.RWMutex
	var data = 1.0

	for i := 0; i < 10; i++ {
		go func(t int) {
			l.RLock()
			defer l.RUnlock()
			// data += .1
			fmt.Printf("Read data: %d %v\n", t, data)
		}(i)
		// if i == 3 {
		// 	time.Sleep(time.Second)
		// }
		go func(t int) {
			l.Lock()
			defer l.Unlock()
			data++
			fmt.Printf("Write Data: %d %v \n", t, data)
		}(i)
	}
	time.Sleep(time.Second)
}

源码见:go-easy/并发/锁/mutex2-2.go

输出:

Read data: 0 1
Write Data: 2 2 
Read data: 1 2
Read data: 6 2
Read data: 9 2
Read data: 7 2
Read data: 3 2
Read data: 2 2
Read data: 5 2
Read data: 4 2
Read data: 8 2
Write Data: 1 3 
Write Data: 0 4 
Write Data: 3 5 
Write Data: 4 6 
Write Data: 6 7 
Write Data: 5 8 
Write Data: 7 9 
Write Data: 8 10 
Write Data: 9 11 

看看现在的输出,读锁完全并发了,它们挤在一块执行,只拿到了data等于2。

所以我们看,在使用读写锁时,如果我们向内存写入了,此时开启RLock、与开启Lock是一样的。不了解这一点机制,很容易就写出错误的代码,当然了别人的代码也不易读懂。

互斥锁的早期源码

虽然最新的Mutex源码很复杂,难于理解,但早期的Mutex源码却很简单,可谓是骨骼清奇:

package main

type Mutex struct {
	key  int32
	sema int32
}

func xadd(val *int32, delta int32) (new int32) {
	for {
		v := *val
		// cas这个函数是原子操作,它有三个参数,第一个是目标数据的地址,第二个是目标数据的旧值,第三个则是等待更新的新值。每次CAS都会用old和addr内的数据进行比较,如果数值相等,则执行操作,用new覆盖addr内的旧值,如果数据不相等,则忽略后面的操作。
		if cas(val, v, v+delta) {
			return v + delta
		}
	}
	panic("unreached")
}

func (m *Mutex) Lock() {
	if xadd(&m.key, 1) == 1 {
		// changed from 0 to 1; we hold lock
		return
	}
	// semacquire函数首先检查信号量是否为0:如果大于0,让信号量减一,返回; 
	// 如果等于0,就调用goparkunlock函数,把当前Goroutine放入该sema的等待队列,并把他设为等待状态。
	sys.semacquire(&m.sema)
}

func (m *Mutex) Unlock() {
	if xadd(&m.key, -1) == 0 {
		// changed from 1 to 0; no contention
		return
	}
	// semrelease函数首先让信号量加一,然后检查是否有正在等待的Goroutine: 如果没有,直接返回;
	// 如果有,调用goready函数唤醒一个Goroutine。
	sys.semrelease(&m.sema)
}

源码见:go-easy/并发/锁/SimpleMutex.go

这份源码已经加了注释,很好理解。后来变得复杂,是为了解决多并发线程中容易出现的尾部延迟现象,加入了饥饿模式。有了这种机制,更加加强了并发微线程执行的不确定性。不一定后来的微线程就启动的晚,也不一定早期的微线程就一直没有机会。我们所以看到,mutex2-2.go示例中第18行代码的执行像完全随机一样。理解这种机制就好。

一道题:看懂这道题就理解基本的互斥锁了

我们看一道有意思的题:

package main
import (
    "sync"
    "time"
)
func main() { // g1
    var mu sync.Mutex
    go func() { // g2
        mu.Lock()
        time.Sleep(10 * time.Second)
        mu.Unlock()
    }()
    time.Sleep(time.Second)
    mu.Unlock()
    select {}
}

源码见:go-easy/并发/锁/mutex3.go,这道题来自https://colobu.com/2018/12/18/dive-into-sync-mutex/open in new window

问题是这样的:

如果一个goroutine g1 通过Lock获取了锁, 在 g1 持有锁的期间, 另外一个goroutine g2 调用Unlock释放这个锁, 会出现什么现象?

三个选项:

A、 g2 调用 Unlock panic

B、 g2 调用 Unlock 成功,将来 g1调用 Unlock 会 panic

C、 g2 调用 Unlock 成功,将来 g1调用 Unlock 也成功

应该选择哪一个呢?

答案为B,源码中有注释。

在了解了Go语言的互斥锁和读写锁之后,不知道你是什么想法。是不是感觉锁非常复杂,其实除非逼不得已,不必使用锁。锁既麻烦,效率又低,在Go程同步上完败于信道。

除了信道、互斥锁与读写锁,在Go语言中用于实现微线程同步的还有Once与WaitGroup,这两者它们也是锁吗?这个问题留给你思考一下。

所有源码见:https://gitee.com/rxyk/go-easyopen in new window

欢迎留言。

参考链接

(2021年1月16日)

如何使用Go语言的panic?

Go语言追求简洁优雅,所以,Go语言不支持传统的 try…catch…finally 这种异常,因为Go语言的设计者们认为,将异常与控制结构混在一起会很容易使得代码变得混乱。因为开发者很容易滥用异常,甚至一个小小的错误都抛出一个异常。

什么是异常?异常就是线程失控的错误。

什么是错误?错误发生时,线程仍可继续;错误只是方法返回的一种特殊的数据对象。

在Go语言中,一般使用多值的方式返回错误,例如两个值,前面一个是正常值,后面一个是err对象。Go语言不建议用异常代替错误,Go语言要求所有错误都是可控的;只有在极个别的情况下,在错误不可预知的情况下,才使用接下来我们要谈论的panic机制:

defer, panic, recover

看一个示例代码:

package main

import (
	"fmt"
)

func main() {
	fmt.Println("c")
	defer func() { // 必须要先声明defer,否则不能捕获到panic异常
		fmt.Println("d")
		if err := recover(); err != nil {
			fmt.Println(err) // 这里的err其实就是panic传入的内容
		}
		fmt.Println("e")
	}()
	f()              //开始调用f
	fmt.Println("f") //这里开始下面代码不会再执行
	f2()
}

func f() {
	fmt.Println("a")
	panic("异常信息")
	fmt.Println("b") //这里开始下面代码不会再执行
}

func f2() {
	fmt.Println("f2")
}

执行与输出结果是:

set GO111MODULE=off && go run panic.go 
// output:
c
a
d
异常信息
e

关于这个代码的解析:

panic:

1、内建函数 2、函数f中书写了panic语句,会终止其后要执行的代码。在panic所在函数f内如果存在要执行的defer函数列表,按照defer的逆序,从从向前的顺序执行 3、返回函数f的调用者main,在main中,调用函数f语句之后的代码不会执行。函数main中存在要执行的defer函数列表,按照defer函数定义的逆序执行,这里的 defer 有点类似js语言里 try-catch-finally 中的 finally 4、直到当前goroutine整个退出,并报告错误

recover:

1、内建函数 2、用来控制一个goroutine的panic行为,捕获panic抛出的异常,从而控制线程的行为。不要让线程崩溃,是对线程的解救 3、在defer函数中,通过recover来终止当前goroutine中后续代码panic出来的异常,从而恢复正常代码的执行;在使用recover时,可以获取panic传递的err对象

画个图,它个流程应当是这样的:

image-20210417174542154
image-20210417174542154

参考链接

如何使用Go+?

Go+是一个在Google Go基础之上开源的语言,它的作者是国内2012年开始布道Go语言的大牛许式伟。Go+有两个显著特点:

  1. 作为静态语言,保留了原Go语言的所有特征
  2. 像Python一样,可以作为解析型语言使用

gop需要mod,在执行官方指令前,要确保GO111MODULE已经打开。

set GO111MODULE=on
git clone git@github.com:goplus/gop.git
cd gop
go install -v ./...

完成以后,就可以运行相关指令了。

Commands

gop run         # Run a Go+ program
gop repl        # Run Go+ in REPL/Console mode
gop go [-test]  # Convert Go+ packages into Go packages. If -test specified, it tests related packages.
gop fmt         # Format Go+ packages
gop export      # Export Go packages for Go+ programs

例如:

$ cd tutorial\01-Hello-world
$ gop run hello.gop 
// output:
Hello, world!
Loading...