1. 對建立的gorouting負載
1.1 不要建立一個你不知道何時退出的 goroutine
下面的程式碼有什麼問題? 是不是在我們的程式種經常寫類似的程式碼?
// Week03/blog/01/01.go
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
// 初始化函式
func setup() {
// 這裡面有一些初始化的操作
}
// 入口函式
func main() {
setup()
// 主服務
server()
// for debug
pprof()
select {}
}
// http api server
func server() {
go func() {
mux := http.NewServeMux()
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
// 主服務
if err := http.ListenAndServe(":8080", mux); err != nil {
log.Panicf("http server err: %+v", err)
return
}
}()
}
// 輔助服務,用來debug效能測試
func pprof() {
// 輔助服務,監聽了其他埠,這裡是 pprof 服務,用於 debug
go http.ListenAndServe(":8081", nil)
}
以上程式碼有幾個問題,是否想到過?
- 如果
server
是在其他的包裡面, 如果沒有特殊的說明, 呼叫者是否知道這是一個非同步呼叫? main
函式種,最後使用select {}
使整個程式處於阻塞狀態,也就是空轉, 會不會存在浪費?- 如果線上出現事故,debug服務已經突出,你想要debug這時是否很茫然?
- 如果某一天服務突然重啟, 你卻找不到事故日誌, 是否能想到起的這個
8801
埠的服務呢?
1.1.1 不要幫別人做選擇
把是否 併發 的選擇權交給你的呼叫者,而不是自己就直接悄悄的用上了 goroutine
下面做如下改變,將兩個函式是否併發操作的選擇權留給main
函式
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func setup(){
// 初始化操作
}
func main(){
setup()
// for debug
go pprof()
// 主服務,http api
go server()
select{}
}
func server(){
mux := http.NewServerMux()
mux.HandleFunc("ping", func(w http.ResponseWriter, r * http.Request){
w.Write([]byte("pong"))
}
// 主服務
if err := http.ListerAndServer(":8080",mux); err != nil{
log.panic("http server launch error: %v", err)
return
}
}
func pprof(){
// 輔助服務 監聽其他埠,這裡是pprof服務,擁有debug
http.ListerAndServer(":8081",nil)
}
1.1.2 不要作為一個旁觀者
一般情況下,不要讓 主程式稱為一個無所事事的旁觀者, 明明可以幹活,但是最後使用一個select
在那兒空跑,而且這種看著也怪,在沒有特殊場景下儘量不要使用這種阻塞的方式
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func setup() {
// 這裡面有一些初始化的操作
}
func main() {
setup()
// for debug
go pprof()
// 主服務, http本來就是一個阻塞的服務
server()
}
func server() {
mux := http.NewServeMux()
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
// 主服務
if err := http.ListenAndServe(":8080", mux); err != nil {
log.Panicf("http server err: %+v", err)
return
}
}
func pprof() {
// 輔助服務,監聽了其他埠,這裡是 pprof 服務,用於 debug
http.ListenAndServe(":8081", nil)
}
1.1.3 不要建立不知道什麼時候退出的 goroutine
很多時候我們在建立一個 協程(goroutine)後就放任不管了,如果程式永遠執行下去,可能不會有什麼問題,但實際情況並非如此, 我們的產品需要迭代,需要修復bug,需要不停進行構建,釋出, 所以當程式退出後(主程式),執行的某些子程式並不會完全退出,比如這個 pprof, 他自身本來就是一個後臺服務,但是當 main退出後,實際 pprof這個服務並不會退出,這樣 pprof就會稱為一個孤魂野鬼,稱為一個 zombie, 導致goroutine洩漏。
所以再一次對程式進行修改, 保證 goroutine能正常退出
package main
import (
"context"
"fmt"
"log"
"net/http"
_ "net/http/pprof"
"time"
)
func setup() {
// 這裡面有一些初始化的操作
}
func main() {
setup()
// 用於監聽服務退出, 這裡使用了兩個 goroutine,所以 cap 為2
done := make(chan error, 2)
// 無緩衝的通道,用於控制服務退出,傳入同一個 stop,做到只要有一個服務退出了那麼另外一個服務也會隨之退出
stop := make(chan struct{}, 0)
// for debug
go func() {
// pprof 傳遞一個 channel
fmt.Println("pprof start...")
done <- pprof(stop)
fmt.Printf("err1:%v\n", done)
}()
// 主服務
go func() {
fmt.Println("app start...")
done <- app(stop)
fmt.Printf("err2:%v\n", done)
}()
// stopped 用於判斷當前 stop 的狀態
var stopped bool
// 這裡迴圈讀取 done 這個 channel
// 只要有一個退出了,我們就關閉 stop channel
for i := 0; i < cap(done); i++ {
// 對於有緩衝的chan, chan中無值會一直處於阻塞狀態
// 對於app 服務會一直阻塞狀態,不會有 資料寫入到done 通道,只有在5s後,模擬的 pprof會有err寫入chan,此時才會觸發以下邏輯
if err := <-done; err != nil {
log.Printf("server exit err: %+v", err)
}
if !stopped {
stopped = true
// 通過關閉 無緩衝的channel 來通知所有的 讀 stop相關的goroutine退出
close(stop)
}
}
}
// http 服務
func app(stop <-chan struct{}) error {
mux := http.NewServeMux()
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
return server(mux, ":8080", stop)
}
func pprof(stop <-chan struct{}) error {
// 注意這裡主要是為了模擬服務意外退出,用於驗證一個服務退出,其他服務同時退出的場景
// 因為這裡沒有返回err, 所以done chan中無法接收到值, 主程式中會一直阻塞住
go func() {
server(http.DefaultServeMux, ":8081", stop)
}()
time.Sleep(5 * time.Second)
// 模擬出錯
return fmt.Errorf("mock pprof exit")
}
// 啟動一個服務
func server(handler http.Handler, addr string, stop <-chan struct{}) error {
s := http.Server{
Handler: handler,
Addr: addr,
}
// 這個 goroutine 控制退出,因為 stop channel 只要close或者是寫入資料,這裡就會退出
go func() {
// 無緩衝channel等待,寫入或者關閉
<-stop
log.Printf("server will exiting, addr: %s", addr)
// 此時 httpApi 服務就會優雅的退出
s.Shutdown(context.Background())
}()
// 沒有觸發異常的話,會一直處於阻塞
return s.ListenAndServe()
}
檢視以下執行結果
D:\gopath\controlGoExit>go run demo.go
app start...
pprof start...
err1:0xc00004c720
2021/09/12 22:48:37 server exit err: mock pprof exit
2021/09/12 22:48:37 server will exiting, addr: :8080
2021/09/12 22:48:37 server will exiting, addr: :8081
err2:0xc00004c720
2021/09/12 22:48:37 server exit err: http: Server closed
雖然我們已經經過了三輪優化,但是這裡還是有一些需要注意的地方:
- 雖然我們呼叫了 Shutdown 方法,但是我們其實並沒有實現優雅退出
- 在 server 方法中我們並沒有處理 panic的邏輯,這裡需要處理麼?如果需要那該如何處理呢?
1.1.4 不要建立都無法退出的 goroutine
永遠無法退出的 goroutine, 即 goroutine 洩漏
下面是一個例子,可能在不知不覺中會用到
package main
import (
"log"
_ "net/http/pprof"
"net/http"
)
func setup() {
// 這裡面有一些初始化的操作
log.Print("服務啟動初始化...")
}
func main() {
setup()
// for debug
go pprof()
// 主服務, http本來就是一個阻塞的服務
server()
}
func server() {
mux := http.NewServeMux()
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
mux.HandleFunc("/leak", LeakHandle)
// 主服務
if err := http.ListenAndServe(":8080", mux); err != nil {
log.Panicf("http server err: %+v", err)
return
}
}
func pprof() {
// 輔助服務,監聽了其他埠,這裡是 pprof 服務,用於 debug
http.ListenAndServe(":8081", nil)
}
func LeakHandle(w http.ResponseWriter, r *http.Request) {
ch := make(chan bool, 0)
go func() {
fmt.Println("非同步任務做一些操作")
<-ch
}()
w.Write([]byte("will leak"))
}
複用一下上面的 server 程式碼,我們經常會寫出這種類似的程式碼
- http 請求來了,我們啟動一個 goroutine 去做一些耗時一點的工作
- 然後返回了
- 然後之前建立的那個 goroutine 阻塞了(對於一個無緩衝的chan,如果沒有接收或關閉操作會永遠阻塞下去)
- 然後就洩漏了
絕大部分的 goroutine 洩漏都是因為 goroutine 當中因為各種原因阻塞了,我們在外面也沒有控制它退出的方式,所以就洩漏了
接下來我們驗證一下是不是真的洩漏了
服務啟動之後,訪問debug訪問網址,http://localhost:8081/debug/pprof/goroutine?debug=1.
當請求兩次 http://127.0.0.1/leak後
檢視 goroutine數量,如圖
繼續請求三次後,如圖
1.1.5 確保建立出的goroutine工作已經完成
這個其實就是優雅退出的問題,程式中可能啟動了很多的 goroutine 去處理一些問題,但是服務退出的時候我們並沒有考慮到就直接退出了。例如退出前日誌沒有 flush 到磁碟,我們的請求還沒完全關閉,非同步 worker 中還有 job 在執行等等。
看一個例子,假設現在有一個埋點服務,每次請求我們都會上報一些資訊到埋點服務上
// Reporter 埋點服務上報
type Reporter struct {
}
var reporter Reporter
// 模擬耗時
func (r Reporter) report(data string) {
time.Sleep(time.Second)
fmt.Printf("report: %s\n", data)
}
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
// 在請求中非同步呼叫
// 這裡並沒有滿足一致性
go reporter.report("ping pong")
fmt.Println("ping")
w.Write([]byte("pong"))
})
在傳送一次請後之後就直接退出了, 非同步上報的邏輯是沒有執行的
$ go tun demo.go
ping
^C signal:interrupt
有兩種改法:
- 一種是給 reporter 加上 shutdown 方法,類似 http 的 shutdown,等待所有的非同步上報完成之後,再退出
- 另外一種是我們直接使用 一些 worker 來執行,在當然這個 worker 也要實現類似 shutdown 的方法。
一般推薦後一種,因為這樣可以避免請求量比較大時,建立大量 goroutine,當然如果請求量比較小,不會很大,用第一種也是可以的。
第二種方法程式碼如下:
// 埋點上報
package main
import (
"context"
"fmt"
"log"
"net/http"
"sync"
)
// Reporter 埋點服務上報
type Reporter struct {
worker int
messages chan string
wg sync.WaitGroup
closed chan struct{}
once sync.Once
}
// NewReporter NewReporter
func NewReporter(worker, buffer int) *Reporter {
return &Reporter{
worker: worker,
messages: make(chan string, buffer),
closed: make(chan struct{}),
}
}
// 執行上報
func (r *Reporter) Run(stop <-chan struct{}) {
// 用於執行錯誤
go func() {
// 沒有錯誤時
<-stop
fmt.Println("stop...")
r.shutdown()
}()
for i := 0; i < r.worker; i++ {
r.wg.Add(1)
go func() {
defer r.wg.Done()
for {
select {
case <-r.closed:
return
case msg := <-r.messages:
fmt.Printf("report: %s\n", msg)
}
}
}()
}
r.wg.Wait()
fmt.Println("report workers exit...")
}
// 這裡不必關閉 messages
// 因為 closed 關閉之後,傳送端會直接丟棄資料不再傳送
// Run 方法中的消費者也會退出
// Run 方法會隨之退出
func (r *Reporter) shutdown() {
r.once.Do(func() { close(r.closed) })
}
// 模擬耗時
func (r *Reporter) Report(data string) {
// 這個是為了及早退出
// 並且為了避免我們消費者能力很強,傳送者這邊一直不阻塞,可能還會一直寫資料
select {
case <-r.closed:
fmt.Printf("reporter is closed, data will be discarded: %s \n", data)
default:
}
select {
case <-r.closed:
fmt.Printf("reporter is closed, data will be discarded: %s \n", data)
case r.messages <- data:
}
}
func setup3() {
// 初始化一些操作
fmt.Println("程式啟動...")
}
func main() {
setup3()
// 用於監聽服務完成時退出
done := make(chan error, 3)
// 例項化一個 reporter
reporter := NewReporter(2, 100)
// 用於控制服務退出,傳入同一個 stop,做到只要有一個服務退出了那麼另外一個服務也會隨之退出
stop := make(chan struct{}, 0)
// for debug
go func() {
done <- pprof3(stop)
}()
// http主服務
go func() {
done <- app3(reporter, stop)
}()
// 上報服務,接收一個監控停止的 chan
go func() {
reporter.Run(stop)
done <- nil
}()
// 這裡迴圈讀取 done 這個 channel
// 只要有一個退出了,我們就關閉 stop channel
for i := 0; i < cap(done); i++ {
// 對於有緩衝的chan, chan中無值會一直處於阻塞狀態
// 對於app 服務會一直阻塞狀態,不會有 資料寫入到done 通道,只有在5s後,模擬的 pprof會有err寫入chan,此時才會觸發以下邏輯
if err := <-done; err != nil {
log.Printf("server exit err: %+v", err)
}
// 通過關閉 無緩衝的channel 來通知所有的 讀 stop相關的goroutine退出
close(stop)
}
}
func pprof3(stop <-chan struct{}) error {
// 輔助服務,監聽了其他埠,這裡是 pprof 服務,用於 debug
err := server3(http.DefaultServeMux, ":8081", stop)
return err
}
func app3(report *Reporter, stop <-chan struct{}) error {
mux := http.NewServeMux()
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
// 在請求中非同步呼叫
// 這裡並沒有滿足一致性
go report.Report("ping pong")
fmt.Println("ping")
_, err := w.Write([]byte("pong"))
if err != nil {
log.Println("response err")
}
})
return server3(mux, ":8080", stop)
}
// 啟動一個服務
func server3(handler http.Handler, addr string, stop <-chan struct{}) error {
s := http.Server{
Handler: handler,
Addr: addr,
}
// 這個 goroutine 控制退出,因為 stop channel 只要close 或者是寫入資料,這裡就會退出
go func() {
// 無緩衝channel等待,寫入或者關閉
<-stop
log.Printf("server will exiting, addr: %s", addr)
// 此時 httpApi 服務就會優雅的退出
err := s.Shutdown(context.Background())
if err != nil {
log.Printf("server exiting occur error, %s", err.Error())
}
}()
// 沒有觸發異常的化,會一直處於阻塞
return s.ListenAndServe()
}
- 上面程式碼應該還有問題,等日後再做優化
第一種方法參考:reporter 新增shutdown方法
2. 總結
在使用go語言初期, 使用一個go
關鍵字輕鬆開啟一個非同步協程,再加上chan很容易實現 生產者---》消費者
設計模型,但是在使用過程中往往忽略了 程式退出時資源回收的問題,也很容易寫成一個資料使用一個go來處理,雖然官方說明了 建立一個goroutine的佔用資源很小,但是再小的 佔用空間也敵不過一個死迴圈啊。 所以在使用gorouine建立協程除了注意正確規定執行緒數以為,也要注意以下幾點。
-
將是否非同步呼叫的選擇泉交給呼叫者, 不然很有可能使用者不知道所呼叫的函式立使用了
go
-
如果要啟動一個
goroutine
, 要對他負責- 不用啟動一個無法控制他退出或者無法知道何時退出的goroutine
- 啟動goroutine時加上 panic recovery機制,避免服務直接不可用,可以使用如下程式碼
// DeferRecover defer recover from panic. func DeferRecover(tag string, handlePanic func(error)) func() { return func() { if err := recover(); err != nil { log.Errorf("%s, recover from: %v\n%s\n", tag, err, debug.Stack()) if handlePanic != nil { handlePanic(fmt.Errorf("%v", err)) } } } } // WithRecover recover from panic. func WithRecover(tag string, f func(), handlePanic func(error)) { defer DeferRecover(tag, handlePanic)() f() } // Go is a wrapper of goroutine with recover. func Go(name string, f func(), handlePanic func(error)) { go WithRecover(fmt.Sprintf("goroutine %s", name), f, handlePanic) }
- 造成 goroutine 洩漏的主要原因就是 goroutine 中造成了阻塞,並且沒有外部手段控制它退出
-
儘量避免在請求中直接啟動 goroutine 來處理問題,而應該通過啟動 worker 來進行消費,這樣可以避免由於請求量過大,而導致大量建立 goroutine 從而導致 oom,當然如果請求量本身非常小,那當我沒說