一、協程如何退出
一個協程啟動後,大部分情況需要等待裡面的代碼執行完畢,然後協程會自行退出。但是如果有一種情景,需要讓協程提前退出怎麼辦呢?
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
watchDog("【監控狗1】")
}()
wg.Wait()
}
func watchDog(name string) {
// 開啟for select循環,一直背景監控
for {
select {
default:
fmt.Println(name, "正在監控... ...")
}
time.Sleep(1 * time.Second)
}
}
通過 watchDog 函數實作了一個監控狗,它會一直在背景運作,每隔一秒就會列印"監控狗正在監控……"的文字。
如果需要讓監控狗停止監控、退出程式,一個辦法是定義一個全局變量,其他地方可以通過修改這個變量發出停止監控狗的通知。然後在協程中先檢查這個變量,如果發現被通知關閉就停止監控,退出目前協程。
但是這種方法需要通過加鎖來保證多協程下并發的安全,基于這個思路,有個更新版的方案:用 select+channel 做檢測
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
// 用來停止監控狗
stopCh := make(chan bool)
go func() {
defer wg.Done()
watchDog(stopCh, "【監控狗1】")
}()
// 想讓監控狗監控5秒
time.Sleep(5 * time.Second)
// 發出停止指令
stopCh <- true
wg.Wait()
}
func watchDog(stopCh chan bool,name string) {
// 開啟for select循環,一直背景監控
for {
select {
case <- stopCh:
fmt.Println(name, "停止指令已收到,馬上停止")
return
default:
fmt.Println(name, "正在監控... ...")
}
time.Sleep(1 * time.Second)
}
}
這個示例是使用 select+channel 的方式改造的 watchDog 函數,實作了通過 channel 發送指令讓監控狗停止,進而達到協程退出的目的。
1. 為 watchDog 函數增加 stopCh 參數,用于接收停止指令
2. 在 main 函數中,聲明用于停止的 stopCh,傳遞給 watchDog 函數,然後通過 stopCh<-true 發送停止指令讓協程退出
二、Context
通過 select+channel 讓協程退出的方式比較優雅,但是如果我們希望做到同時取消很多個協程呢?如果是定時取消協程又該怎麼辦?這時候 select+channel 的局限性就凸現出來了,即使定義了多個 channel 解決問題,代碼邏輯也會非常複雜、難以維護。
要解決這種複雜的協程問題,必須有一種可以跟蹤協程的方案,隻有跟蹤到每個協程,才能更好地控制它們,這種方案就是 Go 語言标準庫為我們提供的 Context。
package main
import (
"context"
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
ctx, stop := context.WithCancel(context.Background())
go func() {
defer wg.Done()
watchDog(ctx, "【監控狗1】")
}()
// 先讓監控狗監控5秒
time.Sleep(5 * time.Second)
// 發停止指令
stop()
wg.Wait()
}
func watchDog(ctx context.Context, name string) {
// 開啟for select循環,一直背景監控
for {
select {
case <-ctx.Done():
fmt.Println(name, "停止指令已收到,馬上停止")
return
default:
fmt.Println(name, "正在監控... ...")
}
time.Sleep(1 * time.Second)
}
}
相比于 select + channel的方案,Context方案有4點改動:
- watchDog 的 stopCh 參數換成了 ctx,類型為 context.Context
- 原來的 case <-stopCh 改為 case <-ctx.Done(),用于判斷是否停止
- 使用 context.WithCancel(context.Background()) 函數生成一個可以取消的 Context,用于發送停止指令。這裡的 context.Background() 用于生成一個空 Context,一般作為整個 Context 樹的根節點
- 原來的 stopCh <- true 停止指令,改為 context.WithCancel 函數傳回的取消函數 stop()。
可以看到,這和修改前的整體代碼結構一樣,隻不過從 channel 換成了 Context。
什麼是Context
一個任務會有很多個協程協作完成,一次 HTTP 請求也會觸發很多個協程的啟動,而這些協程有可能會啟動更多的子協程,并且無法預知有多少層協程、每一層有多少個協程。
如果因為某些原因導緻任務終止了,HTTP 請求取消了,那麼它們啟動的協程怎麼辦?該如何取消呢?因為取消這些協程可以節約記憶體,提升性能,同時避免不可預料的 Bug。
Context 就是用來簡化解決這些問題的,并且是并發安全的。Context 是一個接口,它具備手動、定時、逾時發出取消信号、傳值等功能,主要用于控制多個協程之間的協作,尤其是取消操作。一旦取消指令下達,那麼被 Context 跟蹤的這些協程都會收到取消信号,就可以做清理和退出操作。
Context 接口隻有四個方法:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
- Deadline 方法可以擷取設定的截止時間,第一個傳回值 deadline 是截止時間,到了這個時間點,Context 會自動發起取消請求,第二個傳回值 ok 代表是否設定了截止時間。
- Done 方法傳回一個隻讀的 channel,類型為 struct{}。在協程中,如果該方法傳回的 chan 可以讀取,則意味着 Context 已經發起了取消信号。通過 Done 方法收到這個信号後,就可以做清理操作,然後退出協程,釋放資源。
- Err 方法傳回取消的錯誤原因,即因為什麼原因 Context 被取消。
- Value 方法擷取該 Context 上綁定的值,是一個鍵值對,是以要通過一個 key 才可以擷取對應的值。
Context 接口的四個方法中最常用的就是 Done 方法,它傳回一個隻讀的 channel,用于接收取消信号。當 Context 取消的時候,會關閉這個隻讀 channel,也就等于發出了取消信号。
三、Context樹
我們不需要自己實作 Context 接口,Go 語言提供了函數可以幫助我們生成不同的 Context,通過這些函數可以生成一顆 Context 樹,這樣 Context 才可以關聯起來,父 Context 發出取消信号的時候,子 Context 也會發出,這樣就可以控制不同層級的協程退出。
從使用功能上分,有四種實作好的 Context
- 空Context:不可取消,沒有截止時間,主要用于 Context 樹的根節點
- 可取消的Context:用于發出取消信号,當取消的時候,它的子 Context 也會取消。
- 可定時取消的Context:多了一個定時的功能
- 值Context:用于存儲一個 key-value 鍵值對
從下圖 Context 的衍生樹可以看到,最頂部的是空 Context,它作為整棵 Context 樹的根節點,在 Go 語言中,可以通過 context.Background() 擷取一個根節點 Context。
有了根節點 Context 後,這顆 Context 樹要怎麼生成呢?需要使用 Go 語言提供的四個函數。
- WithCancel(parent Context):生成一個可取消的Context。
- WithDeadline(parent Context, d time.Time):生成一個可定時取消的 Context,參數 d 為定時取消的具體時間。
- WithTimeout(parent Context, timeout time.Duration):生成一個可逾時取消的 Context,參數 timeout 用于設定多久後取消。
- WithValue(parent Context, key, val interface{}):生成一個可攜帶 key-value 鍵值對的 Context。
以上四個生成 Context 的函數中,前三個都屬于可取消的 Context,它們是一類函數,最後一個是值 Context,用于存儲一個 key-value 鍵值對。
四、使用Context取消多個協程
package main
import (
"context"
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Add(3)
ctx, stop := context.WithCancel(context.Background())
go func() {
defer wg.Done()
watchDog(ctx, "【監控狗1】")
}()
go func() {
defer wg.Done()
watchDog(ctx, "【監控狗2】")
}()
go func() {
defer wg.Done()
watchDog(ctx, "【監控狗3】")
}()
time.Sleep(5 * time.Second)
stop()
wg.Wait()
}
func watchDog(ctx context.Context, name string) {
// 開啟for select循環,一直背景監控
for {
select {
case <-ctx.Done():
fmt.Println(name, "停止指令已收到,馬上停止")
return
default:
fmt.Println(name, "正在監控... ...")
}
time.Sleep(1 * time.Second)
}
}
示例中增加了兩個監控狗,也就是增加了兩個協程,這樣一個 Context 就同時控制了三個協程,一旦 Context 發出取消信号,這三個協程都會取消退出。
以上示例中的 Context 沒有子 Context,如果一個 Context 有子 Context,在該 Context 取消時會發生什麼呢?下面通過一幅圖說明:
可以看到,當節點 Ctx2 取消時,它的子節點 Ctx4、Ctx5 都會被取消,如果還有子節點的子節點,也會被取消。也就是說根節點為 Ctx2 的所有節點都會被取消,其他節點如 Ctx1、Ctx3 和 Ctx6 則不會。
五、Context 傳值
Context 不僅可以取消,還可以傳值,通過這個能力,可以把 Context 存儲的值供其他協程使用。
package main
import (
"context"
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Add(4)
ctx, stop := context.WithCancel(context.Background())
valCtx := context.WithValue(ctx, "userId", 2)
go func() {
defer wg.Done()
getUser(valCtx)
}()
go func() {
defer wg.Done()
watchDog(ctx, "【監控狗1】")
}()
go func() {
defer wg.Done()
watchDog(ctx, "【監控狗2】")
}()
go func() {
defer wg.Done()
watchDog(ctx, "【監控狗3】")
}()
time.Sleep(5 * time.Second)
stop()
wg.Wait()
}
func getUser(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("【擷取使用者】", "協程退出")
return
default:
userId := ctx.Value("userId")
fmt.Println("【擷取使用者】", "使用者ID為", userId)
time.Sleep(1 * time.Second)
}
}
}
func watchDog(ctx context.Context, name string) {
// 開啟for select循環,一直背景監控
for {
select {
case <-ctx.Done():
fmt.Println(name, "停止指令已收到,馬上停止")
return
default:
fmt.Println(name, "正在監控... ...")
}
time.Sleep(1 * time.Second)
}
}
通過 context.WithValue 函數存儲一個 userId 為 2 的鍵值對,就可以在 getUser 函數中通過 ctx.Value("userId") 方法把對應的值取出來,達到傳值的目的。
六、Context 使用原則
Context 是一種非常好的工具,使用它可以很友善地控制取消多個協程。在 Go 語言标準庫中也使用了它們,比如 net/http 中使用 Context 取消網絡的請求。
要更好地使用 Context,有一些使用原則需要盡可能地遵守。
- Context 不要放在結構體中,要以參數的方式傳遞。
- Context 作為函數的參數時,要放在第一位,也就是第一個參數。
- 要使用 context.Background 函數生成根節點的 Context,也就是最頂層的 Context。
- Context 傳值要傳遞必須的值,而且要盡可能地少,不要什麼都傳。
- Context 多協程安全,可以在多個協程中放心使用。
- 《22 講通關 Go 語言》