Go语言这几年越来越火,特别是在云计算、微服务、分布式领域,Go语言已经成为了主流语言之一,很多公司都在用Go做后端开发。我用Go也有两年多了,从一开始的入门,到后来的进阶,踩了不少坑,也积累了一些经验和技巧。今天就来分享一些Go语言入门进阶的技巧,这些技巧可能是你平时没注意到的,但是掌握了之后,能让你的Go代码更优雅、更高效、更地道。

一、接口的妙用:小接口,大能量

Go语言的接口和其他语言不太一样,它是隐式实现的,不需要像Java那样显式声明implements,只要一个类型实现了接口的所有方法,它就自动实现了这个接口。这个特性让Go的接口非常灵活,非常强大,但是很多初学者用不好,总是把接口定义得很大,很复杂,没有发挥出Go接口的优势。

Go语言的接口哲学是"小接口",接口越小越抽象,越灵活,越强大。标准库里的接口都很小,比如io.Reader只有一个Read方法,io.Writer只有一个Write方法,error只有一个Error方法,但是这些小接口却非常强大,几乎无处不在,用得非常广泛。

为什么小接口好?第一,小接口容易实现,任何类型只要实现一两个方法,就能实现这个接口,复用性高。第二,小接口职责单一,符合单一职责原则,一个接口只做一件事。第三,小接口灵活,可以组合,比如io.ReadWriter就是io.Reader和io.Writer的组合,通过组合小接口,可以得到大接口,但是不需要一开始就定义大接口。

所以写Go代码的时候,要习惯定义小接口,不要一开始就定义一个有很多方法的大接口。需要什么方法就定义什么方法,用的时候再组合。比如你需要一个能读的对象,就定义一个Reader,需要能写的,就定义一个Writer,需要既能读又能写的,就组合一下,而不是一开始就定义一个ReadWriteCloser。

还有一个技巧是,接受接口,返回结构体。也就是函数的参数尽量用接口类型,这样更灵活,可以接受任何实现了这个接口的类型;函数的返回值尽量用具体的结构体类型,这样调用者可以使用结构体的所有方法,更方便。当然,这不是绝对的,有时候返回接口更好,比如需要返回多种实现的时候,但是大部分情况下,接受接口返回结构体是一个好的实践。

二、并发编程:goroutine和channel的正确姿势

并发是Go语言最大的特色,goroutine和channel让并发编程变得非常简单,但是简单不代表不容易出错,很多初学者用goroutine和channel的时候,经常会写出有问题的代码,比如goroutine泄漏、channel死锁、数据竞争等等。

首先说goroutine泄漏。goroutine泄漏就是goroutine启动之后,一直没有退出,占用着资源,时间长了就会导致内存泄漏,程序崩溃。最常见的goroutine泄漏原因是,goroutine在等一个channel,但是这个channel永远不会有数据发过来,也不会被关闭,所以goroutine就一直等在那里,永远退出不了。

比如这样的代码:

func leak() {
    ch := make(chan int)
    go func() {
        val := <-ch // 一直等,永远等不到
        fmt.Println(val)
    }()
    // 没有往ch发数据,也没有关闭ch,goroutine泄漏
}

这个goroutine会一直等ch的数据,但是ch永远不会有数据,所以这个goroutine永远不会退出,就泄漏了。怎么避免呢?第一,启动goroutine的时候,就要想清楚它什么时候退出,确保它能正常退出。第二,用context来控制goroutine的生命周期,需要取消的时候,cancel context,goroutine收到信号就退出。第三,channel使用完之后,及时关闭,让等待的goroutine能收到零值退出。

然后说channel的死锁。死锁就是所有goroutine都在等,没有人能继续执行,程序就卡住了。最常见的是,往一个无缓冲的channel发数据,但是没有其他goroutine接收,或者反过来,从一个无缓冲的channel收数据,但是没有其他goroutine发送,就会死锁。

避免死锁的方法是,用channel的时候,一定要确保有发送方也有接收方,而且发送和接收要在不同的goroutine里。还有就是,尽量用有缓冲的channel,缓冲可以在一定程度上避免死锁,但是也不能完全避免,还是要注意。

还有一个常见问题是数据竞争,就是多个goroutine同时读写同一个变量,没有同步,导致结果不确定,甚至崩溃。Go语言有个工具叫race detector,可以检测数据竞争,测试的时候加上-race参数,就能检测出来。避免数据竞争的方法是,用channel来通信,不要通过共享内存来通信,这是Go并发的哲学。如果必须共享内存,那就用sync包里的锁,比如sync.Mutex、sync.RWMutex,来保护共享变量。

还有一个技巧是,用sync.WaitGroup来等待多个goroutine完成,不要用time.Sleep来等,因为你不知道goroutine什么时候完成,Sleep时间短了没完成,长了浪费时间。WaitGroup可以准确地等待所有goroutine完成。

三、错误处理:Go的错误处理哲学

Go的错误处理和其他语言不太一样,它没有try/catch/exception,而是用返回值的方式返回错误,函数一般返回两个值,一个是结果,一个是错误,调用者要自己检查错误,处理错误。很多从其他语言转过来的人,一开始很不适应这种错误处理方式,觉得太繁琐,到处都是if err != nil,但是这就是Go的哲学,显式地处理错误,不忽略错误,让代码更健壮。

很多初学者写Go代码,经常忽略错误,用_来接收错误,或者不检查错误,这是很不好的习惯。Go的错误处理原则是,不要忽略任何错误,每个错误都要检查,都要处理。如果你真的确定某个错误不会发生,或者发生了也无所谓,那可以忽略,但是大部分情况下,错误都应该被检查和处理。

处理错误的方式一般有几种:第一,直接返回,把错误返回给调用者,让调用者处理,这是最常见的。第二,重试,如果错误是临时的,比如网络超时,可以重试几次。第三,记录日志然后继续,如果错误不影响主流程,可以记录一下日志,继续执行。第四,退出程序,如果是致命错误,无法恢复,就记录日志然后退出。

还有一个技巧是,给错误加上下文信息,不要直接返回原始错误。比如你调用一个函数出错了,返回的时候,可以用fmt.Errorf加上一些上下文信息,比如"调用xxx失败: %w",这样错误信息更丰富,排查问题的时候更容易。Go 1.13之后支持了%w动词,可以包装错误,还能用errors.Is和errors.As来判断和提取错误,非常方便。

还有就是,panic和recover的使用。Go里也有panic,相当于其他语言的异常,但是Go里不建议用panic来做普通的错误处理,panic应该只用在真正的致命错误,比如程序启动的时候配置文件不存在,无法继续运行,这时候可以panic。普通的错误,应该用返回错误的方式,不要用panic。recover可以捕获panic,但是只能在defer里用,而且只在当前goroutine有效,一般在程序的入口处recover一下,防止整个程序崩溃。

四、性能优化:一些容易被忽略的点

Go语言的性能已经很好了,但是如果写得不好,还是会有性能问题。这里分享一些性能优化的技巧,都是一些容易被忽略的点。

第一,字符串拼接。很多人拼接字符串用+,但是在Go里,字符串是不可变的,用+拼接会产生很多临时字符串,性能很差,特别是在循环里拼接。正确的做法是用strings.Builder,或者bytes.Buffer,它们是可变的,拼接性能好很多。Go 1.10之后推荐用strings.Builder,比bytes.Buffer更轻量。

第二,切片的容量。切片是Go里最常用的数据结构,很多人创建切片的时候不指定容量,append的时候会自动扩容,但是扩容会涉及到内存分配和数据拷贝,有性能开销。如果你知道切片的大概长度,创建的时候就指定容量,比如make([]int, 0, 100),这样就不会频繁扩容,性能更好。

第三,map的容量。和切片一样,map创建的时候也可以指定容量,如果你知道map大概有多少个key,创建的时候指定容量,可以避免频繁扩容,提升性能。

第四,defer的开销。defer是个好东西,能保证资源被释放,但是defer是有开销的,因为要注册和执行defer函数,在性能要求很高的地方,频繁的defer会影响性能。这时候可以手动释放资源,不用defer,当然这会牺牲代码的可读性和安全性,要权衡。一般情况下,还是建议用defer,只有在性能瓶颈的地方再考虑优化。

第五,减少内存分配。Go的GC虽然已经很好了,但是频繁的内存分配还是会影响性能,因为GC要扫描和回收。减少内存分配的方法有:复用对象,用sync.Pool来池化对象,避免频繁创建和销毁;用值类型代替指针类型,减少堆分配;提前分配容量,避免扩容时的内存分配等等。

第六,用pprof做性能分析。Go自带了pprof工具,可以做CPU分析、内存分析、阻塞分析等等,非常强大。性能优化的时候,不要瞎猜,要用pprof分析,找到真正的瓶颈,然后针对性地优化。net/http/pprof可以在web服务里直接暴露pprof接口,很方便。

五、写在最后

Go语言入门进阶:这些技巧你可能不知道。

以上就是我用Go两年多总结的一些进阶技巧,包括接口、并发、错误处理、性能优化等几个方面,都是一些平时容易被忽略,但是掌握了之后能让代码更优雅、更高效的技巧。当然,Go语言的内容远不止这些,还有很多细节和高级特性,需要在实际使用中不断学习和积累。

总的来说,Go是一门很优秀的语言,简单、高效、并发能力强,非常适合做后端开发、云计算、微服务、分布式系统。它的学习曲线很平缓,入门很容易,但是要写得地道、写得高效,还是需要下一番功夫的。希望这些技巧能帮助正在学习Go的朋友,少走弯路,写出更好的Go代码。

最后用一句话结尾:"Less is more." Go语言的设计哲学就是简单,少即是多,简单的语言,简单的语法,但是能写出强大的程序。希望我们都能领悟Go的哲学,写出简单、优雅、高效的Go代码。