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 之后、函数真正返回给调用者之前执行。
更准确地说,执行顺序是:
- return 语句的表达式先求值,结果赋给返回值
- 然后执行 defer 函数(LIFO,后进先出)
- 最后函数才把返回值交给调用者
关键推论: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