Rule 1:永远不要开始一个你无法确他何时退出的协程
一个无法退出的goroutine可能是阻塞在某些channel上了,对于这种永久阻塞,GC并不会将goroutine停止,并释放内存,这将导致goroutine leak 内存泄漏。
Goroutines can leak by blocking on channel sends or receives: the garbage collector will not terminate a goroutine even if the channels it is blocked on are unreachable.
https://github.com/golang/go/wiki/CodeReviewComments#goroutine-lifetimes
即使goroutine并没有阻塞在channel,而是在期望他已关闭的场景下继续运行。这将可能导致很难诊断的错误,可能会引起panic或者是data races,也可能会导致内存飙升等一系列头疼问题。
如何管理goroutine声明周期?可以尝试一下的tips:
以下用我最喜欢的channel通知退出模式来举例说明:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
type add struct {
done chan struct{} //退出信号
in chan int
out chan int
wg sync.WaitGroup
}
//每一秒往in里添加一个随机数,当close(a.done)时退出
func (a *add) input() {
defer a.wg.Done()
for {
select {
case <-a.done:
return
case <-time.After(1 * time.Second):
a.in <- rand.Intn(100)
}
}
}
//从in里取出内容,并+1,写入out,当close(a.done)时退出
func (a *add) process() {
defer a.wg.Done()
for {
select {
case <-a.done:
return
case i := <-a.in:
i++
a.out <- i
}
}
}
//打印out里的值,当close(a.done)时退出
func (a *add) output() {
defer a.wg.Done()
for {
select {
case <-a.done:
return
case o := <-a.out:
fmt.Println(o)
}
}
}
func main() {
a := &add{done: make(chan struct{}), in: make(chan int, 5), out: make(chan int, 5)}
a.wg.Add(3)
go a.input()
go a.process()
go a.output()
<-time.After(5 * time.Second) //5秒后结束
close(a.done)
a.wg.Wait() //等待goroutine全部结束
fmt.Println("结束")
}
在上例的程序中,我们启用了3个协程,设置定时器在5秒后全部结束,通过close的广播机制,实现了父协程(main方法)对子协程的生命周期管理。但这段程序还有一点小问题:只能由唯一的父协程来决定子协程的生死,然而在实际应用中,input() process() output()
这种兄弟goroutine是一个可工作的goroutine集合,任意一个协程都不能脱离其他兄弟协程单独运行(虽然可以运行,但是没有实际的业务意义)。也就是说我们要赋予协程结束其他兄弟协程的能力。
我们稍微改动一下上列的程序:我们设定当input的输入值>50,或者五秒之后
,结束全部goroutine。
改动一下input方法:
//每一秒往in里添加一个随机数,当close(a.done)时退出
func (a *add) input() {
defer a.wg.Done()
for {
select {
case <-a.done:
return
case <-time.After(1 * time.Second):
rd := rand.Intn(100)
if rd > 50 {
close(a.done)
return
}
a.in <- rand.Intn(100)
}
}
}
//main方法保持不变
func main() {
a := &add{done: make(chan struct{}), in: make(chan int, 5), out: make(chan int, 5)}
a.wg.Add(3)
go a.input()
go a.process()
go a.output()
<-time.After(5 * time.Second) //5秒后结束
close(a.done)
a.wg.Wait() //等待goroutine全部结束
fmt.Println("结束")
}
以上程序已经赋予了input协程通知其他兄弟协程退出的能力,但还不够完美。细心的我们已经发现:我们在两个协程里面都调用了close(a.done)
,这意味着a.done
很可能会重复关闭,产生panic。再改一下,添加一个closeDone()
方法:
func (a *add) closeDone() {
select {
case <-a.done: //已经关闭过了
return
default:
close(a.done)
}
}
func (a *add) input() {
defer a.wg.Done()
for {
select {
case <-a.done:
return
case <-time.After(1 * time.Second):
rd := rand.Intn(100)
if rd > 50 {
a.closeDone() //修改此处
return
}
a.in <- rd
}
}
}
func main() {
a := &add{done: make(chan struct{}), in: make(chan int, 5), out: make(chan int, 5)}
a.wg.Add(3)
go a.input()
go a.process()
go a.output()
<-time.After(5 * time.Second) //5秒后结束
a.closeDone() //<--修改此处
a.wg.Wait() //等待goroutine全部结束
fmt.Println("结束")
}
至此,我们已经实现了一个比较完善的协程生命周期管理的机制。
注意,我们仍需要关注这样一种情况:
func (a *add) input() {
defer a.wg.Done()
for {
select {
case <-a.done:
return
case <-time.After(1 * time.Second):
rd := rand.Intn(100)
if rd > 50 {
a.closeDone()
return
}
a.in <- rd //<---看这一行
}
}
}
如果a.in缓冲通道已满,而读in通道的协程output()
已经退出。input()
协程将无法退出,会永远阻塞在a.in <- rd
上,从而造成goroutine leak。所以,在使用退出channel通知机制的情况下,对于每一个可能造成阻塞的channel,都需要额外的小心。
继续修改我们的程序,解决上面的问题:
func (a *add) input() {
defer a.wg.Done()
for {
select {
case <-a.done:
return
case <-time.After(1 * time.Second):
rd := rand.Intn(100)
if rd > 50 {
a.closeDone()
return
}
select {
case <- a.done: //如果a.in阻塞了,依旧能通过退出channel来退出
return
case a.in <- rd
}
}
}
}
我相信在任何OOP语言中使用过接口的人,都经有过这种感觉:兴高采烈的分析好需求,写完接口,却发现在写接口实现类的时候困难重重,最后把接口改的面目全非。
Rule 2:预先定义接口通常是一种过度设计
Go中采用隐式接口其实就是想要鼓励大家先设计具体的实现,然后在有必要的情况下,再写接口去满足具体实现。
Do not define interfaces before they are used: without a realistic example of usage, it is too difficult to see whether an interface is even necessary, let alone what methods it ought to contain.
不要在还没有具体实现的时候去定义接口,因为我们很难确定这个接口是不是必要的,更别说接口里应该包含什么方法了。
https://github.com/golang/go/wiki/CodeReviewComments#interfaces
我认为这种后定义接口的思维方式也值得应用在其他OOP的编程语言当中。
将接口定义在调用端能减少依赖,以下引用How To Use Go Interfaces一文中的例子加以说明:
错误示范:
package animals //动物 animals.go
type Animal interface {
Speaks() string
}
type Dog struct{}
func (a Dog) Speaks() string { return "woof" }
package circus //马戏团 circus.go
import "animals"
func Perform(a animal.Animal) string { return a.Speaks() }
以上例子将Animal接口定义在了接口的实现端,让我们分析一下这种做法的一些问题。
上述例子中表现出了这样一种包依赖关系,在circus中import了animals。而这显然不是我们所期望的结果,正确的依赖关系应当是:circus依赖Animal接口,并不需要关注其具体实现(并不需要import "animals"
)。
通过Go隐式接口的特性,我们将程序修改一下。
正确做法:
package animals
type Dog struct{}
func (a Dog) Speaks() string { return "woof" }
package circus
type Speaker interface {
Speaks() string
}
func Perform(a Speaker) string { return a.Speaks() }
现在,我们把接口定义在了使用端,可以看到,circus已经不再需要import "animals"
了,从而降低了包之间的依赖关系。
如果感到上面的例子讲述的还不够清晰,可以举官方包sort
包中的sort.go
为例:
type Interface interface {
// Len is the number of elements in the collection.
Len() int
// Less reports whether the element with
// index i should sort before the element with index j.
Less(i, j int) bool
// Swap swaps the elements with indexes i and j.
Swap(i, j int)
}
...
// Sort sorts data.
// It makes one call to data.Len to determine n, and O(n*log(n)) calls to
// data.Less and data.Swap. The sort is not guaranteed to be stable.
func Sort(data Interface) {
n := data.Len()
quickSort(data, 0, n, maxDepth(n))
}
实现sort.Interface
接口的结构体均可以由sort
包提供的Sort()
方法进行排序。实现端千千万万,总不能把接口实现在实现端把?
“Accept interfaces, return structs”
https://blog.chewxy.com/2018/03/18/golang-interfaces/
大体上来说,这有助于设计出更具健壮性的程序。符合这项准则的Go函数签名可以由如下的例子表示:
func funcName(a 接口类型) 具体结构体
当然,世事无绝对,在以下的一些特例下,是可以先定义接口的。
Sort
包的例子实际使用当中这些情况不常见,就不在此研究赘述,有兴趣可以阅读How To Use Go Interfaces
写Go程序最让人讨厌的bug之一:data race数据竞争。
谁也无法保证自己的程序里面没有数据竞争,虽然 - race
会导致额外的开销,建议在生产环境中,也请开启- race
竞争检测,这绝对是值得的。
利用好atomic包,不仅不用手动加锁,效率还高。
清楚程序中变量会被哪些协程所读/写,如果可能会发生竞争(同时读写/同时写),加锁。如果程序不是十分复杂,可以画出所有协程的启动和结束时间线,检查协程所操作的变量是否可能发生竞争。
以为用了channel就不会发生数据竞争了吗?情况并没有那么乐观,而且这是最容易忽视的一个陷阱。
通常,对于一些大变量(比如复杂结构体,slice,map),我们习惯在函数,方法或者channel中,传递他们的引用来避免拷贝。但在某些情况下,往channel中传递引用时相当危险的,考虑下面一个例子:
package main //main.go
import (
"fmt"
"time"
)
//简单计数器
type counter struct {
times int
}
//计数器+1
func (c *counter) Add () {
c.times++
}
func main() {
c := &counter{}
ch := make(chan *counter, 10)
go func() { //发送,计数器+1
ch <- c
c.Add() //(1)计数器+1
}()
go func() { //接收,计数器+1
counter := <-ch
counter.Add() //(2)计数器+1
}()
select {}
}
执行go run -race main.go
,会发现c.times出现了数据竞争。这里是因为ch
中传递的是&counter{}
,所以在发送和接收两个协程中,(1)跟(2)的c.Add()
操作的还是同一个结构体,同一个变量(同一个内存地址),自然而然就发生竞争了。
我们改进一下main方法:
func main() {
c := &counter{}
ch := make(chan counter, 10) //不再是指针类型,传值
go func() { //发送,计数器+1
ch <- *c // <--相当于将对象复制一份
c.Add() //(1)计数器+1
}()
go func() { //接收,计数器+1
counter := <-ch
counter.Add() //(2)计数器+1
}()
select {}
}
我们确保ch
接收的是&counter{}
指向的值的拷贝,这样(1)跟(2)的c.Add()
操作的已经是两个不同的结构体了,就避免了数据竞争。
所以,当在channel中传递引用类型变量的时候(结构体指针,slice,map),如果在发送方发送结束之后,在发送一侧还需要对变量进行修改的时候。应将变量复制一份发送,或者是使用锁(采用哪种方法视乎具体业务实现)。
可参考Github上的目录模板
Go的单元测试技巧
#checklist
Rule 3:规范是死的,代码是灵活的,切记不要迷信规范,要根据实际情况,相信自己的选择