Go 編程語言:Golang 入門教程
已發表: 2022-03-11什麼是 Go 編程語言?
相對較新的 Go 編程語言整齊地位於風景的中間,提供了許多好的特性並故意省略了許多不好的特性。 它編譯速度快,運行速度快,包括運行時和垃圾收集,具有簡單的靜態類型系統和動態接口,以及優秀的標準庫。 這就是為什麼這麼多開發人員熱衷於學習 Go 編程的原因。
OOP 是 Go 故意忽略的那些特性之一。 它沒有子類,因此沒有繼承菱形或超級調用或虛擬方法來絆倒你。 儘管如此,OOP 的許多有用部分仍然可以通過其他方式獲得。
*Mixins* 可以通過匿名嵌入結構來獲得,允許直接在包含結構上調用它們的方法(參見嵌入)。 以這種方式提升方法稱為 *forwarding*,它與子類化不同:該方法仍將在內部嵌入結構上調用。
嵌入也不意味著多態性。 雖然`A`可能有`B`,但這並不意味著它是`B`——接受`B`的函數不會接受`A`。 為此,我們需要接口,稍後我們將簡要介紹。
同時,Golang 對可能導致混亂和錯誤的功能採取了強有力的立場。 它省略了 OOP 慣用語,例如繼承和多態,有利於組合和簡單的接口。 它淡化了異常處理,以支持返回值中的顯式錯誤。 由gofmt
工具強制執行的 Go 代碼佈局只有一種正確的方法。 等等。
為什麼要學習 Golang?
Go 也是編寫並發程序的好語言:具有許多獨立運行部分的程序。 一個明顯的例子是網絡服務器:每個請求都單獨運行,但請求通常需要共享資源,例如會話、緩存或通知隊列。 這意味著熟練的 Go 程序員需要處理對這些資源的並發訪問。
雖然 Golang 具有一組出色的低級並發處理功能,但直接使用它們可能會變得複雜。 在許多情況下,對這些低級機制進行一些可重用的抽象會使生活變得更加輕鬆。
在今天的 Go 編程教程中,我們將看到一個這樣的抽象:一個可以將任何數據結構轉換為事務服務的包裝器。 我們將使用Fund
類型作為示例——我們的初創公司剩餘資金的簡單存儲,我們可以在其中檢查餘額並進行提款。
為了在實踐中證明這一點,我們將分小步構建服務,一路上弄得一團糟,然後再次清理它。 隨著我們學習 Go 教程的進展,我們會遇到很多很酷的 Go 語言特性,包括:
- 結構類型和方法
- 單元測試和基準測試
- Goroutines 和通道
- 接口和動態類型
建立一個簡單的基金
讓我們編寫一些代碼來跟踪我們創業公司的資金。 基金從給定餘額開始,只能提取資金(我們稍後會計算收入)。
Go 故意不是一種面向對象的語言:沒有類、對像或繼承。 相反,我們將聲明一個名為Fund
的結構類型,它具有一個創建新基金結構的簡單函數和兩個公共方法。
基金.go
package funding type Fund struct { // balance is unexported (private), because it's lowercase balance int } // A regular function returning a pointer to a fund func NewFund(initialBalance int) *Fund { // We can return a pointer to a new struct without worrying about // whether it's on the stack or heap: Go figures that out for us. return &Fund{ balance: initialBalance, } } // Methods start with a *receiver*, in this case a Fund pointer func (f *Fund) Balance() int { return f.balance } func (f *Fund) Withdraw(amount int) { f.balance -= amount }
使用基準測試
接下來我們需要一種方法來測試Fund
。 我們不會編寫單獨的程序,而是使用 Go 的測試包,它為單元測試和基準測試提供了一個框架。 Fund
中的簡單邏輯並不值得為其編寫單元測試,但由於我們稍後會討論很多關於並發訪問 Fund 的內容,因此編寫基準測試是有意義的。
基準測試類似於單元測試,但包含一個多次運行相同代碼的循環(在我們的例子中是fund.Withdraw(1)
)。 這允許框架計算每次迭代需要多長時間,平均來自磁盤尋道、緩存未命中、進程調度和其他不可預測因素的瞬時差異。
測試框架希望每個基準測試至少運行 1 秒(默認情況下)。 為了確保這一點,它會多次調用基準測試,每次都傳入一個不斷增加的“迭代次數”值( bN
字段),直到運行至少需要一秒鐘。
目前,我們的基準只會存入一些錢,然後一次提取一美元。
fund_test.go
package funding import "testing" func BenchmarkFund(b *testing.B) { // Add as many dollars as we have iterations this run fund := NewFund(bN) // Burn through them one at a time until they are all gone for i := 0; i < bN; i++ { fund.Withdraw(1) } if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }
現在讓我們運行它:
$ go test -bench . funding testing: warning: no tests to run PASS BenchmarkWithdrawals 2000000000 1.69 ns/op ok funding 3.576s
那進展順利。 我們運行了 20 億(!)次迭代,最後對余額的檢查是正確的。 我們可以忽略“no tests to run”警告,它指的是我們沒有編寫的單元測試(在本教程後面的 Go 編程示例中,警告被剪掉了)。
Go 中的並發訪問
現在讓基準測試並發,模擬不同的用戶同時提款。 為此,我們將生成 10 個 goroutine,並讓它們中的每一個提取十分之一的資金。
Goroutines 是 Go 語言中並發的基本構建塊。 它們是綠色線程——由 Go 運行時而不是操作系統管理的輕量級線程。 這意味著您可以運行數千個(或數百萬個)它們而無需任何顯著開銷。 Goroutine 使用go
關鍵字生成,並且總是以函數(或方法調用)開頭:
// Returns immediately, without waiting for `DoSomething()` to complete go DoSomething()
通常,我們希望只用幾行代碼就生成一個簡短的一次性函數。 在這種情況下,我們可以使用閉包代替函數名:
go func() { // ... do stuff ... }() // Must be a function *call*, so remember the ()
一旦我們所有的 goroutine 都生成了,我們需要一種方法來等待它們完成。 我們可以使用channels自己構建一個,但我們還沒有遇到過這些,所以這就跳過了。
現在,我們可以只使用 Go 標準庫中的WaitGroup
類型,它就是為此目的而存在的。 我們將創建一個(稱為“ wg
”)並在生成每個 worker 之前調用wg.Add(1)
來跟踪有多少。 然後工作人員將使用wg.Done()
進行報告。 同時在主 goroutine 中,我們可以說wg.Wait()
來阻塞,直到每個工人都完成。
在下一個示例中的工作 goroutine 中,我們將使用defer
來調用wg.Done()
。
defer
接受一個函數(或方法)調用,並在當前函數返回之前立即運行它,在其他所有操作完成之後。 這對於清理很方便:
func() { resource.Lock() defer resource.Unlock() // Do stuff with resource }()
通過這種方式,我們可以輕鬆地將Unlock
與其Lock
匹配,以提高可讀性。 更重要的是,即使在主函數中出現恐慌(我們可以在其他語言中通過 try-finally 處理),延遲函數也會運行。
最後,延遲函數將按照調用它們的相反順序執行,這意味著我們可以很好地進行嵌套清理(類似於嵌套goto
和label
的 C 習語,但更整潔):
func() { db.Connect() defer db.Disconnect() // If Begin panics, only db.Disconnect() will execute transaction.Begin() defer transaction.Close() // From here on, transaction.Close() will run first, // and then db.Disconnect() // ... }()
好的,說了這麼多,這是新版本:
fund_test.go
package funding import ( "sync" "testing" ) const WORKERS = 10 func BenchmarkWithdrawals(b *testing.B) { // Skip N = 1 if bN < WORKERS { return } // Add as many dollars as we have iterations this run fund := NewFund(bN) // Casually assume bN divides cleanly dollarsPerFounder := bN / WORKERS // WaitGroup structs don't need to be initialized // (their "zero value" is ready to use). // So, we just declare one and then use it. var wg sync.WaitGroup for i := 0; i < WORKERS; i++ { // Let the waitgroup know we're adding a goroutine wg.Add(1) // Spawn off a founder worker, as a closure go func() { // Mark this worker done when the function finishes defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { fund.Withdraw(1) } }() // Remember to call the closure! } // Wait for all the workers to finish wg.Wait() if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }
我們可以預測這裡會發生什麼。 工人將在彼此之上執行Withdraw
。 在其中, f.balance -= amount
將讀取餘額,減一,然後將其寫回。 但有時兩個或多個工人會同時讀取相同的餘額,並進行相同的減法運算,我們最終會得到錯誤的總數。 正確的?
$ go test -bench . funding BenchmarkWithdrawals 2000000000 2.01 ns/op ok funding 4.220s
不,還是過去了。 這裡發生了什麼?
請記住,goroutine 是綠色線程——它們由 Go 運行時管理,而不是由操作系統管理。 運行時在它可用的許多 OS 線程中調度 goroutine。 在編寫這個 Go 語言教程時,Go 並沒有試圖猜測它應該使用多少個 OS 線程,如果我們想要多個,我們必須這樣說。 最後,當前運行時不會搶占 goroutine —— goroutine 將繼續運行,直到它執行一些表明它已準備好中斷的操作(例如與通道交互)。
所有這一切意味著雖然我們的基準測試現在是並發的,但它不是並行的。 一次只有一名工人會運行,並且會一直運行直到完成。 我們可以通過GOMAXPROCS
環境變量告訴 Go 使用更多線程來改變這一點。
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 account_test.go:39: Balance wasn't zero: 4238 ok funding 0.007s
這樣更好。 正如我們預期的那樣,現在我們顯然正在失去一些提款。
使其成為服務器
在這一點上,我們有多種選擇。 我們可以在基金周圍添加一個明確的互斥鎖或讀寫鎖。 我們可以使用帶有版本號的比較和交換。 我們可以全力以赴並使用 CRDT 方案(也許用每個客戶的交易列表替換balance
字段,並從中計算餘額)。
但我們現在不會做任何這些事情,因為它們很亂或很可怕,或者兩者兼而有之。 相反,我們將決定一個基金應該是一個服務器。 什麼是服務器? 這是你談話的東西。 在 Go 中,事物通過渠道進行對話。
通道是 goroutine 之間的基本通信機制。 值被發送到通道( channel <- value
),並且可以在另一端接收( value = <- channel
)。 通道是“goroutine 安全的”,這意味著任意數量的 goroutine 可以同時向它們發送和接收。
在某些情況下,緩衝通信通道可能是一種性能優化,但應謹慎使用(並進行基準測試!)。
但是,緩衝通道的用途並非直接與通信有關。
例如,一個常見的節流習慣用法會創建一個(例如)緩衝區大小為“10”的通道,然後立即向其中發送十個令牌。 然後生成任意數量的工作 goroutine,每個工作 goroutine 在開始工作之前從通道接收一個令牌,然後將其發送回來。 然後,無論有多少工人,只有十個人會同時工作。
默認情況下,Go 通道是無緩衝的。 這意味著向通道發送值將被阻塞,直到另一個 goroutine 準備好立即接收它。 Go 還支持通道的固定緩衝區大小(使用make(chan someType, bufferSize)
)。 但是,對於正常使用,這通常是一個壞主意。
想像一下我們基金的網絡服務器,每個請求都會在其中提款。 當事情非常繁忙時, FundServer
將無法跟上,嘗試發送到其命令通道的請求將開始阻塞並等待。 此時,我們可以在服務器中強制執行最大請求計數,並向超過該限制的客戶端返回一個合理的錯誤代碼(如503 Service Unavailable
)。 當服務器超載時,這是可能的最佳行為。
向我們的通道添加緩衝會使這種行為的確定性降低。 基於客戶端更早看到的信息(也許對於上游已經超時的請求),我們很容易得到一長串未處理的命令。 這同樣適用於許多其他情況,例如當接收方無法跟上發送方時通過 TCP 施加背壓。
無論如何,對於我們的 Go 示例,我們將堅持使用默認的無緩衝行為。
我們將使用一個通道向我們的FundServer
發送命令。 每個基準測試工作者都會向通道發送命令,但只有服務器會接收它們。
我們可以將 Fund 類型直接轉換為服務器實現,但這會很麻煩——我們將混合併發處理和業務邏輯。 相反,我們將完全保留 Fund 類型,並使FundServer
成為一個單獨的包裝器。

像任何服務器一樣,包裝器將有一個主循環,它在其中等待命令,並依次響應每個命令。 這裡還有一個細節需要說明:命令的類型。
我們可以讓我們的命令通道將 *pointers* 指向命令 (`chan *TransactionCommand`)。 為什麼我們沒有?
在 goroutine 之間傳遞指針是有風險的,因為任何一個 goroutine 都可能修改它。 它的效率通常也較低,因為另一個 goroutine 可能運行在不同的 CPU 內核上(意味著更多的緩存失效)。
只要有可能,更喜歡傳遞普通值。
在下面的下一節中,我們將發送幾個不同的命令,每個命令都有自己的結構類型。 我們希望服務器的命令通道接受其中的任何一個。 在 OOP 語言中,我們可以通過多態來做到這一點:讓通道採用超類,其中各個命令類型是子類。 在 Go 中,我們使用接口代替。
接口是一組方法簽名。 實現所有這些方法的任何類型都可以被視為該接口(無需聲明這樣做)。 對於我們的第一次運行,我們的命令結構實際上不會公開任何方法,因此我們將使用空接口interface{}
。 由於它沒有要求,任何值(包括像整數這樣的原始值)都滿足空接口。 這並不理想——我們只想接受命令結構——但我們稍後會回到它。
現在,讓我們開始為我們的 Go 服務器搭建腳手架:
服務器.go
package funding type FundServer struct { Commands chan interface{} fund Fund } func NewFundServer(initialBalance int) *FundServer { server := &FundServer{ // make() creates builtins like channels, maps, and slices Commands: make(chan interface{}), fund: NewFund(initialBalance), } // Spawn off the server's main loop immediately go server.loop() return server } func (s *FundServer) loop() { // The built-in "range" clause can iterate over channels, // amongst other things for command := range s.Commands { // Handle the command } }
現在讓我們為命令添加幾個 Golang 結構類型:
type WithdrawCommand struct { Amount int } type BalanceCommand struct { Response chan int }
WithdrawCommand
只包含要提取的金額。 沒有回應。 BalanceCommand
確實有響應,因此它包含一個發送它的通道。 這可確保響應始終到達正確的位置,即使我們的基金後來決定無序響應。
現在我們可以編寫服務器的主循環:
func (s *FundServer) loop() { for command := range s.Commands { // command is just an interface{}, but we can check its real type switch command.(type) { case WithdrawCommand: // And then use a "type assertion" to convert it withdrawal := command.(WithdrawCommand) s.fund.Withdraw(withdrawal.Amount) case BalanceCommand: getBalance := command.(BalanceCommand) balance := s.fund.Balance() getBalance.Response <- balance default: panic(fmt.Sprintf("Unrecognized command: %v", command)) } } }
唔。 這有點醜我們正在打開命令類型,使用類型斷言,並且可能會崩潰。 無論如何,讓我們繼續前進並更新基準以使用服務器。
func BenchmarkWithdrawals(b *testing.B) { // ... server := NewFundServer(bN) // ... // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { server.Commands <- WithdrawCommand{ Amount: 1 } } }() } // ... balanceResponseChan := make(chan int) server.Commands <- BalanceCommand{ Response: balanceResponseChan } balance := <- balanceResponseChan if balance != 0 { b.Error("Balance wasn't zero:", balance) } }
這也有點難看,尤其是當我們檢查餘額時。 沒關係。 讓我們嘗試一下:
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 465 ns/op ok funding 2.822s
更好的是,我們不再丟失提款。 但是代碼越來越難以閱讀,並且存在更嚴重的問題。 如果我們發出BalanceCommand
然後忘記讀取響應,我們的基金服務器將永遠阻止嘗試發送它。 讓我們清理一下。
使其成為服務
服務器是您與之交談的東西。 什麼是服務? 服務是您使用 API與之交談的東西。 我們不會讓客戶端代碼直接與命令通道一起工作,而是使通道不導出(私有)並將可用命令包裝在函數中。
type FundServer struct { commands chan interface{} // Lowercase name, unexported // ... } func (s *FundServer) Balance() int { responseChan := make(chan int) s.commands <- BalanceCommand{ Response: responseChan } return <- responseChan } func (s *FundServer) Withdraw(amount int) { s.commands <- WithdrawCommand{ Amount: amount } }
現在我們的基準可以說server.Withdraw(1)
和balance := server.Balance()
,並且意外發送無效命令或忘記讀取響應的可能性較小。
這些命令還有很多額外的樣板文件,但我們稍後會回來討論。
交易
最終,錢總是用完。 讓我們同意,當我們的基金降至最後十美元時,我們將停止提取,並將這筆錢花在公共比薩餅上以慶祝或同情。 我們的基準將反映這一點:
// Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { // Stop when we're down to pizza money if server.Balance() <= 10 { break } server.Withdraw(1) } }() } // ... balance := server.Balance() if balance != 10 { b.Error("Balance wasn't ten dollars:", balance) }
這次我們真的可以預測結果了。
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 fund_test.go:43: Balance wasn't ten dollars: 6 ok funding 0.009s
我們又回到了開始的地方——幾個工人可以一次讀取餘額,然後全部更新。 為了解決這個問題,我們可以在基金本身中添加一些邏輯,例如minimumBalance
屬性,或者添加另一個名為WithdrawIfOverXDollars
的命令。 這些都是可怕的想法。 我們的協議是我們之間的協議,而不是基金的財產。 我們應該將其保留在應用程序邏輯中。
我們真正需要的是事務,就像數據庫事務一樣。 由於我們的服務一次只執行一個命令,這非常容易。 我們將添加一個包含回調(閉包)的Transact
命令。 服務器將在其自己的 goroutine 中執行該回調,並傳入原始Fund
。 然後回調可以安全地對Fund
做任何牠喜歡的事情。
在下一個示例中,我們做錯了兩件小事。
首先,我們使用“完成”通道作為信號量,讓調用代碼知道其事務何時完成。 很好,但為什麼通道類型是“bool”? 我們只會向它發送 `true` 來表示“完成”(發送 `false` 甚至意味著什麼?)。 我們真正想要的是一個單一狀態的值(一個沒有價值的值?)。 在 Go 中,我們可以使用空結構類型:`struct{}` 來做到這一點。 這還具有使用較少內存的優點。 在示例中,我們將堅持使用 `bool`,以免看起來太嚇人。
其次,我們的交易回調沒有返回任何東西。 正如我們稍後將看到的,我們可以使用範圍技巧從回調中獲取值到調用代碼中。 然而,真實係統中的事務有時可能會失敗,所以 Go 的約定是讓事務返回一個“錯誤”(然後在調用代碼中檢查它是否為“nil”)。
我們現在也沒有這樣做,因為我們沒有任何錯誤要生成。
// Typedef the callback for readability type Transactor func(fund *Fund) // Add a new command type with a callback and a semaphore channel type TransactionCommand struct { Transactor Transactor Done chan bool } // ... // Wrap it up neatly in an API method, like the other commands func (s *FundServer) Transact(transactor Transactor) { command := TransactionCommand{ Transactor: transactor, Done: make(chan bool), } s.commands <- command <- command.Done } // ... func (s *FundServer) loop() { for command := range s.commands { switch command.(type) { // ... case TransactionCommand: transaction := command.(TransactionCommand) transaction.Transactor(s.fund) transaction.Done <- true // ... } } }
我們的事務回調不直接返回任何東西,但是 Go 語言可以很容易地直接從閉包中獲取值,所以我們將在基準測試中這樣做,以便在資金不足時設置pizzaTime
標誌:
pizzaTime := false for i := 0; i < dollarsPerFounder; i++ { server.Transact(func(fund *Fund) { if fund.Balance() <= 10 { // Set it in the outside scope pizzaTime = true return } fund.Withdraw(1) }) if pizzaTime { break } }
並檢查它是否有效:
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 775 ns/op ok funding 4.637s
無非交易
您現在可能已經發現了一個清理更多內容的機會。 由於我們有一個通用的Transact
命令,我們不再需要WithdrawCommand
或BalanceCommand
。 我們將根據事務重寫它們:
func (s *FundServer) Balance() int { var balance int s.Transact(func(f *Fund) { balance = f.Balance() }) return balance } func (s *FundServer) Withdraw(amount int) { s.Transact(func (f *Fund) { f.Withdraw(amount) }) }
現在服務器接受的唯一命令是TransactionCommand
,所以我們可以在它的實現中移除整個interface{}
混亂,讓它只接受事務命令:
type FundServer struct { commands chan TransactionCommand fund *Fund } func (s *FundServer) loop() { for transaction := range s.commands { // Now we don't need any type-switch mess transaction.Transactor(s.fund) transaction.Done <- true } }
好多了。
我們可以在這裡採取最後一步。 除了對Balance
和Withdraw
的便利功能外,服務實現不再與Fund
綁定。 它可以管理interface{}
並用於包裝任何東西,而不是管理Fund
。 但是,每個事務回調都必須將interface{}
轉換回實際值:
type Transactor func(interface{}) server.Transact(func(managedValue interface{}) { fund := managedValue.(*Fund) // Do stuff with fund ... })
這是醜陋且容易出錯的。 我們真正想要的是編譯時泛型,因此我們可以為特定類型(如*Fund
)“模板化”服務器。
不幸的是,Go 還不支持泛型。 一旦有人為它找出一些合理的語法和語義,它就會最終出現。 同時,仔細的接口設計通常會消除對泛型的需求,如果不需要,我們可以使用類型斷言(在運行時檢查)。
我們完了嗎?
是的。
嗯,好吧,不。
例如:
事務中的恐慌將殺死整個服務。
沒有超時。 永遠不會返回的事務將永遠阻塞服務。
如果我們的 Fund 增長了一些新字段並且交易在更新它們的過程中崩潰了,我們就會有不一致的狀態。
交易能夠洩露託管的
Fund
對象,這是不好的。沒有合理的方法可以跨多個基金進行交易(例如從一個基金中提取並存入另一個基金)。 我們不能僅僅嵌套我們的事務,因為它會允許死鎖。
異步運行事務現在需要一個新的 goroutine 和很多麻煩。 與此相關的是,我們可能希望能夠在長期交易正在進行時從其他地方讀取最新的
Fund
狀態。
在我們的下一個 Go 編程語言教程中,我們將研究解決這些問題的一些方法。