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:

  1. 尽量让你的goroutine代码简单清晰,简单到一眼就能看穿其生命周期。
  2. 对于每个goroutine方法,都应将其生命周期注释清楚,说明其退出的条件。
  3. 在父级goroutine中使用context来管理子goroutine的声明周期。
  4. 使用专门的channel通知goroutine结束

channel通知退出模式

以下用我最喜欢的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  
			}
		
		}
	}
}

如何使用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.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 接口类型) 具体结构体

什么时候可以先定义接口再谈具体实现

当然,世事无绝对,在以下的一些特例下,是可以先定义接口的。

  1. 不可导出的接口
  2. ADT,就比如上面举的Sort包的例子
  3. 递归接口

实际使用当中这些情况不常见,就不在此研究赘述,有兴趣可以阅读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.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上的目录模板

测试Testing

Go的单元测试技巧

#checklist

  1. 是否了解你程序当中所有协程的生命周期
  2. 是否所有的协程都有明确的退出条件,并在注释中说明
  3. 设计代码的时候是否先设计实现,再设计接口
  4. 是否有过度设计嫌疑
  5. 是否将接口声明在调用端
  6. 是否遵循“接受接口,返回具体结构体”准则
  7. 是否检查了在channel传递引用类型可能发生的数据竞争的情况
  8. 目录结构是否规范
  9. 测试[TODO]

Rule 3:规范是死的,代码是灵活的,切记不要迷信规范,要根据实际情况,相信自己的选择

你可能感兴趣的:(Go)