我爲什麼放棄 Go 語言?

你在什麼時候會產生 “想要放棄用 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 變量

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 沒有初始化的緣故,我們在初始化的時候可以這麼做:

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

或者內層的變量聲明換一個變量名字,這樣就不容易出錯了。

我們也可以使用工具分析代碼是否有 shadow,先安裝一下工具:

go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow

然後使用 shadow 命令:

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 函數會在全局變量之後被執行

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
init
main

init 函數並不是最先被執行的,如果聲明瞭 const 或全局變量,那麼 init 函數會在它們之後執行:

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
init
main

   2.2 init 初始化按解析的依賴關係順序執行

比如 main 包裏面有 init 函數,依賴了 redis 包,main 函數執行了 redis 包的 Store 函數,恰好 redis 包裏面也有 init 函數,那麼執行順序會是:

還有一種情況,如果是使用 "import _ foo" 這種方式引入的,也是會先調用 foo 包中的 init 函數。

   2.3 擾亂單元測試

比如我們在 init 函數中初始了一個全局的變量,但是單測中並不需要,那麼實際上會增加單測得複雜度,比如:

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 裏面定義的匿名的字段,如:

type Foo struct {
  Bar
}
type Bar struct {
  Baz int
}

那麼在上面這個例子中,我們可以通過 Foo.Baz 直接訪問到成員變量,當然也可以通過 Foo.Bar.Baz 訪問。

這樣在很多時候可以增加我們使用的便捷性,如果沒有使用 embed types 那麼可能需要很多代碼,如下:

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 我們的代碼可以變得很簡潔

type Logger struct {
        io.WriteCloser
}

func main() {
        l := Logger{WriteCloser: os.Stdout}
        _, _ = l.Write([]byte("foo"))
        _ = l.Close()
}

但是同樣它也有缺點,有些字段我們並不想 export ,但是 embed types 可能給我們帶出去,例如:

type InMem struct {
  sync.Mutex
  m map[string]int
}

func New() *InMem {
   return &InMem{m: make(map[string]int)}
}

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

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

但是這麼寫卻可以讓拿到 InMem 類型的變量都可以使用它裏面的 Lock 方法:

m := inmem.New()
m.Lock() // ??

04 Functional Options Pattern 傳遞參數

這種方法在很多 Go 開源庫都有看到過使用,比如 zap、GRPC 等。

它經常用在需要傳遞和初始化校驗參數列表的時候使用,比如我們現在需要初始化一個 HTTP server,裏面可能包含了 port、timeout 等等信息,但是參數列表很多,不能直接寫在函數上,並且我們要滿足靈活配置的要求,畢竟不是每個 server 都需要很多參數。那麼我們可以:

比如我們現在要給 HTTP server 裏面設置一個 port 參數,那麼我們可以這麼聲明一個 WithPort 函數,返回 Option 類型的閉包,當這個閉包執行的時候會將 options 的 port 填充進去:

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:

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 =={
                        port = randomPort()
                } else {
                        port = *options.port
                }
        }

        // ...
}

初始化 server:

server, err := httplib.NewServer("localhost",
               httplib.WithPort(8080),
               httplib.WithTimeout(time.Second))

這樣寫的話就比較靈活,如果只想生成一個簡單的 server,我們的代碼可以變得很簡單:

server, err := httplib.NewServer("localhost")

05 小心八進制整數

比如下面例子:

sum := 100 + 010
  fmt.Println(sum)

你以爲要輸出 110,其實輸出的是 108,因爲在 Go 中以 0 開頭的整數表示八進制。

它經常用在處理 Linux 權限相關的代碼上,如下面打開一個文件:

file, err := os.OpenFile("foo", os.O_RDONLY, 0644)

所以爲了可讀性,我們在用八進制的時候最好使用 "0o" 的方式表示,比如上面這段代碼可以表示爲:

file, err := os.OpenFile("foo", os.O_RDONLY, 0o644)

06 float 的精度問題

在 Go 中浮點數表示方式和其他語言一樣,都是通過科學計數法表示,float 在存儲中分爲三部分:

計算規則我就不在這裏展示了,感興趣的可以自己去查查,我這裏說說這種計數法在 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.
}

在上面這段代碼中,我們簡單地做了一下加法:

lRtcp5

可以看到 n 越大,誤差就越大,並且 f2 的誤差是小於 f1 的。

對於乘法我們可以做下面的實驗:

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/decimal" 庫,換成這個庫我們再來計算一下:

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 :

s := make([]int, 3, 6)

在 make 函數里面,capacity 是可選的參數。上面這段代碼我們創建了一個 length 是 3,capacity 是 6 的 slice,那麼底層的數據結構是這樣的:

slice 的底層實際上指向了一個數組。當然,由於我們的 length 是 3,所以這樣設置 s[4] = 0 會 panic 的。需要使用 append 才能添加新元素。

panic: runtime error: index out of range [4] with length 3

當 appned 超過 cap 大小的時候,slice 會自動幫我們擴容,在元素數量小於 1024 的時候每次會擴大一倍,當超過了 1024 個元素每次擴大 25%

有時候我們會使用 :操作符從另一個 slice 上面創建一個新切片:

s1 := make([]int, 3, 6)
s2 := s1[1:3]

實際上這兩個 slice 還是指向了底層同樣的數組,構如下:

由於指向了同一個數組,那麼當我們改變第一個槽位的時候,比如 s1[1]=2,實際上兩個 slice 的數據都會發生改變:

但是當我們使用 append 的時候情況會有所不同:

s2 = append(s2, 3)

fmt.Println(s1) // [0 2 0]
fmt.Println(s2) // [2 0 3]

s1 的 len 並沒有被改變,所以看到的還是 3 元素。

還有一件比較有趣的細節是,如果再接着 append s1 那麼第四個元素會被覆蓋掉:

s1 = append(s1, 4)
  fmt.Println(s1) // [0 2 0 4]
  fmt.Println(s2) // [2 0 4]

我們再繼續 append s2 直到 s2 發生擴容,這個時候會發現 s2 實際上和 s1 指向的不是同一個數組了:

s2 = append(s2, 5, 6, 7)
fmt.Println(s1) //[0 2 0 4]
fmt.Println(s2) //[2 0 4 5 6 7]

除了上面這種情況,還有一種情況 append 會產生意想不到的效果:

s1 := []int{1, 2, 3}
s2 := s1[1:2]
s3 := append(s2, 10)

如果 print 它們應該是這樣:

s1=[1 2 10], s2=[2], s3=[2 10]

   7.2 slice 初始化

對於 slice 的初始化實際上有很多種方式:

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)
}

輸出:

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 這種方式來說,好處就是不用做任何的內存分配。比如下面場景可能可以節省一次內存分配:

func f() []string {
        var s []string
        if foo() {
                s = append(s, "foo")
        }
        if bar() {
                s = append(s, "bar")
        }
        return s
}

對於 s := []string{} 這種方式來說,它比較適合初始化一個已知元素的 slice

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:

src := []int{0, 1, 2}
dst := append([]int(nil), src...)

對於 make 來說,它可以初始化 slice 的 length 和 capacity,如果我們能確定 slice 裏面會存放多少元素,從性能的角度考慮最好使用 make 初始化好,因爲對於一個空的 slice append 元素進去每次達到閾值都需要進行擴容,下面是填充 100 萬元素的 benchmark:

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

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

使用 copy 函數 copy slice 的時候需要注意,上面這種情況實際上會 copy 失敗,因爲對 slice 來說是由 length 來控制可用數據,copy 並沒有複製這個字段,要想 copy 我們可以這麼做:

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

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

src := []int{0, 1, 2}
dst := append([]int(nil), src...)

   7.4 slice capacity 內存釋放問題

先來看個例子:

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 函數來打印內存佔用:

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 KB
1024319 KB

實際上並沒有,原因就是實際上 keepFirstTwoElementsOnly 返回的 slice 底層持有的數組是和 foos 持有的同一個:

所以我們真的要只返回 slice 的前 2 個元素的話應該這樣做:

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
        res := make([]Foo, 2)
        copy(res, foos)
        return res
}

不過上面這種方法會初始化一個新的 slice,然後將兩個元素 copy 過去。不想進行多餘的分配可以這麼做:

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

08 注意 range

   8.1 copy 的問題

使用 range 的時候如果我們直接修改它返回的數據會不生效,因爲返回的數據並不是原始數據:

type account struct {
  balance float32
}
 
  accounts := []account{
    {balance: 100.},
    {balance: 200.},
    {balance: 300.},
  }
  for _, a := range accounts {
    a.balance += 1000
  }

如果像上面這麼做,那麼輸出的 accounts 是:

[{100} {200} {300}]

所以我們想要改變 range 中的數據可以這麼做:

for i := range accounts {
  accounts[i].balance += 1000
}

range slice 的話也會 copy 一份:

s := []int{0, 1, 2}
for range s {
  s = append(s, 10)
}

這份代碼在 range 的時候會 copy 一份,因此只會調用三次 append 後停止。

   8.2 指針問題

比方我們想要 range slice 並將返回值存到 map 裏面供後面業務使用,類似這樣:

type Customer struct {
    ID string
    Balance float64
}

test := []Customer{
      {ID: "1", Balance: 10},
      {ID: "2", Balance: -10},
      {ID: "3", Balance: 0},
}

var m map[string]*Customer
for _, customer := range test {
    m[customer.ID] = &customer
}

但是這樣遍歷 map 裏面存的並不是我們想要的,你會發現存的 value 都是最後一個:

{"1":{"ID":"3","Balance":0},"2":{"ID":"3","Balance":0},"3":{"ID":"3","Balance":0}}

這是因爲當我們使用 range 遍歷 slice 的時候,返回的 customer 變量實際上是一個固定的地址:

for _, customer := range test {
    fmt.Printf("%p\n", &customer) //我們想要獲取這個指針的時候
}

輸出:

0x1400000e240
0x1400000e240
0x1400000e240

這是因爲迭代器會把數據都放入到 0x1400000e240 這塊空間裏面:

所以我們可以這樣在 range 裏面獲取指針:

for _, customer := range test {
    current := customer // 使用局部變量
    fmt.Printf("%p\n", ¤t) // 這裏獲取的指針是 range copy 出來元素的指針
  }

或者:

for i := range test {
    current := &test[i] // 使用局部變量
    fmt.Printf("%p\n", current)
  }

09 注意 break 作用域

比方說:

for i := 0; i < 5; i++ {
      fmt.Printf("%d ", i)

      switch i {
      default:
      case 2:
              break
      }
  }

上面這個代碼本來想 break 停止遍歷,實際上只是 break 了 switch 作用域,print 依然會打印:0,1,2,3,4。

正確做法應該是通過 label 的方式 break:

loop:
  for i := 0; i < 5; i++ {
    fmt.Printf("%d ", i)
    switch i {
    default:
    case 2:
      break loop
    }
  }

有時候我們會沒注意到自己的錯誤用法,比如下面:

for {
    select {
    case <-ch:
      // Do something
    case <-ctx.Done():
      break
    }
  }

上面這種寫法會導致只跳出了 select,並沒有終止 for 循環,正確寫法應該這樣:

loop:
  for {
    select {
    case <-ch:
      // Do something
    case <-ctx.Done():
      break loop
    }
  }

10 defer

   10.1 注意 defer 的調用時機

有時候我們會像下面一樣使用 defer 去關閉一些資源:

func readFiles(ch <-chan string) error {
            for path := range ch {
                    file, err := os.Open(path)
                    if err != nil {
                            return err
                    }
    
                    defer file.Close()
    
                    // Do something with file
            }
            return nil
}

因爲 defer 會在方法結束的時候調用,但是如果上面的 readFiles 函數永遠沒有 return,那麼 defer 將永遠不會被調用,從而造成內存泄露。並且 defer 寫在 for 循環裏面,編譯器也無法做優化,會影響代碼執行性能。

爲了避免這種情況,我們可以 wrap 一層:

func readFiles(ch <-chan string) error {
      for path := range ch {
          if err := readFile(path); err != nil {
                  return err
          }
      }
      return nil
}

func readFile(path string) error {
      file, err := os.Open(path)
      if err != nil {
              return err
      }

      defer file.Close()

      // Do something with file
      return nil
}

   10.2 注意 defer 的參數

defer 聲明時會先計算確定參數的值。

func a() {
    i := 0
    defer notice(i) // 0
    i++
    return
}

func notice(i int) {
  fmt.Println(i)
}

在這個例子中,變量 i 在 defer 被調用的時候就已經確定了,而不是在 defer 執行的時候,所以上面的語句輸出的是 0。

所以我們想要獲取這個變量的真實值,應該用引用:

func a() {
  i := 0
  defer notice(&i) // 1
  i++
  return
}

   10.2 defer 下的閉包

func a() int {
  i := 0
  defer func() {
    fmt.Println(i + 1) //12
  }()
  i++
  return i+10  
}

func TestA(t *testing.T) {
  fmt.Println(a()) //11
}

如果換成閉包的話,實際上閉包中對變量 i 是通過指針傳遞,所以可以讀到真實的值。但是上面的例子中 a 函數返回的是 11 是因爲執行順序是:

先計算(i+10)-> (call defer) -> (return)

11 string 相關

   11.1 迭代帶來的問題

在 Go 語言中,字符串是一種基本類型,默認是通過 utf8 編碼的字符序列,當字符爲 ASCII 碼時則佔用 1 個字節,其他字符根據需要佔用 2-4 個字節,比如中文編碼通常需要 3 個字節。

那麼我們在做 string 迭代的時候可能會產生意想不到的問題:

s := "hêllo"
  for i := range s {
    fmt.Printf("position %d: %c\n", i, s[i])
  }
  fmt.Printf("len=%d\n", len(s))

輸出:

position 0: h
position 1: Ã
position 3: l
position 4: l
position 5: o
len=6

上面的輸出中發現第二個字符是 Ã,不是 ê,並且位置 2 的輸出” 消失 “了,這其實就是因爲 ê 在 utf8 裏面實際上佔用 2 個 byte:

29v9Q4

所以我們在迭代的時候 s[1] 等於 c3 這個 byte 等價 Ã 這個 utf8 值,所以輸出的是 hÃllo 而不是 hêllo。

那麼根據上面的分析,我們就可以知道在迭代獲取字符的時候不能只獲取單個 byte,應該使用 range 返回的 value 值:

s := "hêllo"
  for i, v := range s {
    fmt.Printf("position %d: %c\n", i, v)
  }

或者我們可以把 string 轉成 rune 數組,在 go 中 rune 代表 Unicode 碼位,用它可以輸出單個字符:

s := "hêllo"
  runes := []rune(s)
  for i, _ := range runes {
    fmt.Printf("position %d: %c\n", i, runes[i])
  }

輸出:

position 0: h
position 1: ê
position 2: l
position 3: l
position 4: o

   11.2 截斷帶來的問題

在上面我們講 slice 的時候也提到了,**在對 slice 使用 ****:**操作符進行截斷的時候,底層的數組實際上指向同一個,在 string 裏面也需要注意這個問題,比如下面:

func (s store) handleLog(log string) error {
            if len(log) < 36 {
                    return errors.New("log is not correctly formatted")
            }
            uuid := log[:36]
            s.store(uuid)
            // Do something
    }

這段代碼用了 :操作符進行截斷,但是如果 log 這個對象很大,比如上面的 store 方法把 uuid 一直存在內存裏,可能會造成底層的數組一直不釋放,從而造成內存泄露。

爲了解決這個問題,我們可以先複製一份再處理:

func (s store) handleLog(log string) error {
            if len(log) < 36 {
                    return errors.New("log is not correctly formatted")
            }
            uuid := strings.Clone(log[:36]) // copy一份
            s.store(uuid)
            // Do something
    }

12 interface 類型返回的非 nil 問題

假如我們想要繼承 error 接口實現一個自己的 MultiError:

type MultiError struct {
  errs []string
}

func (m *MultiError) Add(err error) {
  m.errs = append(m.errs, err.Error())
}

func (m *MultiError) Error() string {
  return strings.Join(m.errs, ";")
}

然後在使用的時候返回 error,並且想通過 error 是否爲 nil 判斷是否有錯誤:

func Validate(age int, name string) error {
  var m *MultiError
  if age < 0 {
    m = &MultiError{}
    m.Add(errors.New("age is negative"))
  }
  if name == "" {
    if m == nil {
      m = &MultiError{}
    }
    m.Add(errors.New("name is nil"))
  }

  return m
}

func Test(t *testing.T) {
  if err := Validate(10, "a"); err != nil {
    t.Errorf("invalid")
  }
}

實際上 Validate 返回的 err 會總是爲非 nil 的,也就是上面代碼只會輸出 invalid:

invalid <nil>

13 Error

   13.1 error wrap

對於 err 的 return 我們一般可以這麼處理:

err:= xxx()
 if err != nil {
   return err
 }

但是這樣處理只是簡單地將原始的錯誤拋出去了,無法知道當前處理的這段程序的上下文信息,這個時候我們可能會自定義個 error 結構體,繼承 error 接口:

err:= xxx()
 if err != nil {
   return XXError{Err: err}
 }

然後我們把上下文信息都加到 XXError 中,但是這樣雖然可以添加一些上下文信息,但是每次都需要創建一個特定類型的 error 類會變得很麻煩,那麼在 1.13 之後,我們可以使用 %w 進行 wrap。

if err != nil {
   return fmt.Errorf("xxx failed: %w", err)
 }

當然除了上面這種做法以外,我們還可以直接 %v 直接格式化我們的錯誤信息:

if err != nil {
   return fmt.Errorf("xxx failed: %v", err)
 }

這樣做的缺點就是我們會丟失這個 err 的類型信息,如果不需要這個類型信息,只是想往上拋打印一些日誌當然也無所謂。

   13.2 error Is & As

因爲我們的 error 可以會被 wrap 好幾層,那麼使用 == 是可能無法判斷我們的 error 究竟是不是我們想要的特定的 error,那麼可以用 errors.Is:

var BaseErr = errors.New("base error")

func main() {
   err1 := fmt.Errorf("wrap base: %w", BaseErr)
   err2 := fmt.Errorf("wrap err1: %w", err1)
   println(err2 == BaseErr)
   
   if !errors.Is(err2, BaseErr) {
      panic("err2 is not BaseErr")
   }
   println("err2 is BaseErr")
}

輸出:

false
err2 is BaseErr

在上面,我們通過 errors.Is 就可以判斷出 err2 裏面包含了 BaseErr 錯誤。errors.Is 裏面會遞歸調用 Unwrap 方法拆包裝,然後挨個使用 == 判斷是否和指定類型的 error 相等。

errors.As 主要用來做類型判斷,原因也是和上面一樣,error 被 wrap 之後我們通過 err.(type) 無法直接判斷,errors.As 會用 Unwrap 方法拆包裝,然後挨個判斷類型。使用如下:

type TypicalErr struct {
   e string
}

func (t TypicalErr) Error() string {
   return t.e
}

func main() {
   err := TypicalErr{"typical error"}
   err1 := fmt.Errorf("wrap err: %w", err)
   err2 := fmt.Errorf("wrap err1: %w", err1)
   var e TypicalErr
   if !errors.As(err2, &e) {
      panic("TypicalErr is not on the chain of err2")
   }
   println("TypicalErr is on the chain of err2")
   println(err == e)
}

輸出:

TypicalErr is on the chain of err2
true

   13.3 處理 defer 中的 error

比如下面代碼,我們如果在調用 Close 的時候報錯是沒有處理的:

func getBalance(db *sql.DB, clientID string) (
            float32, error) {
            rows, err := db.Query(query, clientID)
            if err != nil {
                    return 0, err
            }
            defer rows.Close()
    
            // Use rows
    }

那麼也許我們可以在 defer 中打印一些 log,但是無法 return,defer 不接受一個 err 類型的返回值:

defer func() {
            err := rows.Close()
            if err != nil {
                    log.Printf("failed to close rows: %v", err)
            }
            return err //無法通過編譯
    }()

那麼我們可能想通過默認 err 返回值的方式將 defer 的 error 也返回了:

func getBalance(db *sql.DB, clientID string) (balance float32, err error) {
            rows, err = db.Query(query, clientID)
            if err != nil {
                    return 0, err
            }
            defer func() {
                    err = rows.Close()
            }()
    
            // Use rows
    }

上面代碼看起來沒問題,那麼假如 Query 的時候和 Close 的時候同時發生異常呢?其中有一個 error 會被覆蓋,那麼我們可以根據自己的需求選擇一個打印日誌,另一個 error 返回:

defer func() {
            closeErr := rows.Close()
            if err != nil {
                    if closeErr != nil {
                            log.Printf("failed to close rows: %v", err)
                    }
                    return
            }
            err = closeErr
    }()

14 happens before 保證

創建 goroutine 發生先於 goroutine 執行,所以下面這段代碼先讀一個變量,然後在 goroutine 中寫變量不會發生 data race 問題:

i := 0
    go func() {
            i++
    }()

goroutine 退出沒有任何 happen before 保證,例如下面代碼會有 data race :

i := 0
    go func() {
            i++
    }()
    fmt.Println(i)

channel 操作中 send 操作是 happens before receive 操作 :

var c = make(chan int, 10)
var a string

func f() {
  a = "hello, world"
  c <- 0
}

func main() {
  go f()
  <-c
  print(a)
}

上面執行順序應該是:

variable change -》channel send -》channel receive -》variable read

上面能夠保證一定輸出 "hello, world"。

close channel 是 happens before receive 操作,所以下面這個例子中也不會有 data race 問題:

i := 0
    ch := make(chan struct{})
    go func() {
            <-ch
            fmt.Println(i)
    }()
    i++
    close(ch)

在無緩衝的 channel 中 receive 操作是 happens before send 操作的,例如:

var c = make(chan int)
var a string

func f() {
  a = "hello, world"
  <-c
}

func main() {
  go f()
  c <- 0
  print(a)
}

這裏同樣能保證輸出 hello, world。

15 Context Values

在 context 裏面我們可以通過 key value 的形式傳遞一些信息:

context.WithValue 是從 parentCtx 創建,所以創建出來的 ctx 既包含了父類的上下文信息,也包含了當前新加的上下文。

fmt.Println(ctx.Value("key"))

使用的時候可以直接通過 Value 函數輸出。那麼其實就可以想到,如果 key 相同的話後面的值會覆蓋前面的值的,所以在寫 key 的時候可以自定義一個非導出的類型作爲 key 來保證唯一

package provider
    
    type key string
    
    const myCustomKey key = "key"
    
    func f(ctx context.Context) {
            ctx = context.WithValue(ctx, myCustomKey, "foo")
            // ...
    }

16 應多關注 goroutine 何時停止

很多同學覺得 goroutine 比較輕量,認爲可以隨意地啓動 goroutine 去執行任何而不會有很大的性能損耗。這個觀點基本沒錯,但是如果在 goroutine 啓動之後因爲代碼問題導致它一直佔用,沒有停止,數量多了之後可能會造成內存泄漏

比如下面的例子:

ch := foo()
    go func() {
            for v := range ch {
                    // ...
            }
    }()

如果在該 goroutine 中的 channel 一直沒有關閉,那麼這個 goroutine 就不會結束,會一直掛着佔用一部分內存。

還有一種情況是我們的主進程已經停止運行了,但是 goroutine 裏面的任務還沒結束就被主進程殺掉了,那麼這樣也可能造成我們的任務執行出問題,比如資源沒有釋放,抑或是數據還沒處理完等等,如下:

func main() {
            newWatcher()
    
            // Run the application
    }
    
    type watcher struct { /* Some resources */ }
    
    func newWatcher() {
            w := watcher{}
            go w.watch()
    }

上面這段代碼就可能出現主進程已經執行 over 了,但是 watch 函數還沒跑完的情況,那麼其實可以通過設置 stop 函數,讓主進程執行完之後執行 stop 函數即可:

func main() {
            w := newWatcher()
            defer w.close()
    
            // Run the application
    }
    
    func newWatcher() watcher {
            w := watcher{}
            go w.watch()
            return w
    }
    
    func (w watcher) close() {
            // Close the resources
    }

17 Channel

   17.1 select & channel

select 和 channel 搭配起來往往有意想不到的效果,比如下面:

for {
            select {
            case v := <-messageCh:
                    fmt.Println(v)
            case <-disconnectCh:
                    fmt.Println("disconnection, return")
                    return
            }
    }

上面代碼中接受了 messageCh 和 disconnectCh 兩個 channel 的數據,如果我們想先接受 messageCh 的數組再接受 disconnectCh 的數據,那麼上面代碼會產生 bug ,如:

for i := 0; i < 10; i++ {
            messageCh <- i
    }
    disconnectCh <- struct{}{}

我們想要上面的 select 先輸出完 messageCh 裏面的數據,然後再 return,實際上可能會輸出:

0
1
2
3
4
disconnection, return

這是因爲 select 不像 switch 會依次匹配 case 分支,select 會隨機執行下面的 case 分支,所以想要做到先消費 messageCh channel 數據,如果只有單個 goroutine 生產數據可以這樣做:

如果有多個 goroutine 生產數據,那麼可以這樣:

for {
   select {
    case v := <-messageCh:
        fmt.Println(v)
    case <-disconnectCh:
        for {
           select {
                case v := <-messageCh:
                        fmt.Println(v)
                default:
                        fmt.Println("disconnection, return")
                    return
                            }
                    }
            }
    }

在讀取 disconnectCh 的時候裏面再套一個循環讀取 messageCh,讀完了之後會調用 default 分支進行 return。

   17.2 不要使用 nil channel

使用 nil channel 進行收發數據的時候會永遠阻塞,例如發送數據:

var ch chan int
ch <- 0 //block

接收數據:

var ch chan int
<-ch //block

   17.3 Channel 的 close 問題

channel 在 close 之後仍然可以接收數據的,例如:

ch1 := make(chan int, 1)
  close(ch1)
  for {
    v := <-ch1
    fmt.Println(v)
  }

這段代碼會一直 print 0。這會導致什麼問題呢?比如我們想要將兩個 channel 的數據彙集到另一個 channel 中:

func merge(ch1, ch2 <-chan int) <-chan int {
        ch := make(chan int, 1)
        go func() {
          for {
            select {
            case v:= <-ch1:
              ch <- v
            case v:= <-ch2:
              ch <- v
            }
          }
          close(ch) // 永遠運行不到
        }()
        return ch
}

由於 channel 被 close 了還可以接收到數據,所以上面代碼中,即使 ch1 和 ch2 都被 close 了,也是運行不到 close(ch) 這段代碼,並且還一直將 0 推入到 ch channel 中。所以爲了感知到 channel 被關閉了,我們應該使用 channel 返回的兩個參數:

   v, open := <-ch1
   fmt.Print(v, open) //open返回false 表示沒有被關閉

那麼回到我們上面的例子中,就可以這樣做:

func merge(ch1, ch2 <-chan int) <-chan int {
  ch := make(chan int, 1)
  ch1Closed := false
  ch2Closed := false

  go func() {
    for {
      select {
      case v, open := <-ch1:
        if !open { // 如果已經關閉
          ch1Closed = true //標記爲true
          break
        }
        ch <- v
      case v, open := <-ch2:
        if !open { // 如果已經關閉
          ch2Closed = true//標記爲true
          break
        }
        ch <- v
      }

      if ch1Closed && ch2Closed {//都關閉了
        close(ch)//關閉ch
        return
      }
    }
  }()
  return ch
}

通過兩個標記以及返回的 open 變量就可以判斷 channel 是否被關閉了,如果都關閉了,那麼執行 close(ch)。

18 string format 帶來的 dead lock

如果類型定義了 String() 方法,它會被用在 fmt.Printf() 中生成默認的輸出:等同於使用格式化描述符 %v 產生的輸出。還有 fmt.Print() 和 fmt.Println() 也會自動使用 String() 方法。

那麼我們看看下面的例子:

type Customer struct {
  mutex sync.RWMutex
  id string
  age int
}

func (c *Customer) UpdateAge(age int) error {
  c.mutex.Lock()
  defer c.mutex.Unlock()

  if age < 0 {
    return fmt.Errorf("age should be positive for customer %v", c)
  }

  c.age = age
  return nil
}

func (c *Customer) String() string {
  fmt.Println("enter string method")
  c.mutex.RLock()
  defer c.mutex.RUnlock()
  return fmt.Sprintf("id %s, age %d", c.id, c.age)
}

這個例子中,如果調用 UpdateAge 方法 age 小於 0 會調用 fmt.Errorf,格式化輸出,這個時候 String() 方法裏面也進行了加鎖,那麼這樣會造成死鎖。

mutex.Lock -> check age -> Format error -> call String() -> mutex.RLock

解決方法也很簡單,一個是縮小鎖的範圍,在 check age 之後再加鎖,另一種方法是 Format error 的時候不要 Format 整個結構體,可以改成 Format id 就行了。

19 錯誤使用 sync.WaitGroup

sync.WaitGroup 通常用在併發中等待 goroutines 任務完成,用 Add 方法添加計數器,當任務完成後需要調用 Done 方法讓計數器減一。等待的線程會調用 Wait 方法等待,直到 sync.WaitGroup 內計數器爲零。

需要注意的是 Add 方法是怎麼使用的,如下:

wg := sync.WaitGroup{}
    var v uint64
    
    for i := 0; i < 3; i++ {
            go func() {
                    wg.Add(1)
                    atomic.AddUint64(&v, 1)
                    wg.Done()
            }()
    }
    
    wg.Wait()
    fmt.Println(v)

這樣使用可能會導致 v 不一定等於 3,因爲在 for 循環裏面創建的 3 個 goroutines 不一定比外面的主線程先執行,從而導致在調用 Add 方法之前可能 Wait 方法就執行了,並且恰好 sync.WaitGroup 裏面計數器是零,然後就通過了。

正確的做法應該是在創建 goroutines 之前就將要創建多少個 goroutines 通過 Add 方法添加進去。

20 不要拷貝 sync 類型

sync 包裏面提供一些併發操作的類型,如 mutex、condition、wait gorup 等等,這些類型都不應該被拷貝之後使用。

有時候我們在使用的時候拷貝是很隱祕的,比如下面:

type Counter struct {
  mu sync.Mutex
  counters map[string]int
}

func (c Counter) Increment(name string) {
  c.mu.Lock()
  defer c.mu.Unlock()
  c.counters[name]++
}

func NewCounter() Counter {
  return Counter{counters: map[string]int{}}
}

func main() {
  counter := NewCounter()
  go counter.Increment("aa")
  go counter.Increment("bb")
}

receiver 是一個值類型,所以調用 Increment 方法的時候實際上拷貝了一份 Counter 裏面的變量。這裏我們可以將 receiver 改成一個指針,或者將 sync.Mutex 變量改成指針類型。

所以如果:

遇到這種情況需要注意檢查一下,我們可以借用 go vet 來檢測,比如上面如果併發調用了就可以檢測出來:

» go vet . bear@BEARLUO-MB7
# github.com/cch123/gogctuner/main
./main.go:53:9: Increment passes lock by value: github.com/cch123/gogctuner/main.Counter contains sync.Mutex

21 time.After 內存泄露

我們用一個簡單的例子模擬一下:

package main

import (
    "fmt"
    "time"
)
//define a channel
var chs chan int

func Get() {
    for {
        select {
            case v := <- chs:
                fmt.Printf("print:%v\n", v)
            case <- time.After(3 * time.Minute):
                fmt.Printf("time.After:%v", time.Now().Unix())
        }
    }
}

func Put() {
    var i = 0
    for {
        i++
        chs <- i
    }
}

func main() {
    chs = make(chan int, 100)
    go Put()
    Get()
}

邏輯很簡單就是先往 channel 裏面存數據,然後不停地使用 for select case 語法從 channel 裏面取數據,爲了防止長時間取不到數據,所以在上面加了 time.After 定時器,這裏只是簡單打印一下。

然後我沒用 pprof 看一下內存佔用:

$ go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap

發現不一會兒 Timer 的內存佔用很高了。這是因爲在計時器觸發之前,垃圾收集器不會回收 Timer,但是在循環裏面每次都調用 time.After 都會實例化一個一個新的定時器,並且這個定時器會在激活之後纔會被清除。

爲了避免這種情況我們可以使用 下面代碼:

func Get() {
    delay := time.NewTimer(3 * time.Minute)

    defer delay.Stop()

    for {
        delay.Reset(3 * time.Minute)

        select {
            case v := <- chs:
                fmt.Printf("print:%v\n", v)
            case <- delay.C:
                fmt.Printf("time.After:%v", time.Now().Unix())
        }
    }
}

22 HTTP body 忘記 Close 導致的泄露

type handler struct {
        client http.Client
        url string
}

func (h handler) getBody() (string, error) {
        resp, err := h.client.Get(h.url)
        if err != nil {
                return "", err
        }

        body, err := io.ReadAll(resp.Body)
        if err != nil {
                return "", err
        }

        return string(body), nil
}

上面這段代碼看起來沒什麼問題,但是 resp 是 *http.Response 類型,裏面包含了 Body io.ReadCloser 對象,它是一個 io 類,必須要正確關閉,否則是會產生資源泄露的。一般我們可以這麼做:

defer func() {
        err := resp.Body.Close()
        if err != nil {
                log.Printf("failed to close response: %v\n", err)
        }
}()

23 Cache line

目前在計算機中,主要有兩大存儲器 SRAM 和 DRAM。主存儲器是由 DRAM 實現的,也就是我們常說的內存,在 CPU 裏通常會有 L1、L2、L3 這樣三層高速緩存是用 SRAM 實現的。

當從內存中取單元到 cache 中時,會一次取一個 cacheline 大小的內存區域到 cache 中,然後存進相應的 cacheline 中,所以當你讀取一個變量的時候,可能會把它相鄰的變量也讀取到 CPU 的緩存中 (如果正好在一個 cacheline 中),因爲有很大的幾率你會繼續訪問相鄰的變量,這樣 CPU 利用緩存就可以加速對內存的訪問。

cacheline 大小通常有 32 bit,64 bit, 128 bit。拿我電腦的 64 bit 舉例:

cat /sys/devices/system/cpu/cpu1/cache/index0/coherency_line_size
64

我們設置兩個函數,一個 index 加 2,一個 index 加 8:

func sum2(s []int64) int64 {
  var total int64
  for i := 0; i < len(s); i +={
    total += s[i]
  }
  return total
}

func sum8(s []int64) int64 {
  var total int64
  for i := 0; i < len(s); i +={
    total += s[i]
  }
  return total
}

這看起來 sum8 處理的元素比 sum2 少四倍,那麼性能應該也快四倍左右,書上說只快了 10%,但是我沒測出來這個數據,無所謂了大家知道因爲 cacheline 的存在,並且數據在 L1 緩存裏面性能很高就行了。

然後再看看 slice 類型的結構體和結構體裏包含 slice:

type Foo struct {
        a int64
        b int64
}

func sumFoo(foos []Foo) int64 {
        var total int64
        for i := 0; i < len(foos); i++ {
                total += foos[i].a
        }
        return total
}

Foo 裏面包含了兩個字段 a 和 b, sumFoo 會遍歷 Foo slice 將所有 a 字段加起來返回。

type Bar struct {
        a []int64
        b []int64
}

func sumBar(bar Bar) int64 {
        var total int64
        for i := 0; i < len(bar.a); i++ {
                total += bar.a[i]
        }
        return total
}

Bar 裏面是包含了 a,b 兩個 slice,sumBar 會將 Bar 裏面的 a 的元素和相加返回。我們同樣用兩個 benchmark 測試一下:

func Benchmark_sumBar(b *testing.B) {
  s := Bar{
    a: make([]int64, 16),
    b: make([]int64, 16),
  }

  b.RunParallel(func(pb *testing.PB) {
    for pb.Next() {
      sumBar(s)
    }
  })
}

func Benchmark_sumFoo(b *testing.B) {
  s := make([]Foo, 16)

  b.RunParallel(func(pb *testing.PB) {
    for pb.Next() {
      sumFoo(s)
    }
  })
}

測試結果:

# go test -gcflags "-N -l" -bench .
Benchmark_sumBar-16 249029368 4.855 ns/op
Benchmark_sumFoo-16 238571205 5.056 ns/op

sumBar 會比 sumFoo 快一點的。這是因爲對於 sumFoo 來說要讀完整個數據纔行,而對於 sumBar 來說只需要讀前 16 bytes 讀入到 cache line:

24 關於 False Sharing 造成的性能問題

False Sharing 是由於多線程對於同一片內存進行並行讀寫操作的時候會造成內存緩存失效,而反覆將數據載入緩存所造成的性能問題。

因爲現在 CPU 的緩存都是分級的,對於 L1 緩存來說是每個 Core 所獨享的,那麼就有可能面臨緩存數據失效的問題。

如果同一片數據被多個 Core 同時加載,那麼它就是共享狀態在共享狀態下想要修改數據要先向所有的其他 CPU 核心廣播一個請求,要求先把其他 CPU 核心裏面的 cache ,都變成無效的狀態,然後再更新當前 cache 裏面的數據。

CPU 核心裏面的 cache 變成無效之後就不能使用了,需要重新加載,因爲不同級別的緩存的速度是差異很大的,所以這其實性能影響還蠻大的,我們寫個測試看看。

type MyAtomic interface {
  IncreaseAllEles()
}

type Pad struct {
  a uint64
  _p1 [15]uint64
  b uint64
  _p2 [15]uint64
  c uint64
  _p3 [15]uint64
}

func (myatomic *Pad) IncreaseAllEles() {
  atomic.AddUint64(&myatomic.a, 1)
  atomic.AddUint64(&myatomic.b, 1)
  atomic.AddUint64(&myatomic.c, 1)
}

type NoPad struct {
  a uint64
  b uint64
  c uint64
}

func (myatomic *NoPad) IncreaseAllEles() {
  atomic.AddUint64(&myatomic.a, 1)
  atomic.AddUint64(&myatomic.b, 1)
  atomic.AddUint64(&myatomic.c, 1)
}

這裏我定義了兩個結構體 Pad 和 NoPad。然後我們定義一個 benchmark 進行多線程測試:

func testAtomicIncrease(myatomic MyAtomic) {
  paraNum := 1000
  addTimes := 1000
  var wg sync.WaitGroup
  wg.Add(paraNum)
  for i := 0; i < paraNum; i++ {
    go func() {
      for j := 0; j < addTimes; j++ {
        myatomic.IncreaseAllEles()
      }
      wg.Done()
    }()
  }
  wg.Wait()

}
func BenchmarkNoPad(b *testing.B) {
  myatomic := &NoPad{}
  b.ResetTimer()
  testAtomicIncrease(myatomic)
}

func BenchmarkPad(b *testing.B) {
  myatomic := &Pad{}
  b.ResetTimer()
  testAtomicIncrease(myatomic)
}

結果可以看到快了 40% 左右:

BenchmarkNoPad
BenchmarkNoPad-10      1000000000           0.1360 ns/op
BenchmarkPad
BenchmarkPad-10        1000000000           0.08887 ns/op

如果沒有 pad 話,變量數據都會在一條 cache line 裏面,這樣如果其中一個線程修改了數據會導致另一個線程的 cache line 無效,需要重新加載:

加了 padding 之後數據都不在同一個 cache line 上了,即使發生了修改 invalid 不是同一行數據也不需要重新加載。

25 內存對齊

簡而言之,現在的 CPU 訪問內存的時候是一次性訪問多個 bytes,比如 64 位架構一次訪問 8bytes ,該處理器只能從地址爲 8 的倍數的內存開始讀取數據,所以要求數據在存放的時候首地址的值是 8 的倍數存放,這就是所謂的內存對齊。

比如下面的例子中因爲內存對齊的存在,所以下面的例子中 b 這個字段只能在後面另外找地址爲 8 的倍數地址開始存放:

除此之外還有一個零大小字段對齊的問題,如果結構體或數組類型不包含大小大於零的字段或元素,那麼它的大小就爲 0。比如 x [0]int8, 空結構體 struct{} 。當它作爲字段時不需要對齊,但是作爲結構體最後一個字段時需要對齊。我們拿空結構體來舉個例子:

type M struct {
    m int64
    x struct{}
}

type N struct {
    x struct{}
    n int64
}

func main() {
    m := M{}
    n := N{}
    fmt.Printf("as final field size:%d\nnot as final field size:%d\n", unsafe.Sizeof(m), unsafe.Sizeof(n))
}

輸出:

as final field size:16
not as final field size:8

當然,我們不可能手動去調整內存對齊,我們可以通過使用工具 fieldalignment:

$ go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest

$ fieldalignment -fix .\main\my.go
main\my.go:13:9: struct of size 24 could be 16

26 逃逸分析

Go 是通過在編譯器裏做逃逸分析(escape analysis)來決定一個對象放棧上還是放堆上,不逃逸的對象放棧上,可能逃逸的放堆上。對於 Go 來說,我們可以通過下面指令來看變量是否逃逸:

go run -gcflags '-m -l' main.go

   26.1 指針逃逸

在函數中創建了一個對象,返回了這個對象的指針。這種情況下,函數雖然退出了,但是因爲指針的存在,對象的內存不能隨着函數結束而回收,因此只能分配在堆上。

type Demo struct {
  name string
}

func createDemo(name string) *Demo {
  d := new(Demo) // 局部變量 d 逃逸到堆
  d.name = name
  return d
}

func main() {
  demo := createDemo("demo")
  fmt.Println(demo)
}

我們檢測一下:

go run -gcflags '-m -l'  .\main\main.go
# command-line-arguments
main\main.go:12:17: leaking param: name
main\main.go:13:10: new(Demo) escapes to heap
main\main.go:20:13: ... argument does not escape
&{demo}

   26.2 interface{}/any 動態類型逃逸

因爲編譯期間很難確定其參數的具體類型,也會發生逃逸,例如這樣:

func createDemo(name string) any {
  d := new(Demo) // 局部變量 d 逃逸到堆
  d.name = name
  return d
}

   26.3 切片長度或容量沒指定逃逸

如果使用局部切片時,已知切片的長度或容量,請使用常量或數值字面量來定義,否則也會逃逸:

func main() {
    number := 10
    s1 := make([]int, 0, number)
    for i := 0; i < number; i++ {
        s1 = append(s1, i)
    }
    s2 := make([]int, 0, 10)
    for i := 0; i < 10; i++ {
        s2 = append(s2, i)
    }
}

輸出一下:

go run -gcflags '-m -l'  main.go    
 
./main.go:65:12: make([]int, 0, number) escapes to heap
./main.go:69:12: make([]int, 0, 10) does not escape

   26.4 閉包

例如下面:Increase() 返回值是一個閉包函數,該閉包函數訪問了外部變量 n,那變量 n 將會一直存在,直到 in 被銷燬。很顯然,變量 n 佔用的內存不能隨着函數 Increase() 的退出而回收,因此將會逃逸到堆上。

func Increase() func() int {
  n := 0
  return func() int {
    n++
    return n
  }
}

func main() {
  in := Increase()
  fmt.Println(in()) // 1
  fmt.Println(in()) // 2
}

輸出:

go run -gcflags '-m -l'  main.go  
 
./main.go:64:5: moved to heap: n
./main.go:65:12: func literal escapes to heap

27 byte slice 和 string 的轉換優化

直接通過強轉 string(bytes) 或者 []byte(str) 會帶來數據的複製,性能不佳,所以在追求極致性能場景使用 unsafe 包的方式直接進行轉換來提升性能:

// toBytes performs unholy acts to avoid allocations
func toBytes(s string) []byte {
  return *(*[]byte)(unsafe.Pointer(&s))
}
// toString performs unholy acts to avoid allocations
func toString(b []byte) string {
  return *(*string)(unsafe.Pointer(&b))
}

在 Go 1.12 中,增加了幾個方法 String、StringData、Slice 和 SliceData , 用來做這種性能轉換。

28 容器中的 GOMAXPROCS

自 Go 1.5 開始, Go 的 GOMAXPROCS 默認值已經設置爲 CPU 的核數,但是在 Docker 或 k8s 容器中 runtime.GOMAXPROCS() 獲取的是 宿主機的 CPU 核數 。這樣會導致 P 值設置過大,導致生成線程過多,會增加上

下文切換的負擔,導致嚴重的上下文切換,浪費 CPU。

所以可以使用 uber 的 automaxprocs 庫,大致原理是讀取 CGroup 值識別容器的 CPU quota,計算得到實際核心數,並自動設置 GOMAXPROCS 線程數量。

import _ "go.uber.org/automaxprocs"

func main() {
  // Your application logic here
}

29 總結

以上就是本篇文章對《100 Go Mistakes How to Avoid Them》書中內容的技術總結,也是一些在日常使用 Go 在工作中容易忽視掉的問題。內容量較大,常見錯誤和技巧也很多,可以反覆閱讀,感興趣的開發者可以收藏下來慢慢研究。

參考:

https://go.dev/ref/mem

https://colobu.com/2019/01/24/cacheline-affects-performance-in-go/

https://teivah.medium.com/go-and-cpu-caches-af5d32cc5592

https://geektutu.com/post/hpg-escape-analysis.html

https://github.com/uber-go/automaxprocs

https://gfw.go101.org/article/unsafe.html

原創作者|羅志贇

技術責編|吳連火

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/XHbfPtUzkUTGF06Ao4jQYA