欢迎光临散文网 会员登陆 & 注册

我为什么放弃Go语言?

2023-06-24 14:20 作者:你认识张大卫吗  | 我要投稿

你在什么时候会产生“想要放弃用 Go 语言”的念头?也许是在用 Go 开发过程中,接连不断踩坑的时候。本文作者提炼和总结《100 Go Mistakes and How to Avoid Them》里的精华内容,并结合自身的工作经验,盘点了 Go 的常见典型错误,撰写了这篇超全避坑指南。让我们跟随文章,一起重拾用 Go 的信心~

目录

1 注意 shadow 变量

2 慎用 init 函数

3 embed types 优缺点

4 Functional Options Pattern 传递参数

5 小心八进制整数

6 float 的精度问题

7 slice 相关注意点 slice 相关注意点

8 注意 range

9 注意 break 作用域

10 defer

11 string 相关

12 interface 类型返回的非 nil 问题

13 Error

14 happens before 保证

15 Context Values

16 应多关注 goroutine 何时停止

17 Channel

18 string format 带来的 dead lock

19 错误使用 sync.WaitGroup

20 不要拷贝 sync 类型

21 time.After 内存泄露

22 HTTP body 忘记 Close 导致的泄露

23 Cache line

24 关于 False Sharing 造成的性能问题

25 内存对齐

26 逃逸分析

27 byte slice 和 string 的转换优化

28 容器中的 GOMAXPROCS

29 总结

01、注意 shadow 变量

go复制代码var client *http.Client   if tracing {     client, err := createClientWithTracing()     if err != nil {       return err     }     log.Println(client)   } else {     client, err := createDefaultClient()     if err != nil {       return err     }     log.Println(client)  }

在上面这段代码中,声明了一个 client 变量,然后使用 tracing 控制变量的初始化,可能是因为没有声明 err 的缘故,使用的是 := 进行初始化,那么会导致外层的 client 变量永远是 nil。这个例子实际上是很容易发生在我们实际的开发中,尤其需要注意。

如果是因为 err 没有初始化的缘故,我们在初始化的时候可以这么做:

go复制代码var client *http.Client   var err error   if tracing {     client, err = createClientWithTracing()    } else {     ...   }     if err != nil { // 防止重复代码         return err    }

或者内层的变量声明换一个变量名字,这样就不容易出错了。

我们也可以使用工具分析代码是否有 shadow,先安装一下工具:

go复制代码go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow

然后使用 shadow 命令:

go复制代码go vet -vettool=C:\Users\luozhiyun\go\bin\shadow.exe .\main.go # command-line-arguments .\main.go:15:3: declaration of "client" shadows declaration at line 13 .\main.go:21:3: declaration of "client" shadows declaration at line 13

02、慎用 init 函数

使用 init 函数之前需要注意下面几件事:

2.1 init 函数会在全局变量之后被执行

init 函数并不是最先被执行的,如果声明了 const 或全局变量,那么 init 函数会在它们之后执行:

go复制代码package main  import "fmt"  var a = func() int {   fmt.Println("a")   return 0 }()  func init() {   fmt.Println("init") }  func main() {   fmt.Println("main") }  // output a initmain

2.2 init 初始化按解析的依赖关系顺序执行

比如 main 包里面有 init 函数,依赖了 redis 包,main 函数执行了 redis 包的 Store 函数,恰好 redis 包里面也有 init 函数,那么执行顺序会是:

还有一种情况,如果是使用 "import _ foo" 这种方式引入的,也是会先调用 foo 包中的 init 函数。

2.3 扰乱单元测试

比如我们在 init 函数中初始了一个全局的变量,但是单测中并不需要,那么实际上会增加单测得复杂度,比如:

go复制代码var db *sql.DB func init(){   dataSourceName := os.Getenv("MYSQL_DATA_SOURCE_NAME")     d, err := sql.Open("mysql", dataSourceName)     if err != nil {         log.Panic(err)     }     db = d}

在上面这个例子中 init 函数初始化了一个 db 全局变量,那么在单测的时候也会初始化一个这样的变量,但是很多单测其实是很简单的,并不需要依赖这个东西。

03、embed types 优缺点

embed types 指的是我们在 struct 里面定义的匿名的字段,如:

go复制代码type Foo struct {   Bar } type Bar struct {   Baz int}

那么在上面这个例子中,我们可以通过 Foo.Baz 直接访问到成员变量,当然也可以通过 Foo.Bar.Baz 访问。

这样在很多时候可以增加我们使用的便捷性,如果没有使用 embed types 那么可能需要很多代码,如下:

go复制代码type Logger struct {         writeCloser io.WriteCloser }  func (l Logger) Write(p []byte) (int, error) {         return l.writeCloser.Write(p) }  func (l Logger) Close() error {         return l.writeCloser.Close() }  func main() {         l := Logger{writeCloser: os.Stdout}         _, _ = l.Write([]byte("foo"))         _ = l.Close()}

如果使用了 embed types 我们的代码可以变得很简洁

go复制代码type Logger struct {         io.WriteCloser }  func main() {         l := Logger{WriteCloser: os.Stdout}         _, _ = l.Write([]byte("foo"))         _ = l.Close()}

但是同样它也有缺点,有些字段我们并不想 export ,但是 embed types 可能给我们带出去,例如:

go复制代码type InMem struct {   sync.Mutex   m map[string]int }  func New() *InMem {    return &InMem{m: make(map[string]int)}}

Mutex 一般并不想 export, 只想在 InMem 自己的函数中使用,如:

go复制代码func (i *InMem) Get(key string) (int, bool) {   i.Lock()   v, contains := i.m[key]   i.Unlock()   return v, contains}

但是这么写却可以让拿到 InMem 类型的变量都可以使用它里面的 Lock 方法:

css复制代码m := inmem.New() m.Lock() // ??

04、Functional Options Pattern传递参数

这种方法在很多 Go 开源库都有看到过使用,比如 zap、GRPC 等。

它经常用在需要传递和初始化校验参数列表的时候使用,比如我们现在需要初始化一个 HTTP server,里面可能包含了 port、timeout 等等信息,但是参数列表很多,不能直接写在函数上,并且我们要满足灵活配置的要求,毕竟不是每个 server 都需要很多参数。那么我们可以:

设置一个不导出的 struct 叫 options,用来存放配置参数; 创建一个类型 type Option func(options *options) error,用这个类型来作为返回值;

比如我们现在要给 HTTP server 里面设置一个 port 参数,那么我们可以这么声明一个 WithPort 函数,返回 Option 类型的闭包,当这个闭包执行的时候会将 options 的 port 填充进去:

go复制代码type options struct {         port *int }  type Option func(options *options) error  func WithPort(port int) Option {          // 所有的类型校验,赋值,初始化啥的都可以放到这个闭包里面做         return func(options *options) error {                 if port < 0 {                         return errors.New("port should be positive")                 }                 options.port = &port                 return nil         }}

假如我们现在有一个这样的 Option 函数集,除了上面的 port 以外,还可以填充 timeout 等。然后我们可以利用 NewServer 创建我们的 server:

go复制代码func NewServer(addr string, opts ...Option) (*http.Server, error) {         var options options         // 遍历所有的 Option         for _, opt := range opts {                 // 执行闭包                 err := opt(&options)                 if err != nil {                         return nil, err                 }         }          // 接下来可以填充我们的业务逻辑,比如这里设置默认的port 等等         var port int         if options.port == nil {                 port = defaultHTTPPort         } else {                 if *options.port == 0 {                         port = randomPort()                 } else {                         port = *options.port                 }         }          // ...}

初始化 server:

css复制代码server, err := httplib.NewServer("localhost",                 httplib.WithPort(8080),                httplib.WithTimeout(time.Second))

这样写的话就比较灵活,如果只想生成一个简单的 server,我们的代码可以变得很简单:

css复制代码server, err := httplib.NewServer("localhost")

05、小心八进制整数

比如下面例子:

bash复制代码sum := 100 + 010  fmt.Println(sum)

你以为要输出110,其实输出的是 108,因为在 Go 中以 0 开头的整数表示八进制。

它经常用在处理 Linux 权限相关的代码上,如下面打开一个文件:

lua复制代码file, err := os.OpenFile("foo", os.O_RDONLY, 0644)

所以为了可读性,我们在用八进制的时候最好使用 "0o" 的方式表示,比如上面这段代码可以表示为:

lua复制代码file, err := os.OpenFile("foo", os.O_RDONLY, 0o644)

06、float 的精度问题

在 Go 中浮点数表示方式和其他语言一样,都是通过科学计数法表示,float 在存储中分为三部分:

符号位(Sign): 0代表正,1代表为负 指数位(Exponent):用于存储科学计数法中的指数数据,并且采用移位存储 尾数部分(Mantissa):尾数部分

计算规则我就不在这里展示了,感兴趣的可以自己去查查,我这里说说这种计数法在 Go 里面会有哪些问题。

go复制代码func f1(n int) float64 {   result := 10_000.   for i := 0; i < n; i++ {     result += 1.0001   }   return result }  func f2(n int) float64 {   result := 0.   for i := 0; i < n; i++ {     result += 1.0001   }   return result + 10_000.}

在上面这段代码中,我们简单地做了一下加法:

nExact resultf1f21010010.00110010.00110010.0011k11000.111000.111000.11m1.01E+061.01E+061.01E+06

可以看到 n 越大,误差就越大,并且 f2 的误差是小于 f1的。

对于乘法我们可以做下面的实验:

css复制代码a := 100000.001 b := 1.0001 c := 1.0002  fmt.Println(a * (b + c))fmt.Println(a*b + a*c)

输出:

复制代码200030.00200030004 200030.0020003

正确输出应该是 200030.0020003,所以它们实际上都有一定的误差,但是可以看到先乘再加精度丢失会更小

如果想要准确计算浮点的话,可以尝试 "github.com/shopspring/…" 库,换成这个库我们再来计算一下:

css复制代码a := decimal.NewFromFloat(100000.001) b := decimal.NewFromFloat(1.0001) c := decimal.NewFromFloat(1.0002)  fmt.Println(a.Mul(b.Add(c))) //200030.0020003

07、slice 相关注意点

7.1 区分 slice 的 length 和 capacity

首先让我们初始化一个带有 length 和 capacity 的 slice :

go复制代码s := make([]int, 3, 6)

在 make 函数里面,capacity 是可选的参数。上面这段代码我们创建了一个 length 是 3,capacity 是 6 的 slice,那么底层的数据结构是这样的:

slice 的底层实际上指向了一个数组。当然,由于我们的 length 是 3,所以这样设置 s[4] = 0 会 panic 的。需要使用 append 才能添加新元素。

go复制代码panic: runtime error: index out of range [4] with length 3

当 appned 超过 cap 大小的时候,slice 会自动帮我们扩容,在元素数量小于 1024 的时候每次会扩大一倍,当超过了 1024 个元素每次扩大 25%

有时候我们会使用 :操作符从另一个 slice 上面创建一个新切片:

go复制代码s1 := make([]int, 3, 6) s2 := s1[1:3]

实际上这两个 slice 还是指向了底层同样的数组,构如下:

由于指向了同一个数组,那么当我们改变第一个槽位的时候,比如 s1[1]=2,实际上两个 slice 的数据都会发生改变:

但是当我们使用 append 的时候情况会有所不同:

scss复制代码s2 = append(s2, 3)  fmt.Println(s1) // [0 2 0] fmt.Println(s2) // [2 0 3]

s1 的 len 并没有被改变,所以看到的还是3元素。

还有一件比较有趣的细节是,如果再接着 append s1 那么第四个元素会被覆盖掉:

scss复制代码s1 = append(s1, 4)   fmt.Println(s1) // [0 2 0 4]   fmt.Println(s2) // [2 0 4]

我们再继续 append s2 直到 s2 发生扩容,这个时候会发现 s2 实际上和 s1 指向的不是同一个数组了:

scss复制代码s2 = append(s2, 5, 6, 7) fmt.Println(s1) //[0 2 0 4] fmt.Println(s2) //[2 0 4 5 6 7]

除了上面这种情况,还有一种情况 append 会产生意想不到的效果:

go复制代码s1 := []int{1, 2, 3} s2 := s1[1:2] s3 := append(s2, 10)

如果 print 它们应该是这样:

css复制代码s1=[1 2 10], s2=[2], s3=[2 10]

7.2 slice 初始化

对于 slice 的初始化实际上有很多种方式:

scss复制代码func main() {         var s []string         log(1, s)          s = []string(nil)         log(2, s)          s = []string{}         log(3, s)          s = make([]string, 0)         log(4, s) }  func log(i int, s []string) {         fmt.Printf("%d: empty=%t\tnil=%t\n", i, len(s) == 0, s == nil)}

输出:

ini复制代码1: empty=true   nil=true 2: empty=true   nil=true 3: empty=true   nil=false 4: empty=true   nil=false

前两种方式会创建一个 nil 的 slice,后两种会进行初始化,并且这些 slice 的大小都为 0 。

对于 var s []string 这种方式来说,好处就是不用做任何的内存分配。比如下面场景可能可以节省一次内存分配:

csharp复制代码func f() []string {         var s []string         if foo() {                 s = append(s, "foo")         }         if bar() {                 s = append(s, "bar")         }         return s}

对于 s := []string{} 这种方式来说,它比较适合初始化一个已知元素的 slice

go复制代码s := []string{"foo", "bar", "baz"}

如果没有这个需求其实用 var s []string 比较好,反正在使用的适合都是通过 append 添加元素, var s []string 还能节省一次内存分配。

如果我们初始化了一个空的 slice, 那么最好是使用 len(xxx) == 0来判断 slice 是不是空的,如果使用 nil 来判断可能会永远非空的情况,因为对于 s := []string{} 和 s = make([]string, 0) 这两种初始化都是非 nil 的。

对于 []string(nil) 这种初始化的方式,使用场景很少,一种比较方便地使用场景是用它来进行 slice 的 copy:

go复制代码src := []int{0, 1, 2} dst := append([]int(nil), src...)

对于 make 来说,它可以初始化 slice 的 length 和 capacity,如果我们能确定 slice 里面会存放多少元素,从性能的角度考虑最好使用 make 初始化好,因为对于一个空的 slice append 元素进去每次达到阈值都需要进行扩容,下面是填充 100 万元素的 benchmark:

bash复制代码BenchmarkConvert_EmptySlice-4 22 49739882 ns/op BenchmarkConvert_GivenCapacity-4 86 13438544 ns/op BenchmarkConvert_GivenLength-4 91 12800411 ns/op

可以看到,如果我们提前填充好 slice 的容量大小,性能是空 slice 的四倍,因为少了扩容时元素复制以及重新申请新数组的开销。

7.3 copy slice

css复制代码src := []int{0, 1, 2} var dst []int copy(dst, src) fmt.Println(dst) // []

使用 copy 函数 copy slice 的时候需要注意,上面这种情况实际上会 copy 失败,因为对 slice 来说是由 length 来控制可用数据,copy 并没有复制这个字段,要想 copy 我们可以这么做:

go复制代码src := []int{0, 1, 2} dst := make([]int, len(src)) copy(dst, src) fmt.Println(dst) //[0 1 2]

除此之外也可以用上面提到的:

go复制代码src := []int{0, 1, 2} dst := append([]int(nil), src...)

7.4 slice capacity内存释放问题

先来看个例子:

css复制代码type Foo struct {   v []byte }  func keepFirstTwoElementsOnly(foos []Foo) []Foo {   return foos[:2] }  func main() {   foos := make([]Foo, 1_000)   printAlloc()    for i := 0; i < len(foos); i++ {     foos[i] = Foo{       v: make([]byte, 1024*1024),     }   }   printAlloc()    two := keepFirstTwoElementsOnly(foos)   runtime.GC()   printAlloc()   runtime.KeepAlive(two)}

上面这个例子中使用 printAlloc 函数来打印内存占用:

swift复制代码func printAlloc() {   var m runtime.MemStats   runtime.ReadMemStats(&m)   fmt.Printf("%d KB\n", m.Alloc/1024)}

上面 foos 初始化了 1000 个容量的 slice ,里面 Foo struct 每个都持有 1M 内存的 slice,然后通过 keepFirstTwoElementsOnly 返回持有前两个元素的 Foo 切片,我们的想法是手动执行 GC 之后其他的 998 个 Foo 会被 GC 销毁,但是输出结果如下:

复制代码387 KB 1024315 KB1024319 KB

实际上并没有,原因就是实际上 keepFirstTwoElementsOnly 返回的 slice 底层持有的数组是和 foos 持有的同一个:

所以我们真的要只返回 slice 的前2个元素的话应该这样做:

go复制代码func keepFirstTwoElementsOnly(foos []Foo) []Foo {         res := make([]Foo, 2)         copy(res, foos)         return res}

不过上面这种方法会初始化一个新的 slice,然后将两个元素 copy 过去。不想进行多余的分配可以这么做:

css复制代码func keepFirstTwoElementsOnly(foos []Foo) []Foo {         for i := 2; i < len(foos); i++ {                 foos[i].v = nil         }         return foos[:2]}

我为什么放弃Go语言?的评论 (共 条)

分享到微博请遵守国家法律