Go Code Review建议以及自查表
管理Goroutine的生命周期
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:
- 尽量让你的goroutine代码简单清晰,简单到一眼就能看穿其生命周期。
- 对于每个goroutine方法,都应将其生命周期注释清楚,说明其退出的条件。
- 在父级goroutine中使用context来管理子goroutine的声明周期。
- 使用专门的channel通知goroutine结束
channel通知退出模式
以下用我最喜欢的channel通知退出模式来举例说明:
package mainimport ("fmt""math/rand""sync""time"
)type add struct {done chan struct{} //退出信号in chan intout chan intwg sync.WaitGroup
}//每一秒往in里添加一个随机数,当close(a.done)时退出
func (a *add) input() {defer a.wg.Done()for {select {case <-a.done:returncase <-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:returncase i := <-a.in:i++a.out <- i}}
}//打印out里的值,当close(a.done)时退出
func (a *add) output() {defer a.wg.Done()for {select {case <-a.done:returncase 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:returncase <-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: //已经关闭过了returndefault:close(a.done) }
}func (a *add) input() {defer a.wg.Done()for {select {case <-a.done:returncase <-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:returncase <-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:returncase <-time.After(1 * time.Second):rd := rand.Intn(100)if rd > 50 {a.closeDone() return}select {case <- a.done: //如果a.in阻塞了,依旧能通过退出channel来退出returncase a.in <- rd }}}
}
如何使用interface{}
我相信在任何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.gotype 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 animalstype Dog struct{}
func (a Dog) Speaks() string { return "woof" }
package circustype 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 接口类型) 具体结构体
什么时候可以先定义接口再谈具体实现
当然,世事无绝对,在以下的一些特例下,是可以先定义接口的。
- 不可导出的接口
- ADT,就比如上面举的
Sort包的例子 - 递归接口
实际使用当中这些情况不常见,就不在此研究赘述,有兴趣可以阅读How To Use Go Interfaces
注意data race
写Go程序最让人讨厌的bug之一:data race数据竞争。
-race 竞争检测
谁也无法保证自己的程序里面没有数据竞争,虽然 - race会导致额外的开销,建议在生产环境中,也请开启- race竞争检测,这绝对是值得的。
atomic
利用好atomic包,不仅不用手动加锁,效率还高。
最保险的方法:锁
清楚程序中变量会被哪些协程所读/写,如果可能会发生竞争(同时读写/同时写),加锁。如果程序不是十分复杂,可以画出所有协程的启动和结束时间线,检查协程所操作的变量是否可能发生竞争。
小心channel
以为用了channel就不会发生数据竞争了吗?情况并没有那么乐观,而且这是最容易忽视的一个陷阱。
通常,对于一些大变量(比如复杂结构体,slice,map),我们习惯在函数,方法或者channel中,传递他们的引用来避免拷贝。但在某些情况下,往channel中传递引用时相当危险的,考虑下面一个例子:
package main //main.goimport ("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() { //发送,计数器+1ch <- c c.Add() //(1)计数器+1}()go func() { //接收,计数器+1counter := <-chcounter.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() { //发送,计数器+1ch <- *c // <--相当于将对象复制一份 c.Add() //(1)计数器+1}()go func() { //接收,计数器+1counter := <-chcounter.Add() //(2)计数器+1}()select {}
}
我们确保ch接收的是&counter{}指向的值的拷贝,这样(1)跟(2)的c.Add()操作的已经是两个不同的结构体了,就避免了数据竞争。
所以,当在channel中传递引用类型变量的时候(结构体指针,slice,map),如果在发送方发送结束之后,在发送一侧还需要对变量进行修改的时候。应将变量复制一份发送,或者是使用锁(采用哪种方法视乎具体业务实现)。
目录结构
可参考Github上的目录模板
测试Testing
Go的单元测试技巧
#checklist
- 是否了解你程序当中所有协程的生命周期
- 是否所有的协程都有明确的退出条件,并在注释中说明
- 设计代码的时候是否先设计实现,再设计接口
- 是否有过度设计嫌疑
- 是否将接口声明在调用端
- 是否遵循“接受接口,返回具体结构体”准则
- 是否检查了在channel传递引用类型可能发生的数据竞争的情况
- 目录结构是否规范
- 测试[TODO]
Rule 3:规范是死的,代码是灵活的,切记不要迷信规范,要根据实际情况,相信自己的选择
本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!
