使用Go兩年學到的五大經驗教訓 - hashnode

發表於2021-06-02

在本文中,我將討論其中的一些錯誤以及我在未來專案中嘗試減輕這些錯誤的經驗教訓。這絕不是對理想解決方案的討論,這只是我通過使用 Go 的經驗學習和發展的想法:

 

1. Goroutines

在我看來,Go 作為一種語言非常吸引人的地方(除了它的簡單性和接近 C 的效能)是它能夠輕鬆編寫併發程式碼。Goroutines是編寫併發程式碼的 Go 方式。goroutine 是一個輕量級執行緒,或者稱為綠色執行緒,是的,它不是核心執行緒。Goroutines 是綠色執行緒,因為它們的排程完全由Go 執行時而不是作業系統管理。Go 排程器負責將 goroutine 多路複用到真正的核心執行緒上,這有利於使 goroutine 在啟動時間和記憶體要求方面非常輕量級,從而允許 go 應用程式執行數百萬個 goroutine!

我認為 Go 處理併發的方式是獨一無二的,一個常見的錯誤是用處理任何其他語言(例如 Java)併發的方式處理 Go 併發。Go 處理併發的方式有多麼不同,最著名的例子之一可以總結為:

不要通過共享記憶體來通訊,通過通訊來共享記憶體。

一個非常常見的情況是應用程式將有多個 goroutine 訪問共享記憶體塊。因此,例如,我們正在實現一個連線池,其中有一個可用連線陣列,每個 goroutine 都可以獲取或釋放連線。最常見的方法是使用mutex,它只允許持有 的 goroutinemutex在給定時間以獨佔方式訪問連線陣列。所以程式碼看起來像這樣(注意一些細節被抽象出來以保持程式碼簡潔):

type ConnectionPool struct {
  Mu          sync.Mutex
  Connections []Connection
}

func (pool *ConnectionPool) Acquire() Connection {
  pool.Mu.Lock()
  defer pool.Mu.Unlock()

  //acquire and return a connection
}

func (pool *ConnectionPool) Release(c Connection) {
  pool.Mu.Lock()
  defer pool.Mu.Unlock()

  //release the connection c
}

這看起來很合理,但是如果我們忘記實現鎖定邏輯怎麼辦?如果我們確實實現了它但忘記鎖定眾多功能之一怎麼辦?如果我們沒有忘記鎖定,而是忘記解鎖怎麼辦?如果我們只鎖定臨界區的一部分(欠鎖)會怎樣?或者如果我們鎖定不屬於臨界區的部分(過度鎖定)怎麼辦?這似乎容易出錯,而且通常不是 Go 處理併發的方式。

這讓我們回到了 Go 的口頭禪“不要通過共享記憶體進行通訊,通過通訊共享記憶體”。要理解這意味著什麼,我們首先需要了解什麼是 Go通道channel?通道是實現 goroutine 之間通訊的 Go 方式。它本質上是一個執行緒安全的資料管道,允許 goroutine 在它們之間傳送或接收資料,而無需訪問共享記憶體塊。Go 通道也可以被緩衝,這允許其控制同時呼叫的數量,有效地充當訊號量!

因此,重新修改我們的程式碼以通過通訊而不是鎖定來共享它,我們得到了一個看起來像這樣的程式碼(注意一些細節被抽象出來以保持程式碼簡潔):

type ConnectionPool struct {

  Connections chan Connection

}

func NewConnectionPool(limit int) *ConnectionPool {

  connections := make(chan Connection, limit)

  return &{ Connections: connections }

}

func (pool *ConnectionPool) Acquire() Connection {

  <- pool.Connections

  //acquire and return a connection

}

func (pool *ConnectionPool) Release(c Connection) {

  pool.Connections <- c

  //release the connection c

}

使用 Go 通道不僅減少了程式碼的大小和整體複雜性,而且還抽象了顯式實現執行緒安全的需要。所以現在資料結構本身本質上是執行緒安全的,所以即使我們忘記了這一點,它仍然可以工作。

使用通道的好處很多,這個例子僅僅觸及皮毛,但這裡的教訓是不要像用任何其他語言編寫的那樣在 Go 中編寫併發程式碼。

 

2.如果可以單例,那就單例

Go 應用程式可能必須訪問Database或Cache等,它們是具有連線池的資源示例,這意味著對該資源的併發連線數有限制。根據我的經驗,Go 中的大多數連線物件(資料庫、快取等)都是作為執行緒安全的連線池構建的,可以由多個 goroutine 同時使用,而不是單個連線。

因此,假設我們有一個 Go 應用程式,它mysql通過一個*sql.DB物件作為資料庫進行訪問,該物件本質上是一個到資料庫的連線池。如果應用程式有很多 goroutines,那麼建立一個新*sql.DB物件是沒有意義的,實際上這可能會導致連線池耗盡(注意使用後不關閉連線也會導致這種情況)。所以*sql.DB表示連線池的物件必須是單例是有道理的,所以即使 goroutine 試圖建立一個新物件,它也會返回相同的物件,從而不允許有多個連線池。

建立可以在應用程式單例的生命週期內共享的物件通常是一個很好的做法,因為它封裝了此邏輯並防止程式碼不遵守此策略。一個常見的陷阱是實現本身不是一個執行緒安全的建立邏輯單例。例如,考慮下面的程式碼(注意一些細節被抽象出來以保持程式碼簡潔):

var dbInstance *DB

func DBConnection() *DB {
  if dbInstance != nil {
    return dbInstance
  }

  dbInstance = &sql.Open(...)
  return dbInstance
}

前面的程式碼檢查單例物件是否不是nil(這意味著它之前已建立),在這種情況下它返回它,但如果是,nil則它建立一個新連線,將其分配給單例物件並返回它。原則上,這應該只建立一個資料庫連線,但這不是執行緒安全的。

考慮 2 個 goroutine 同時呼叫函式DBConnection()的情況。有可能第一個 goroutine 讀取dbInstance並找到它的值nil然後繼續建立一個新的連線,但是在新建立的例項分配給單例物件之前,第二個 goroutine 也執行相同的檢查,得出相同的結論並且繼續建立一個新連線,給我們留下 2 個連線而不是 1 個。

這個問題可以使用上一節中討論的鎖來處理,但這也不是 Go 的方式。Go 支援預設執行緒安全的原子操作,所以如果我們可以使用保證執行緒安全的東西而不是顯式實現它,那麼讓我們這樣做吧!

因此,重新訪問我們的程式碼以使其成為執行緒安全的,我們得到的程式碼看起來像這樣(注意一些細節被抽象出來以保持程式碼簡潔):

var dbOnce sync.Once
var dbInstance *DB

func DBConnection() *DB {
  dbOnce.Do(func() {
    dbInstance = &sql.Open(...)
  }

  return dbInstance
}

這段程式碼使用了一個被呼叫的 Go 結構sync.Once,它允許我們編寫一個只會執行一次的函式。這樣,即使多個 goroutine 嘗試同時執行,連線建立段也能保證只執行一次。

 

3. 小心阻塞程式碼

有時你的 Go 應用程式會執行阻塞呼叫,可能是對可能不響應的外部服務的請求,或者可能是呼叫在某些條件下阻塞的函式。根據經驗,永遠不要假設呼叫會在適當的時候返回,因為有時它們不會。

通常處理這種情況的方法是設定一個超時時間,在此之後該呼叫將被取消並且可以繼續執行。還建議(如果可能)對單獨的例程執行阻塞呼叫,而不是阻塞主例程。

那麼讓我們考慮一下 Go 應用程式需要通過http. Gohttp客戶端預設不會超時,但我們可以按如下方式設定超時:

&http.Client{Timeout: time.Minute}

雖然這工作得很好,但它非常有限,因為現在我們對通過同一個客戶端(這可能是一個單例物件)執行的所有請求都有相同的超時。Go 有一個名為contex包,它允許我們將請求作用域的值、取消訊號和超時傳遞給處理請求所涉及的所有 goroutine。我們可以這樣使用它:

ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

req := req.WithContext(ctx)
res, err := c.Do(req)

前面的程式碼允許設定每個請求的超時,還可以取消請求或在需要時傳遞每個請求的值。如果傳送請求的goroutine派生了其他goroutine,而這些goroutine在請求超時時都需要退出,那麼這將特別有用。

在這個例子中,我們很幸運:http包支援上下文,但是如果我們正在處理一個不支援的阻塞函式呢?有一種臨時方法可以使用 Go 的select語句和 Go channels(再次!)來實現超時邏輯。考慮以下程式碼:

func SendValue(){
  sendChan := make(chan bool, 1)

  go func() {
    sendChan <- Send()
   }()

  select {
    case <-sendChan:
    case <-time.After(time.Minute):
      return
  }

  //continue logic in case send didn't timeout
}

我們有一個名為SendValue()的函式,它呼叫一個名為Send()的阻塞函式,該函式可能會永遠阻塞。因此,我們初始化一個緩衝布林通道,並在一個單獨的goroutine上執行阻塞函式,完成後在通道中傳送一個訊號,而在主goroutine中,我們阻塞等待通道產生一個值(請求成功)或等待1分鐘後返回。請注意,程式碼缺少取消Send()函式從而終止goroutine的邏輯(否則會導致goroutine洩漏)。

 

4. 優雅終止和清理

如果您正在編寫一個長時間執行的程式,例如 Web 伺服器或後臺作業工作者等,您將面臨突然終止的風險。這種終止可能是因為程式被排程程式終止了,或者即使您正在釋出新程式碼並且正在推出它。

通常,長時間執行的程式可能在其記憶體中包含資料,如果該程式要終止,這些資料將丟失,或者它可能持有需要釋放回資源池的資源。所以當一個程式被殺死時,我們需要能夠執行優雅的終止。優雅地終止程式意味著我們攔截kill signal並執行特定於應用程式的關閉邏輯,以確保在實際終止之前一切正常。

因此,假設我們正在構建一個 Web 伺服器,我們通常希望它能夠像這樣執行:

server := NewServer()
server.Run()

這裡的關鍵思想是Run()無限執行的函式,從某種意義上說,如果我們在main函式結束時呼叫它,則程式不會退出,只有在Run()函式退出時才會退出。

可以實現伺服器邏輯來檢查關閉signal並僅signal在收到關閉時退出,如下所示:

func (server *Server) Run() {
  for {
    //infinite loop

    select {
      case <- server.shutdown:
        return
      default:
        //do work
    }
  }
}

伺服器迴圈檢查訊號是否通過關閉通道傳送,在這種情況下它退出伺服器迴圈,否則繼續執行其工作。

現在,這個難題中唯一缺少的部分是能夠截獲作業系統中斷,例如(SIGKILL或SIGTERM),並呼叫Server.Shutdown(),它執行關閉邏輯(將記憶體重新整理到磁碟、釋放資源、清理等),併傳送關閉訊號以終止伺服器迴圈。我們可以通過以下方式實現:

func main() {
  signals := make(chan os.Signal, 1)
  signal.Notify(signals, os.Interrupt)

  server := NewServer()
  server.Run()

  select {
    case <-signals:
      server.Shutdown()
  }
}

建立了一個os.Signal型別的緩衝通道,並在發生os interrupt中斷時向通道傳送一個訊號。main函式的其餘部分執行伺服器並阻止在通道上等待。當接收到一個訊號時,這意味著發生了作業系統中斷,而不是立即退出,它呼叫伺服器關閉邏輯,給它一個優雅地終止的機會。

 

5. Go 模組 FTW

您的 Go 應用程式具有外部依賴項是很常見的,例如,您正在使用mysql 驅動程式或 redis 驅動程式或任何其他包。當您第一次構建應用程式時,構建過程將獲得每個依賴項的最新版本,這很棒。現在你構建了你的二進位制檔案,你測試了它並且它可以工作,所以你進入了生產階段。

一個月後,您需要新增新功能或進行修補程式,這可能不需要新的依賴項,但需要重新構建應用程式以生成新的二進位制檔案。構建過程還將獲得每個所需軟體包的最新版本,但此版本可能與您在第一次構建時獲得的版本不同,並且可能包含會導致應用程式本身中斷的重大更改。所以很明顯,除非您明確選擇 upgrade ,否則我們需要通過始終獲取每個依賴項的相同版本來管理此問題。

Go.11 引入了go.mod這是在 Go 中處理依賴版本控制的新方法。當你在你的應用程式中初始化一個 Go mod 然後構建它時,它會自動生成一個go.mod檔案和一個go.sum檔案。mod檔案看起來像這樣:

module github.com/org/module_name

go 1.14

require (
    github.com/go-sql-driver/mysql v1.5.0
    github.com/onsi/ginkgo v1.12.3 // indirect
    gopkg.in/redis.v5 v5.2.9
    gopkg.in/yaml.v2 v2.3.0
)

它鎖定了 Go 版本以及用於每個依賴項的版本,因此例如redis v5.2.9,即使 redis 儲存庫v5.3.0作為其最新版本釋出以保證穩定性,我們也將始終獲得每個構建。請注意,標記為間接的第二個依賴項意味著該依賴項不是由您的應用程式直接匯入,而是由其依賴項之一匯入,並且它還鎖定其版本。

使用 Go mods 還有許多其他好處,例如:

  1. 它會自動執行go mod tidy,從而刪除任何不需要的依賴項。
  2. 它允許您從任何目錄執行程式碼(在 go mods 之前,go 專案必須放在特定目錄中)。
  3. 它將整個應用程式包裝成一個module可以匯入到全新專案中的應用程式。如果您決定將某些邏輯包裝到一個包中(例如記錄器包)並將其匯入到所有其他專案中,以便在多個程式碼庫中使用該日誌記錄功能,這將特別有用。

 

相關文章