Go学习笔记

Interface

Golang中的interface指的是一种抽象的类型,是一组method的集合,里头只有method方法,没有数据成员。当两个或两个以上的类型都有相同的处理方法时才需要用到接口。先定义接口,然后多个struct类型去实现接口里的方法,就可以通过接口变量去调用struct类型里实现的方法。

比如动物都会叫唤,那可以先定义一个名为动物的接口,接口里有叫唤方法speak,然后猫和狗这2个struct类型去实现各自的speak方法。这样就实现了多态。

举例如下。

package main

import "fmt"

// all animals can speak
type Animal interface {
    speak()
}

// cat
type Cat struct {
    name string
    age int
}

func(cat Cat) speak() {
    fmt.Println("cat miaomiaomiao")
}

// dog
type Dog struct {
    name string
    age int
}

func(dog *Dog) speak() {
    fmt.Println("dog wangwangwang")
}


func main() {
    var animal Animal = Cat{"gaffe", 1}
    animal.speak() // cat miaomiaomiao
    
    /*
    因为Dog的speak方法用的是指针接受者,因此给interface赋值的时候,要赋指针
    */
    animal = &Dog{"caiquan", 2}
    animal.speak() // dog wangwangwang
}

如果空interface作为函数参数,可以接受任何类型的实参。如果空interface作为变量,则可以将任何类型的值赋给他。


空接口作为map的值,可以实现map的value是不同的数据类型

dict := make(map[string]interface{}) 

这样的话dict的每一个string可以对应任意值。


Go语言里面有一个语法,可以直接判断是否是该类型的变量: value, ok = element.(T),这里value就是变量的值,ok是一个bool类型,element是interface变量,T是断言的类型。

通过判断ok从而能够判断类型,是安全的写法,被称作Comma-ok模式。

但是这样的写法在判断的类型较多的时候非常繁杂,有特殊的type switch语法,只能在switch中使用,示例如下。

func checkType(x interface{}) {
    /*动态判断x的数据类型*/
    switch v := x.(type) {
    case int:
        fmt.Printf("type: int, value: %v\n", v)
    case string:
        fmt.Printf("type: string,value: %v\n", v)
    case bool:
        fmt.Printf("type: bool, value: %v\n", v)
    case Cat:
        fmt.Printf("type: Cat, value: %v\n", v)
    case map[string]int:
        fmt.Printf("type: map[string]int, value: %v\n", v)
        v["a"] = 10
    default:
        fmt.Printf("type: %T, value: %v\n", x, x)
    }
}

同时interface还有另一个作用是用于限定泛型的范围,示例如下。

package main

import "fmt"

type test interface{
	~int
}

func printInt[T test](items []T){
	fmt.Println(items)
}

func main(){
	list := []int{1,2,3,4,5}
	printInt(list)
}

如果list的类型为float32切片则会报错,interface限制了传入泛型的类型只能是限定接口的类型。

~int表示以type ID int这样方式定义的基于int的新类型也能通过这个筛选。

注意的是,以type ID int的方式定义的类型和底层类型不是同一个类型,不能直接互相赋值,需要显式转换。新类型不继承底层类型的方法,但可以定义自己的方法(这是 Go 面向对象的基础)。

泛型

泛型类型

type Stack[T any] struct {
    items []T
}

其成员函数这样定义

func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

泛型函数

func Filter[T any](items []T, keep func(T) bool) []T {
    result := make([]T, 0, len(items)) //make(type,size,cap)
    for _, item := range items {
        if keep(item) {
            result = append(result, item)
        }
    }
    return result
}

闭包

匿名函数,基本上就是lambda,不同的是闭包中可以直接读取当前位置的变量

i := 1;
go func() {
    fmt.Println(i);
}()

goroutine

使用go 函数名这一语法可以直接调动一个用户态线程goroutine来运行这个函数,从而实现协程的效果。

go会为main函数创建一个默认的goroutine,main()函数结束后所有在main函数中定义的goroutine都会立刻结束

比如下面的代码。

package main

import "fmt"

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

func main() {
	/*开启一个goroutine去执行hello函数*/
    go hello()
    fmt.Println("main end")
}

实际运行可能只输出main end,先main end后hello,先hello 后main end三种结果,在我的电脑上只运行得到了main end。

在闭包中使用goroutine如果多个goroutine使用同一个变量可能会导致与预期结果不符合,如同上文闭包中所用的调用方式会导致这个问题。改成传参形式或者用一个临时变量保存值都可以确保结果的正确性。

Channel

用于goroutine之间进行通信。一个goroutine可以发送数据到指定channel,其它goroutine可以从这个channel里接收数据。

遵循FIFO,定义Channel时要定义Channel要传递的元素类型

/*channel_name是变量名,data_type是通道里的数据类型
channel_size是channel通道缓冲区的容量,表示最多可以存放的元素个数,这个参数是可选的,不给就表示没有缓冲区,通过cap()函数可以获取channel的容量
*/
var channel_name chan data_type = make(chan data_type, [channel_size])

发送值到channel:channel <- value

从channel接收值:value <- channel

关闭通道: close(channel),关闭nil channel会触发panic: close of nil channel


channel无缓冲区的时候,往channel发送数据和从channel接收数据都会阻塞。

往channel发送数据的时候,必须有其它goroutine从channel里接收了数据,发送操作才可以成功,发送操作所在的goroutine才能继续往下执行。从channel里接收数据也是同理,必须有其它goroutine往channel里发送了数据,接收操作才可以成功,接收操作所在的goroutine才能继续往下执行。

对于有缓冲区的channel,对发送方而言:

如果缓冲区未满,那发送方发送数据到channel缓冲区后,就可以继续往下执行,不用阻塞等待接收方从channel里接收数据。 如果缓冲区已满,那发送方发送数据到channel会阻塞,直到接收方从channel里接收了数据,这样缓冲区才有空间存储发送方发送的数据,发送方所在goroutine才能继续往下执行。


可以用range不断从channel中取数据。

package main

import "fmt"
import "time"


func addData(ch chan int) {
	/*
	每3秒往通道ch里发送一次数据
	*/
	size := cap(ch)
	for i:=0; i<size; i++ {
		ch <- i
		time.Sleep(3*time.Second)
	}
	// 数据发送完毕,关闭通道
	close(ch)
}


func main() {
	ch := make(chan int, 10)
	// 开启一个goroutine,用于往通道ch里发送数据
	go addData(ch)

	/* range迭代从通道ch里获取数据
	通道close后,range迭代取完通道里的值后,循环会自动结束
	*/
	for i := range ch {
		fmt.Println(i)
	}
}

如果channel作为函数的形参,可以控制限制数据和channel之间的数据流向,控制只能往channel发送数据或者只能从channel接收数据。

不做限制的时候,channel是双向的,既可以往channel写数据,也可以从channel读数据。

语法

chan <- int // 只写,只能往channel写数据,不能从channel读数据
<- chan int // 只读,只能从channel读数据,不能往channel写数据

Defer

defer是延迟的意思,在Go里可以放在某个函数或者方法调用的前面,让该函数或方法延迟执行

defer本身是在某个函数体内执行,比如在函数A内调用了defer func_name(),只要defer func_name()这行代码被执行到了,那func_name这个函数就会被延迟到函数A return或者panic之前执行。

注意:如果是函数是因为调用了os.Exit()而退出,那defer就不会被执行了。

如果在函数内调用了多次defer,那在函数return之前,defer的函数调用满足LIFO原则,先defer的函数后执行,后defer的函数先执行。比如在函数A内先后执行了defer f1(), defer f2(), defer f3(),那函数A return之前,会按照f3(), f2(), f1()的顺序执行,再return。

defer常用于成对的操作,比如文件打开后要关闭、锁的申请和释放、sync.WaitGroup跟踪的goroutine的计数器的释放等。为了确保资源被释放,可以结合defer一起使用,避免在代码的各种条件分支里去释放资源,容易遗漏和出错。

defer结合goroutine和闭包一起使用,可以让任务函数内部不用关心Go并发里的同步原语

package main

import (
    "fmt"
    "sync"
)

func worker(id int) {
    fmt.Println(id)
}

func main() {
    var wg sync.WaitGroup
    size := 10
    wg.Add(size)
    
    for i:=0; i<size; i++ {
        i := i 
        /*把worker的调用和defer放在一个闭包里
        这样worker函数内部就不用使用WaitGroup了
        */
        go func() {
            defer wg.Done()
            worker(i)
        }()
    }
    
    wg.Wait()
}

defer函数在 return 之后、函数真正返回给调用者之前执行。

更准确地说,执行顺序是:

  1. return 语句的表达式先求值,结果赋给返回值
  2. 然后执行 defer 函数(LIFO,后进先出)
  3. 最后函数才把返回值交给调用者

关键推论:defer 能修改命名返回值

func f() (result int) {
    defer func() { result++ }()  // 修改返回值
    return 5
}
// f() 返回 6,不是 5!

匿名返回值不受影响

sync.WaitGroup

参考上面的代码,可以看到是用于等待若干个goroutine执行完成后进行操作的。

注意不要传值,要传到函数中需要传指针

sync.Once

Once可以在并发场景下让某个操作只执行一次,比如设计模式里的单例只创建一个实例,比如只加载一次配置文件,比如对同一个channel只关闭一次(对一个已经close的channel再次close会引发panic)等。

package main

import (
    "fmt"
    "sync"
)

func print() {
    fmt.Println("test once")
}

func main() {
    var wg sync.WaitGroup
    var once sync.Once
    size := 10
    wg.Add(size)
    
    /*启用size个goroutine,每个goroutine都调用once.Do(print)
    最终print只会执行一次
    */
    for i:=0; i<size; i++ {
        go func() {
            defer wg.Done()
            once.Do(print)
        }()
    }
    /*等待所有goroutine执行完成*/
    wg.Wait()
    fmt.Println("end")
}

如果once.Do(f)方法调用的函数f发生了panic,那Do也会认为函数f已经return了。

如果多个goroutine执行了都去调用once.Do(f),只有某次的函数f调用返回了,所有Do方法调用才会返回,否则Do方法会一直阻塞等待。如果在f里继续调用同一个once变量的Do方法,就会死锁了,因为Do在等待f返回,f又在等待Do返回。

sync.Mutex & sync.RWMutex

对于Mutex来说

Lock()加锁。Lock()方法会把Mutex变量m锁住,如果m已经锁住了,如果再次调用Lock()就会阻塞,直到锁释放。

Unlock()解锁。Unlock()方法会把Mutex变量m解锁,如果m没有被锁,还去调用Unlock,会遇到runtime error。

对于RWMutex来说

Lock(),加写锁。某个goroutine加了写锁后,其它goroutine不能获取读锁,也不能获取写锁

Unlock(),释放写锁。

RLock(),加读锁。某个goroutine加了读锁后,其它goroutine可以获取读锁,但是不能获取写锁

RUnlock(),释放读锁

RLocker(),获取一个类型为Locker的接口,Locker类型定义了Lock()和Unlock()方法

sync.Cond

sync.Cond可以用于控制goroutine什么时候开始执行。

func NewCond(l Locker) *Cond 用于创建条件变量,条件变量的成员L是NewCond函数的参数l

Broadcast,发出广播,唤醒所有等待条件变量c的goroutine开始执行。注意:在调用Broadcast方法之前,要确保目标goroutine处于Wait阻塞状态,不然会出现死锁问题。

Signal,发出信号,唤醒某一个等待条件变量c的goroutine开始执行。注意:在调用Signal方法之前,要确保目标goroutine处于Wait阻塞状态,不然会出现死锁问题。

Wait,这个方法会解锁c.L以及阻塞当前goroutine往下执行,解锁和阻塞组合在一起构成原子操作。Wait被Broadcast或者Signal唤醒时,会先对c.L加锁,然后Wait才return返回。

每个Cond变量都有一个Locker类型的成员L,L通常是Mutex或者RWMutex类型,调用Wait方法时要对L加锁。

示例

package main

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


func main() {
    var wg sync.WaitGroup
    /**/
    var mutex sync.Mutex
    cond := sync.NewCond(&mutex)
    size := 10
    wg.Add(size+1)
    
    for i:=0; i<size; i++ {
        i := i
        go func() {
            defer wg.Done()
            /*调用Wait方法时,要对L加锁*/
            cond.L.Lock()
            fmt.Printf("%d ready\n", i)
            /*Wait实际上是会先解锁cond.L,再阻塞当前goroutine
            这样其它goroutine调用上面的cond.L.Lock()才能加锁成功,才能进一步执行到Wait方法,
            等待被Broadcast或者signal唤醒。
            Wait被Broadcast或者Signal唤醒的时候,会再次对cond.L加锁,加锁后Wait才会return
            */
            cond.Wait()
            fmt.Printf("%d done\n", i)
            cond.L.Unlock()
        }()
    }
    
    /*这里sleep 2秒,确保目标goroutine都处于Wait阻塞状态
    如果调用Broadcast之前,目标goroutine不是处于Wait状态,会死锁
    */
    time.Sleep(2*time.Second)
    go func() {
        defer wg.Done()
        cond.Broadcast()
    }()
    wg.Wait()
}

sync.Map

go的Map本身不是并发安全的,换用sync.Map保证并发安全

Go官方设计sync.Map主要满足以下2个场景的用途

  • 每个key只写一次,其它对该key的操作都是读操作
  • 多个goroutine同时读写map,但是每个goroutine只读写各自的keys

以上2种场景,相对于对普通的map加Mutex或者RWMutex来实现并发安全,使用sync.Map不用在业务代码里加锁,会大幅减少锁竞争,提升性能。其它更为常见的场景还是使用普通的Map,搭配Mutex或者RWMutex来使用。

sync/atomic

Go语言标准库中的sync/atomic包提供了偏底层的原子内存原语(atomic memory primitives),用于实现同步算法,其本质是将底层CPU提供的原子操作指令封装成了Go函数。

使用sync/atomic提供的原子操作可以确保在任意时刻只有一个goroutine对变量进行操作,避免并发冲突。

使用sync/atomic需要特别小心,Go官方建议只有在一些偏底层的应用场景里才去使用sync/atomic,其它场景建议使用channel或者sync包里的锁。

sync/atomic提供了5种类型的原子操作和1个Value类型。

5种类型的原子操作

  • swap操作:SwapXXX
  • compare-and-swap操作:CompareAndSwapXXX
  • add操作:AddXXX
  • load操作:LoadXXX
  • store操作:StoreXXX 这几种类型的原子操作只支持几个基本的数据类型。

add操作的Addxxx函数只支持int32, int64, uint32, uint64, uintptr这5种基本数据类型。

其它类型的操作函数只支持int32, int64, uint32, uint64, uintptr, unsafe.Pointer这6种基本数据类型。

package & module

package本质上就是一个目录,目录里包含有一个或者多个Go源程序文件,或者package。也就是说package里面还可以嵌套包含子package。

每个Go源文件都属于一个package,在源文件开头指定package名称

同一目录下的所有 .go 文件必须属于同一个包。一个 package 的源码就分布在一个目录下的多个文件里,这些文件之间可以直接互相访问对方的函数和变量,不需要 import。

package里的变量、函数、结构体、方法等如果要被本package外的程序引用,需要在命名的时候首字母大写。小写开头则为私有。

import (
    f "fmt"        // 别名: f.Println("hi")
    . "math"       // 点导入: 直接写 Sqrt(4),不推荐
    _ "net/http/pprof"  // 匿名导入: 只用它的 init() 副作用
)

每个文件(以及每个包)可以有一个 init(),在 main() 之前自动执行,常用于初始化配置、注册驱动:

// 包被导入时自动执行,不需要也不允许手动调用
func init() {
    flag.Parse()
}

执行顺序:被导入的包先于导入方,同包内按文件名字母序。

  • 只有 package main 才能编译成可执行程序
  • 必须有一个 func main(),这是程序入口
  • 其他包编译出来都是库,不能直接运行

internal/ 目录下的包只能被它的父目录树内的代码导入——Go 编译器强制执行,不是靠 lint:

// 这是非法的:在 myproject 外导入 myproject/internal/db 会编译失败

适合放不想被外部依赖的私有实现。

Module是版本管理的单元,对应一个go.mod,里面可以有很多package

myproject/              ← module (有 go.mod)
├── go.mod              ← module myproject
├── main.go             ← package main
├── internal/
│   └── db/
│       └── db.go       ← package db (import 路径: myproject/internal/db)
└── util/
    └── util.go         ← package util (import 路径: myproject/util)

panic & recover

go的异常处理

异常处理用panic()抛出。

如果在函数F里,显式调用了panic或者函数F执行过程中出现运行期错误,那F的执行会终止,接下来会有以下行为依次产生:

  • F里被defer的函数会执行。
  • F的上一级函数,也就是调用F的函数,假设是函数E。对函数E而言,对F的调用就等同于调用了panic,函数E里被defer的函数被执行
  • 如果函数E还有上一级函数,就继续往上,每一级函数里被defer的函数都被执行,直到没有上一级函数。
  • 经过了以上步骤,panic的错误就会被抛出来,整个程序结束。

stack overflow和out of memory这些错误会直接crash不panic

recover 可以捕获panic异常。recover必须结合defer一起使用才能生效。

程序正常执行过程中,没有panic产生,这时调用recover函数会返回nil,除此之外,没有其它任何效果。

如果当前goroutine触发了panic,可以在代码的适当位置调用recover函数捕获异常,让程序继续正常执行,而不是异常终止。

示例

package main

import (
	"fmt"
)

func a() {
	defer func() {
		/*捕获函数a内部的panic*/
		r := recover()
		fmt.Println("panic recover", r)
	}()
	panic(1)
}

func main() {
	defer func() {
		/*因为函数a的panic已经被函数a内部的recover捕获了
		所以main里的recover捕获不到异常,r的值是nil*/
		r := recover()
		fmt.Println("main recover", r)
	}()
	a()
	fmt.Println("main")
}

recover在以下几种情况返回nil

panic的参数是nil。这种情况recover捕获后,拿到的返回值也是nil。 goroutine没有panic产生。没有panic,那当然recover拿到的也就是nil了。 recover不是在被defer的函数里面被直接调用执行。

Select

select语义是和channel绑定在一起使用的,select可以实现从多个channel收发数据。

语法上和switch类似,有case分支和default分支,只不过select的每个case后面跟的是channel的收发操作。

在执行select语句的时候,如果当下那个时间点没有一个case满足条件,就会走default分支。

至多只能有一个 default分支。

如果没有default分支,select语句就会阻塞,直到某一个case满足条件。

如果select里任何case和default分支都没有,就会一直阻塞。

如果多个case同时满足,select会随机选一个case执行。

示例

func b() {
	ch1 := make(chan int, 10)
	ch2 := make(chan int, 10)
	go func() {
		for i:=0; i<10; i++ {
			ch1 <- i
			ch2 <- i
		}
	}()
	for i := 0; i < 10; i++ {
		select {
		case x := <-ch1:
			fmt.Printf("receive %d from channel 1\n", x)
		case y := <-ch2:
			fmt.Printf("receive %d from channel 2\n", y)
		}
	}
}

单元测试

xxx_test.go中编写单元测试代码,要引入testing包,函数以Test开头,示例如下。

package main

import (
    "testing"
)

func TestReverse(t *testing.T) {
    testcases := []struct {
        in, want string
    }{
        {"Hello, world", "dlrow ,olleH"},
        {" ", " "},
        {"!12345", "54321!"},
    }
    for _, tc := range testcases {
        rev := Reverse(tc.in)
        if rev != tc.want {
                t.Errorf("Reverse: %q, want %q", rev, tc.want)
        }
    }
}

使用go test运行所有测试。

模糊测试

fuzzing的优点之一是可以基于开发者代码里指定的测试输入作为基础数据,进一步自动生成新的随机测试数据,用来发现指定测试输入没有覆盖到的边界情况。

函数以Fuzz开头,示例如下

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc)  // Use f.Add to provide a seed corpus
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

f.Add函数把指定输入作为模糊测试的种子语料库(seed corpus),fuzzing基于种子语料库生成随机输入。

f.Fuzz函数接收一个fuzz target函数作为入参。fuzz target函数有多个参数,第一个参数是*testing.T,其它参数是被模糊的类型(注意:被模糊的类型目前只支持部分内置类型, 列在 Go Fuzzing docs,未来会支持更多的内置类型)。

直接使用go test只会测试种子语料库,go test -fuzz才会生成随机测试数据。

$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
fuzz: minimizing 38-byte failing input file...
--- FAIL: FuzzReverse (0.01s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:20: Reverse produced invalid UTF-8 string "\x9c\xdd"

    Failing input written to testdata/fuzz/FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
    To re-run:
    go test -run=FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
FAIL
exit status 1
FAIL    example/fuzz  0.030s

上面的fuzzing测试结果是FAIL,引起FAIL的输入数据被写到了一个语料库文件里。下次运行go test命令的时候,即使没有-fuzz参数,这个语料库文件里的测试数据也会被用到。

可以用文本编辑器打开testdata/fuzz/FuzzReverse目录下的文件,看看引起Fuzzing测试失败的测试数据长什么样。下面是一个示例文件,你那边运行后得到的测试数据可能和这个不一样,但文件里的内容格式会是一样的。

性能测试

性能测试同理,以Benchmark开头的函数,传入参数为b *testing.B,运行需要-bench

b.N是由框架自动决定的测试大小,框架会自动调整循环次数,直到测得稳定的耗时(默认每次至少跑 1 秒)。

go test -bench=.              # 跑当前包所有 benchmark
go test -bench=Sum            # 只跑名字匹配 Sum 的
go test -bench=. -benchmem    # 附带内存分配统计(强烈推荐)
go test -bench=. -count=3     # 重复 3 次,看波动
go test -bench=. -benchtime=10s   # 每次跑 10 秒而非默认 1 秒
go test -run=^$ -bench=.      # -run 排除普通测试,只跑 benchmark

用b.ResetTimer()可以将setup用的时间排除在外,除此之外还有b.StopTimer()和b.StartTimer()


输出解读

BenchmarkSum-8    1000000    1234 ns/op    0 B/op    0 allocs/op
  • -8:使用的CPU核数(GOMAXPROCS)
  • 1000000:b.N,即循环次数
  • 1234 ns/op:每次操作耗时
  • 0 B/op:每次操作分配的内存
  • 0 allocs/op:每次操作的内存分配次数

优化benchmark的核心目标就是让B/op和allocs/op归零——减少内存分配是Go性能优化的第一要务。

子基准测试

func BenchmarkSum(b *testing.B) {
    for _, size := range []int{10, 100, 1000} {
        b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
            nums := make([]int, size)
            for i := 0; i < b.N; i++ {
                Sum(nums)
            }
        })
    }
}
/*
输出:

BenchmarkSum/size=10-8      ...
BenchmarkSum/size=100-8     ...
BenchmarkSum/size=1000-8    ...
*/

可以只跑某个子项:go test -bench=‘Sum/size=100’

并行基准测试

func BenchmarkSumParallel(b *testing.B) {
    nums := make([]int, 1000)
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {      // 各 goroutine 循环直到次数耗尽
            Sum(nums)
        }
    })
}

b.RunParallel 会起 GOMAXPROCS 个 goroutine 并发执行,适合测并发场景下的性能。

对比优化前后

// 同一份代码里写两个版本,跑完后用 benchstat 对比
func BenchmarkSumOld(b *testing.B) { /* 旧实现 */ }
func BenchmarkSumNew(b *testing.B) { /* 新实现 */ }
go test -bench=. -count=10 | tee old.txt
# 改代码后
go test -bench=. -count=10 | tee new.txt
benchstat old.txt new.txt   # 需要安装 golang.org/x/perf/cmd/benchstat

输出会显示差异百分比和置信区间,判断性能变化是否显著。

配合pprof分析性能瓶颈

go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
go tool pprof cpu.out