GoLang併發控制(下)
context的字面意思是上下文,是一個比較抽象的詞,字面上理解就是上下層的傳遞,上會把內容傳遞給下,在go中程式單位一般為goroutine,這裡的上下文便是在goroutine之間進行傳遞。
根據現例項子來講,最常看到context的便是web端。一個網路請求request請求服務端,每一個request都會開啟一個goroutine,這個goroutine在邏輯處理中可能會去開啟其他的goroutine,例如去開啟一個MongoDB的連線,一個request的goroutine開啟了很多個goroutine時候,需要對這些goroutine進行控制,這時候就需要context來進行對這些goroutine進行跟蹤。即一個請求Request,會需要多個Goroutine中處理。而這些Goroutine可能需要共享Request的一些資訊;同時當Request被取消或者超時的時候,所有從這個Request建立的所有Goroutine也應該被結束。
例子講述完畢,用go的風格再講一次。
在每一個goroutine在執行之前,都要知道程式當前的執行狀態,這些狀態都被封裝在context變數中,要傳遞給要執行的goroutine中去,這個上下文就成為了傳遞與請求同生存週期變數的標準方法。
注意 context是在go 1.7版本之後引入的,以前版本的注意(go更新特別快,每一個版本都變得越來越好,自己第一次接觸go語言的時候才1.9版本,實習公司用的好像是1.7,研發團隊解體後現在實習用的版本是1.11 短時間版本就如此之大,1.10版本G-M模型改為G-P-M模型,聽聞1.12社群會再次優化GC垃圾回收,引入分代)
Context介面
Context的介面定義的比較簡潔,我們看下這個介面的方法。
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key interface{}) interface{} }
這個介面共有4個方法,瞭解這些方法的意思非常重要,這樣我們才可以更好的使用他們。
Deadline
方法是獲取設定的截止時間的意思,第一個返回式是截止時間,到了這個時間點,Context會自動發起取消請求;第二個返回值ok==false時表示沒有設定截止時間,如果需要取消的話,需要呼叫取消函式進行取消。
Done
方法返回一個只讀的chan,型別為 struct{}
,我們在goroutine中,如果該方法返回的chan可以讀取,則意味著parent context已經發起了取消請求,我們通過 Done
方法收到這個訊號後,就應該做清理操作,然後退出goroutine,釋放資源。
Err
方法返回取消的錯誤原因,因為什麼Context被取消。
Value
方法獲取該Context上繫結的值,是一個鍵值對,所以要通過一個Key才可以獲取對應的值,這個值一般是執行緒安全的。
有了如上的根Context,那麼是如何衍生更多的子Context的呢?這就要靠context包為我們提供的 With
系列的函數了。
Context的繼承衍生
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) func WithValue(parent Context, key, val interface{}) Context
這四個 With
函式,接收的都有一個partent引數,就是父Context,我們要基於這個父Context創建出子Context的意思,這種方式可以理解為子Context對父Context的繼承,也可以理解為基於父Context的衍生。
通過這些函式,就建立了一顆Context樹,樹的每個節點都可以有任意多個子節點,節點層級可以有任意多個。
WithCancel
函式,傳遞一個父Context作為引數,返回子Context,以及一個取消函式用來取消Context。 WithDeadline
函式,和 WithCancel
差不多,它會多傳遞一個截止時間引數,意味著到了這個時間點,會自動取消Context,當然我們也可以不等到這個時候,可以提前通過取消函式進行取消。
WithTimeout
和 WithDeadline
基本上一樣,這個表示是超時自動取消,是多少時間後自動取消Context的意思。
WithValue
函式和取消Context無關,它是為了生成一個綁定了一個鍵值對資料的Context,這個繫結的資料可以通過 Context.Value
方法訪問到
引用飛雪無情的程式碼:
func main() { ctx, cancel := context.WithCancel(context.Background()) go func(ctx context.Context) { for { select { case <-ctx.Done(): fmt.Println("監控退出,停止了...") return default: fmt.Println("goroutine監控中...") time.Sleep(2 * time.Second) } } }(ctx) time.Sleep(10 * time.Second) fmt.Println("可以了,通知監控停止") cancel() //為了檢測監控過是否停止,如果沒有監控輸出,就表示停止了 time.Sleep(5 * time.Second) }
context.Background()
返回一個空的Context,這個空的Context一般用於整個Context樹的根節點。然後我們使用 context.WithCancel(parent)
函式,建立一個可取消的子Context,然後當作引數傳給goroutine使用,這樣就可以使用這個子Context跟蹤這個goroutine。
在goroutine中,使用select呼叫 <-ctx.Done()
判斷是否要結束,如果接受到值的話,就可以返回結束goroutine了;如果接收不到,就會繼續進行監控。
那麼是如何傳送結束指令的呢?這就是示例中的 cancel
函式啦,它是我們呼叫 context.WithCancel(parent)
函式生成子Context的時候返回的,第二個返回值就是這個取消函式,它是 CancelFunc
型別的。我們呼叫它就可以發出取消指令,然後我們的監控goroutine就會收到訊號,就會返回結束。
在引用一段多控制
func main() { ctx, cancel := context.WithCancel(context.Background()) go watch(ctx,"【監控1】") go watch(ctx,"【監控2】") go watch(ctx,"【監控3】") time.Sleep(10 * time.Second) fmt.Println("可以了,通知監控停止") cancel() //為了檢測監控過是否停止,如果沒有監控輸出,就表示停止了 time.Sleep(5 * time.Second) } func watch(ctx context.Context, name string) { for { select { case <-ctx.Done(): fmt.Println(name,"監控退出,停止了...") return default: fmt.Println(name,"goroutine監控中...") time.Sleep(2 * time.Second) } } }
示例中啟動了3個監控goroutine進行不斷的監控,每一個都使用了Context進行跟蹤,當我們使用 cancel
函式通知取消時,這3個goroutine都會被結束。這就是Context的控制能力,它就像一個控制器一樣,按下開關後,所有基於這個Context或者衍生的子Context都會收到通知,這時就可以進行清理操作了,最終釋放goroutine,這就優雅的解決了goroutine啟動後不可控的問題。
在引用一次潘少大佬的程式碼:
package main import ( "context" "crypto/md5" "fmt" "io/ioutil" "net/http" "sync" "time" ) type favContextKey string func main() { wg := &sync.WaitGroup{} values := []string{"https://www.baidu.com/", "https://www.zhihu.com/"} ctx, cancel := context.WithCancel(context.Background()) for _, url := range values { wg.Add(1) subCtx := context.WithValue(ctx, favContextKey("url"), url) go reqURL(subCtx, wg) } go func() { time.Sleep(time.Second * 3) cancel() }() wg.Wait() fmt.Println("exit main goroutine") } func reqURL(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() url, _ := ctx.Value(favContextKey("url")).(string) for { select { case <-ctx.Done(): fmt.Printf("stop getting url:%s\n", url) return default: r, err := http.Get(url) if r.StatusCode == http.StatusOK && err == nil { body, _ := ioutil.ReadAll(r.Body) subCtx := context.WithValue(ctx, favContextKey("resp"), fmt.Sprintf("%s%x", url, md5.Sum(body))) wg.Add(1) go showResp(subCtx, wg) } r.Body.Close() //啟動子goroutine是為了不阻塞當前goroutine,這裡在實際場景中可以去執行其他邏輯,這裡為了方便直接sleep一秒 // doSometing() time.Sleep(time.Second * 1) } } } func showResp(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() for { select { case <-ctx.Done(): fmt.Println("stop showing resp") return default: //子goroutine裡一般會處理一些IO任務,如讀寫資料庫或者rpc呼叫,這裡為了方便直接把資料列印 fmt.Println("printing ", ctx.Value(favContextKey("resp"))) time.Sleep(time.Second * 1) } } }
首先呼叫context.Background()生成根節點,然後呼叫withCancel方法,傳入根節點,得到新的子Context以及根節點的cancel方法(通知所有子節點結束執行),這裡要注意:該方法也返回了一個Context,這是一個新的子節點,與初始傳入的根節點不是同一個例項了,但是每一個子節點裡會儲存從最初的根節點到本節點的鏈路資訊 ,才能實現鏈式。
程式的reqURL方法接收一個url,然後通過http請求該url獲得response,然後在當前goroutine裡再啟動一個子groutine把response打印出來,然後從ReqURL開始Context樹往下衍生葉子節點(每一個鏈式呼叫新產生的ctx),中間每個ctx都可以通過WithValue方式傳值(實現通訊),而每一個子goroutine都能通過Value方法從父goroutine取值,實現協程間的通訊,每個子ctx可以呼叫Done方法檢測是否有父節點呼叫cancel方法通知子節點退出執行,根節點的cancel呼叫會沿著鏈路通知到每一個子節點,因此實現了強併發控制,流程如圖:

044svco84sif9rjebqagmar0fp.png
context使用規範
最後,Context雖然是神器,但開發者使用也要遵循基本法,以下是一些Context使用的規範:
- Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx;不要把Context存在一個結構體當中,顯式地傳入函式。Context變數需要作為第一個引數使用,一般命名為ctx;
- Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use;即使方法允許,也不要傳入一個nil的Context,如果你不確定你要用什麼Context的時候傳一個context.TODO;
- Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions;使用context的Value相關方法只應該用於在程式和介面中傳遞的和請求相關的元資料,不要用它來傳遞一些可選的引數;
- The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines;同樣的Context可以用來傳遞到不同的goroutine中,Context在多個goroutine中是安全的;