詳解Go語言中上下文context的理解與使用
為什么需要 context
在 Go 程序中,特別是并發(fā)情況下,由于超時、取消等而引發(fā)的異常操作,往往需要及時的釋放相應(yīng)資源,正確的關(guān)閉 goroutine。防止協(xié)程不退出而導(dǎo)致內(nèi)存泄露。如果沒有 context,用來控制協(xié)程退出將會非常麻煩,我們來舉一個例子。
假如說現(xiàn)在一個協(xié)程A開啟了一個子協(xié)程B,這個子協(xié)程B又開啟了另外兩個子協(xié)程B1和B2來運行不同的任務(wù),協(xié)程B2又開啟了協(xié)程C來運行其他任務(wù),現(xiàn)在協(xié)程A通知子協(xié)程B該退出了,這個時候我們需要完成這樣的操作:A通知B退出,B退出時通知B1、B2退出,B2退出時通知C退出:
func TestChanCloseGoroutine(t *testing.T) { fmt.Printf("開始了,有%d個協(xié)程\n", runtime.NumGoroutine()) var ( chB = make(chan struct{}) chB1 = make(chan struct{}) chB2 = make(chan struct{}) chC = make(chan struct{}) ) // 協(xié)程A go func() { // 協(xié)程B go func() { // 協(xié)程B1 go func() { for { select { case <-chB1: return default: } } }() // 協(xié)程B2 go func() { // 協(xié)程C go func() { for { select { case <-chC: return default: } } }() for { select { case <-chB2: // 通知協(xié)程C退出 chC <- struct{}{} return default: } } }() for { select { case <-chB: chB1 <- struct{}{} chB2 <- struct{}{} return default: } } }() // 1秒后通知B退出 time.Sleep(1 * time.Second) chB <- struct{}{} // A后續(xù)沒有任務(wù)了,會自動退出 }() time.Sleep(2 * time.Second) fmt.Printf("最終結(jié)束,有%d個協(xié)程\n", runtime.NumGoroutine()) } // 結(jié)果 開始了,有2個協(xié)程 最終結(jié)束,有2個協(xié)程 // tips: Go Test 會啟動兩個額外的 goroutine 來運行代碼,所以初始就會有2個 goroutine
通過 channel 來控制各個 goroutine 的關(guān)閉,程序看上去一點也不優(yōu)雅。而且這才僅僅四個 goroutine ,就已經(jīng)顯得有些力不從心了,在真實的業(yè)務(wù)中,哪怕一個簡單的 http 請求,都不可能啟用四個 goroutine 就能夠完成,且子協(xié)程的層級也絕非只有寥寥的三層!
context 是什么
context 在 Go 中是一個接口,它的定義如下:
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key any) any }
- Deadline 用來獲取 ctx 的截止時間,如果沒有截至?xí)r間,ok 將返回 false;
- Done 里面是一個通道,當(dāng) ctx 被取消時,會返回一個關(guān)閉的 channel,如果該 ctx 永遠都不會被關(guān)閉,則返回 nil;
- Err 返回的 ctx 取消的原因,如果 ctx 沒有被取消,會返回 nil。如果已經(jīng)關(guān)閉了,會返回被關(guān)閉的原因,如果是被取消的會返回 canceled,超時的顯示 deadline exceeded;
- Value 會返回 ctx 中儲存的值,會從當(dāng)前 ctx 中一路向上追溯,如果整條 ctx 鏈中都沒有找到值,則會返回nil。
context 的基本結(jié)構(gòu)比較簡單,里面也只有四個方法,如果到此沒有理解四個方法也沒有關(guān)系,下文會使用到這四個方法,屆時將會很自然的掌握它們。
context 接口的實現(xiàn)
context 有四個不同的實現(xiàn):emptyCtx、cancelCtx、timerCtx、valueCtx:
type emptyCtx int func (*emptyCtx) Deadline() (deadline time.Time, ok bool) { return } func (*emptyCtx) Done() <-chan struct{} { return nil } func (*emptyCtx) Err() error { return nil } func (*emptyCtx) Value(key any) any { return nil }
emptyCtx 是一個實現(xiàn)了 context 接口的整型,它不能儲存信息,也不能被取消,它被當(dāng)作根節(jié)點 ctx。cancelCtx、timerCtx、valueCtx 由于篇幅原因,這里不放出它們的源碼,只解釋它們的作用:cancelCtx 是一個可以主動取消的 ctx。timerCtx 也是一個可以主動取消的 ctx,不同于 cancelCtx,它還儲存著額外的時間信息,當(dāng)時間條件滿足后,會自動取消該 ctx,利用這點,可以實現(xiàn)超時機制。valueCtx 比較簡單,用來創(chuàng)建一個攜帶鍵值的 ctx。
context 的基本使用
創(chuàng)建一個根節(jié)點
創(chuàng)建根節(jié)點有兩種方法:
ctx := context.Background() ctx := context.TODO()
這兩種方法其實本質(zhì)上都是初始化了一個 emptyCtx:
var ( background = new(emptyCtx) todo = new(emptyCtx) ) func Background() Context { return background } func TODO() Context { return todo }
可以看到,在代碼中,這兩個函數(shù)其實是一模一樣的,只是用于不同場景下:Background 推薦在主函數(shù)、初始化和測試中使用,TODO 用于不清楚使用哪個 context 時使用。根節(jié)點 ctx 不具備任何意義,也不能被取消。
創(chuàng)建一個子 ctx
可以通過WithCancel、WithDeadline、WithTimeout、WithValue 這四個主要的函數(shù)來創(chuàng)建子 ctx ,創(chuàng)建一個子 ctx 必須指定其歸屬的父 ctx,由此來形成一個上下文鏈,用來同步 goroutine 信號。來看一下它們的簡單使用:
WithCancel
用來創(chuàng)建一個 cancelCtx,它可以被主動取消 :
func TestCtxWithCancel(t *testing.T) { ctx := context.Background() ctx, cancel := context.WithCancel(ctx) go func() { for { select { // 還記得前文提到的Done的方法嗎 // 當(dāng) ctx 取消時,ctx.Done()對應(yīng)的通道就會關(guān)閉,case也就會被執(zhí)行 case <-ctx.Done(): // ctx.Err() 會獲取到關(guān)閉原因哦 fmt.Println("協(xié)程關(guān)閉", ctx.Err()) return default: fmt.Println("繼續(xù)運行") time.Sleep(100 * time.Millisecond) } } }() // 等待一秒后關(guān)閉 time.Sleep(1 * time.Second) cancel() // 等待一秒,讓子協(xié)程有時間打印出協(xié)程關(guān)閉的原因 time.Sleep(1 * time.Second) } // 結(jié)果 繼續(xù)運行 繼續(xù)運行 …… 協(xié)程關(guān)閉 context canceled
WithDeadline
用來創(chuàng)建一個 timerCtx,當(dāng)時間條件滿足后,它會被自動取消 :
func TestCtxWithDeadline(t *testing.T) { ctx := context.Background() // 等待2秒后自動關(guān)閉 ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second)) defer cancel() // Deadline 前文也提到了,還記得嗎?用來獲取當(dāng)前任務(wù)的截至?xí)r間 if t, ok := ctx.Deadline(); ok { // time.DateTime 是 go1.20 版本的一個常量,其值是:"2006-01-02 15:04:05" fmt.Println(t.Format(time.DateTime)) } go func() { select { case <-ctx.Done(): // 手動關(guān)閉 context canceled // 自動關(guān)閉 context deadline exceeded fmt.Println("協(xié)程關(guān)閉", ctx.Err()) return } }() time.Sleep(3 * time.Second) } // 結(jié)果 2023-05-10 18:00:36 協(xié)程關(guān)閉 context deadline exceeded // 將最后的等待時間更改為一秒 func TestCtxWithDeadline(t *testing.T) { …… time.Sleep(1 * time.Second) } // 結(jié)果 2023-05-10 18:01:45 協(xié)程關(guān)閉 context canceled
哪怕 WithDeadline 到達指定時間會自動關(guān)閉,但依然推薦使用 defer cancel() 。這是因為如果任務(wù)已經(jīng)完成了,但是自動取消仍需要1天時間,那么系統(tǒng)就會白白浪費資源在這1天上。
WithTimeout
與 WithDeadline
同理,只不過是 WithTimeout 用來接受一個過期時間,而不是接受一個過期時間節(jié)點:
func TestCtxWithTimeout(t *testing.T) { ctx := context.Background() ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() go func() { select { case <-ctx.Done(): fmt.Println("協(xié)程關(guān)閉", ctx.Err()) return } }() time.Sleep(3 * time.Second) } // 結(jié)果 協(xié)程關(guān)閉 context deadline exceeded
WithValue
用來創(chuàng)建一個 valueCtx:
// 向上找到最近的上下文值 func TestCtxWithValue(t *testing.T) { ctx := context.Background() ctx1 := context.WithValue(ctx, "key", "ok") ctx2, _ := context.WithCancel(ctx1) // Value 會一直向上追溯到根節(jié)點,獲取當(dāng)前上下文攜帶的值, value := ctx2.Value("key") if value != nil { fmt.Println(value) } } // 結(jié)果 ok
這四個函數(shù)都是創(chuàng)建一個新的子節(jié)點,并不是直接修改當(dāng)前 ctx,所以最后生成的 ctx 鏈有可能是這樣的:
使用 ctx 退出 goroutine
回到開頭提到的那個例子,我們使用 context 對其改造一下:
func TestCtxCloseGoroutine(t *testing.T) { fmt.Printf("開始了,有%d個協(xié)程\n", runtime.NumGoroutine()) ctx := context.Background() // 協(xié)程A go func(ctx context.Context) { ctx, cancel := context.WithCancel(ctx) // 協(xié)程B go func(ctx context.Context) { // 協(xié)程B1 go func(ctx context.Context) { for { select { case <-ctx.Done(): return default: } } }(ctx) // 協(xié)程B2 go func(ctx context.Context) { // 協(xié)程C go func(ctx context.Context) { for { select { case <-ctx.Done(): return default: } } }(ctx) for { select { case <-ctx.Done(): return default: } } }(ctx) for { select { case <-ctx.Done(): return default: } } }(ctx) // 1秒后通知退出 time.Sleep(1 * time.Second) cancel() // A后續(xù)沒有任務(wù)了,會自動退出 }(ctx) time.Sleep(2 * time.Second) fmt.Printf("最終結(jié)束,有%d個協(xié)程\n", runtime.NumGoroutine()) } // 結(jié)果 開始了,有2個協(xié)程 最終結(jié)束,有2個協(xié)程
可以看到,和使用 channel 控制 goroutine 退出相比,context 大大降低了心智負擔(dān)。context 優(yōu)雅的實現(xiàn)了某一層任務(wù)退出,下層所有任務(wù)退出,上層任務(wù)和同層任務(wù)不受影響。
Go 語言最佳實踐:每次 context 的傳遞都應(yīng)該直接使用值傳遞,不應(yīng)該使用指針傳遞。這樣可以防止上下文的值被多個并發(fā)的 goroutine 修改而導(dǎo)致競爭問題。雖然使用值傳遞會導(dǎo)致一些微小的性能開銷,因為每次傳遞上下文時都需要復(fù)制一份數(shù)據(jù),但它提供了更好的并發(fā)安全性和程序可靠性。另外,由于上下文采用了值傳遞,也不應(yīng)該向上下文中存入較大的數(shù)據(jù),從而導(dǎo)致性能問題。
以上就是詳解Go語言中上下文context的理解與使用的詳細內(nèi)容,更多關(guān)于go上下文context的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
golang利用redis和gin實現(xiàn)保存登錄狀態(tài)校驗登錄功能
這篇文章主要介紹了golang利用redis和gin實現(xiàn)保存登錄狀態(tài)校驗登錄功能,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-01-01Go語言數(shù)據(jù)結(jié)構(gòu)之二叉樹可視化詳解
這篇文章主要為大家詳細介紹了Go語言數(shù)據(jù)結(jié)構(gòu)中二叉樹可視化的方法詳解,文中的示例代碼講解詳細,感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2022-09-09Golang實現(xiàn)帶優(yōu)先級的select
這篇文章主要為大家詳細介紹了如何在Golang中實現(xiàn)帶優(yōu)先級的select,文中的示例代碼講解詳細,對我們學(xué)習(xí)Golang有一定的幫助,需要的可以參考一下2023-04-04