詳解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來運(yùn)行不同的任務(wù),協(xié)程B2又開啟了協(xié)程C來運(yùn)行其他任務(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 來運(yùn)行代碼,所以初始就會有2個 goroutine通過 channel 來控制各個 goroutine 的關(guān)閉,程序看上去一點(diǎ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 永遠(yuǎn)都不會被關(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é)點(diǎn) ctx。cancelCtx、timerCtx、valueCtx 由于篇幅原因,這里不放出它們的源碼,只解釋它們的作用:cancelCtx 是一個可以主動取消的 ctx。timerCtx 也是一個可以主動取消的 ctx,不同于 cancelCtx,它還儲存著額外的時間信息,當(dāng)時間條件滿足后,會自動取消該 ctx,利用這點(diǎn),可以實現(xiàn)超時機(jī)制。valueCtx 比較簡單,用來創(chuàng)建一個攜帶鍵值的 ctx。
context 的基本使用
創(chuàng)建一個根節(jié)點(diǎn)
創(chuàng)建根節(jié)點(diǎn)有兩種方法:
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é)點(diǎn) 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ù)運(yùn)行")
time.Sleep(100 * time.Millisecond)
}
}
}()
// 等待一秒后關(guān)閉
time.Sleep(1 * time.Second)
cancel()
// 等待一秒,讓子協(xié)程有時間打印出協(xié)程關(guān)閉的原因
time.Sleep(1 * time.Second)
}
// 結(jié)果
繼續(xù)運(yùn)行
繼續(xù)運(yùn)行
……
協(xié)程關(guān)閉 context canceledWithDeadline 用來創(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 到達(dá)指定時間會自動關(guān)閉,但依然推薦使用 defer cancel() 。這是因為如果任務(wù)已經(jīng)完成了,但是自動取消仍需要1天時間,那么系統(tǒng)就會白白浪費(fèi)資源在這1天上。
WithTimeout 與 WithDeadline 同理,只不過是 WithTimeout 用來接受一個過期時間,而不是接受一個過期時間節(jié)點(diǎn):
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 exceededWithValue 用來創(chuàng)建一個 valueCtx:
// 向上找到最近的上下文值
func TestCtxWithValue(t *testing.T) {
ctx := context.Background()
ctx1 := context.WithValue(ctx, "key", "ok")
ctx2, _ := context.WithCancel(ctx1)
// Value 會一直向上追溯到根節(jié)點(diǎn),獲取當(dāng)前上下文攜帶的值,
value := ctx2.Value("key")
if value != nil {
fmt.Println(value)
}
}
// 結(jié)果
ok這四個函數(shù)都是創(chuàng)建一個新的子節(jié)點(diǎn),并不是直接修改當(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 大大降低了心智負(fù)擔(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的理解與使用的詳細(xì)內(nèi)容,更多關(guān)于go上下文context的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
golang中的string與其他格式數(shù)據(jù)的轉(zhuǎn)換方法詳解
這篇文章主要介紹了golang中的string與其他格式數(shù)據(jù)的轉(zhuǎn)換方法,文章通過代碼示例介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下2023-10-10
Golang在整潔架構(gòu)基礎(chǔ)上實現(xiàn)事務(wù)操作
這篇文章在 go-kratos 官方的 layout 項目的整潔架構(gòu)基礎(chǔ)上,實現(xiàn)優(yōu)雅的數(shù)據(jù)庫事務(wù)操作,需要的朋友可以參考下2024-08-08
sublime3+Golang+代碼補(bǔ)全的實現(xiàn)
本文主要介紹了sublime3+Golang+代碼補(bǔ)全的實現(xiàn),文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2022-01-01
golang?基于?mysql?簡單實現(xiàn)分布式讀寫鎖
這篇文章主要介紹了golang?基于mysql簡單實現(xiàn)分布式讀寫鎖,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價值,需要的小伙伴可以參考一下2022-09-09
Go語言之使用pprof工具查找goroutine(協(xié)程)泄漏
這篇文章主要介紹了Go語言之使用pprof工具查找goroutine(協(xié)程)泄漏,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-01-01

