深入理解Golang中Context的使用
理解 cancelCtx 的源碼,其實就是理解 Go 語言如何優(yōu)雅地處理并發(fā)控制和信號廣播。
Go 的 context 源碼在 src/context/context.go 中,代碼量不多(不到 600 行),但設(shè)計非常精妙。核心邏輯可以歸納為三個關(guān)鍵詞:掛載(Mount)、廣播(Broadcast)、遞歸(Recursion)。
我們分三步來拆解 cancelCtx 的底層原理。
1 第一步:數(shù)據(jù)結(jié)構(gòu) —— 它是怎么長的?
cancelCtx 是一個結(jié)構(gòu)體,它繼承了父 Context,同時自己維護了一套“家譜”關(guān)系。
// 源碼簡化版
type cancelCtx struct {
Context // 1. 嵌入父 Context,保證能調(diào)用父類方法
mu sync.Mutex // 2. 互斥鎖,保證并發(fā)安全(保護下面的字段)
done chan struct{} // 3. 核心!用來發(fā)信號的 Channel
children map[canceler]struct{} // 4. 存自己的“孩子”,以便取消時通知它們
err error // 5. 記錄取消原因(Canceled 還是 DeadlineExceeded)
}
done:這是一個chan struct{}。這利用了 Go 的一個特性:當(dāng)一個 channel 被 close(關(guān)閉)時,所有監(jiān)聽這個 channel 的 goroutine 都會收到信號(讀到零值)。 這就是它的“廣播”機制。children:這是一個 Set(用 Map 實現(xiàn))。當(dāng)父節(jié)點取消時,它需要遍歷這個 Map,把所有的子節(jié)點也干掉。
2 第二步:掛載機制 —— 如何與父節(jié)點建立聯(lián)系?
當(dāng)你調(diào)用 context.WithCancel(parent) 時,核心邏輯在于如何把自己“掛”到父節(jié)點的 children 列表里。
這主要依賴于內(nèi)部函數(shù) propagateCancel(傳播取消)。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent) // 創(chuàng)建自己
propagateCancel(parent, &c) // 【關(guān)鍵】把自己掛到父節(jié)點上
return &c, func() { c.cancel(true, Canceled) }
}
propagateCancel 的邏輯如下:
- 判斷父節(jié)點狀態(tài):如果父節(jié)點
parent.Done()已經(jīng)是 nil(永遠(yuǎn)不會取消,比如Background()),那就不用掛了,因為父親永遠(yuǎn)不死。如果父親已經(jīng)死了,直接提前cancel并返回;
done := parent.Done()
if done == nil {
return // parent is never canceled
}
select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err(), Cause(parent))
return
default:
}
- 查找祖先:它會嘗試找到最近的、也就是標(biāo)準(zhǔn)的
*cancelCtx類型的祖先。
if p, ok := parentCancelCtx(parent); ok {
// parent is a *cancelCtx, or derives from one.
p.mu.Lock()
if err := p.err.Load(); err != nil {
// parent has already been canceled
child.cancel(false, err.(error), p.cause)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
return
}
- 判斷是否實現(xiàn)了
afterFuncer接口:
if a, ok := parent.(afterFuncer); ok {
// parent implements an AfterFunc method.
c.mu.Lock()
stop := a.AfterFunc(func() {
child.cancel(false, parent.Err(), Cause(parent))
})
c.Context = stopCtx{
Context: parent,
stop: stop,
}
c.mu.Unlock()
return
}
- 掛載:
- 如果找到了親爹(Go 標(biāo)準(zhǔn)庫的 Context):加鎖,把自己塞到父親的
childrenmap 里。
if a, ok := parent.(afterFuncer); ok {
// parent implements an AfterFunc method.
c.mu.Lock()
stop := a.AfterFunc(func() {
child.cancel(false, parent.Err(), Cause(parent))
})
c.Context = stopCtx{
Context: parent,
stop: stop,
}
c.mu.Unlock()
return
}
- 如果父親是外人(比如自定義的 Context):沒辦法直接操作它的 map。于是啟動一個 Goroutine,在那傻傻地監(jiān)聽
parent.Done()。一旦父親 Done 了,這個 Goroutine 就負(fù)責(zé)把自己干掉。
goroutines.Add(1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err(), Cause(parent))
case <-child.Done():
}
}()
3 第三步:取消機制 —— 信號是如何傳播的?
源碼中的 cancel 方法(簡化版邏輯):
// removeFromParent: 是否需要把自己從父親的 map 中移除
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
c.mu.Lock()
if c.err != nil { // 如果已經(jīng)取消過了,直接返回
c.mu.Unlock()
return
}
c.err = err // 記錄錯誤原因
// 1. 【核心動作】關(guān)閉 channel!
// 此時,所有監(jiān)聽 <-ctx.Done() 的協(xié)程都會瞬間收到信號
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
// 2. 【遞歸通知】遍歷所有孩子,挨個調(diào)用它們的 cancel
for child := range c.children {
// 遞歸調(diào)用孩子的 cancel 方法
// 注意:這里傳 false,意思是孩子不需要從我這里移除(因為我自己都要沒了,大家一起銷毀)
child.cancel(false, err)
}
c.children = nil // 釋放 map,利用 GC 回收內(nèi)存
c.mu.Unlock()
// 3. 【斷絕關(guān)系】把自己從父親的 map 里刪掉
// 避免父親一直持有我的引用,導(dǎo)致內(nèi)存泄漏
if removeFromParent {
removeChild(c.Context, c)
}
}
4 總結(jié):Context 的生命周期圖解
假設(shè)有這樣一個調(diào)用鏈:CtxA (根) -> CtxB -> CtxC。
建立連接:
CtxB創(chuàng)建時,將自己放入CtxA.children。CtxC創(chuàng)建時,將自己放入CtxB.children。
觸發(fā)取消 (
CtxB.cancel()被調(diào)用):- 自身:
CtxB關(guān)閉自己的donechannel。所有監(jiān)聽CtxB的代碼收到停止信號。 - 向下(遞歸):
CtxB遍歷children,找到CtxC,調(diào)用CtxC.cancel()。于是CtxC也關(guān)閉donechannel。 - 向上(清理):
CtxB調(diào)用removeChild,讓CtxA把CtxB從 map 中刪掉。
- 自身:
5 補充
我們主動調(diào)用 cancel 的時候,removeFromParent 是不是傳 true ?
回答:是的
當(dāng)我們我們在業(yè)務(wù)代碼中調(diào)用那個由 context.WithCancel(或 WithTimeout、WithDeadline)返回的 cancel() 函數(shù)時,傳入 removeFromParent 的參數(shù)確實是 true。
這一步非常關(guān)鍵,它是防止 Context 樹內(nèi)存泄漏 的核心手段。
我們來看一下源碼驗證,順便對比一下什么時候傳 false。
1. 證據(jù):源碼中的入口
當(dāng)你調(diào)用 context.WithCancel 時,源碼是這樣寫的:
// src/context/context.go
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &c) // 把自己掛到父親節(jié)點
// 【注意看這里】
// 返回的閉包函數(shù),硬編碼了 true
return &c, func() { c.cancel(true, Canceled) }
}
解析: 這里的 func() { c.cancel(true, Canceled) } 就是你拿到的那個 cancel 函數(shù)。 當(dāng)你調(diào)用它時,你告訴 cancelCtx:“我已經(jīng)干完活了(或者想終止了),請把我從父親的 children 列表中移除。”
如果這里不傳 true,父節(jié)點就會一直保留著指向這個子節(jié)點的引用,直到父節(jié)點自己被取消。如果父節(jié)點是一個全局的 Background 或者生命周期很長的 Context,那么這些早已死掉的子節(jié)點就會一直堆積在內(nèi)存里,導(dǎo)致內(nèi)存泄漏。
2. 對比:什么時候傳false?
在 cancel 方法內(nèi)部,當(dāng)它遞歸去取消自己的 子節(jié)點 時,傳的是 false。
// src/context/context.go -> cancel 方法內(nèi)部
// ... 前面是關(guān)閉 channel 的邏輯
// 遍歷所有的孩子
for child := range c.children {
// 【注意看這里】
// 父親取消孩子時,傳的是 false
child.cancel(false, err)
}
c.children = nil // 父親直接清空整個 map
c.mu.Unlock()
// 如果 removeFromParent 為 true,才去調(diào)用 removeChild
if removeFromParent {
removeChild(c.Context, c)
}
為什么這里傳 false?這是一個性能優(yōu)化的設(shè)計。
想象一下這個場景:
- 父節(jié)點取消了。
- 父節(jié)點需要通知它下面的 1000 個子節(jié)點也取消。
- 父節(jié)點遍歷這 1000 個子節(jié)點,調(diào)用它們的
cancel。 - 關(guān)鍵點:父節(jié)點在通知完所有孩子后,緊接著有一行
c.children = nil,它會直接把整個“孩子名冊”撕碎丟進(jìn)垃圾桶。
如果此時給子節(jié)點傳 true,那么這 1000 個子節(jié)點每一個都會回頭去搶父節(jié)點的鎖(c.Context),試圖從父節(jié)點的 map 中刪除自己。 但在父節(jié)點看來:“你們不用一個個來辭職了,反正整個部門我都要裁掉,名單我已經(jīng)準(zhǔn)備銷毀了。”
所以,傳 false 避免了成百上千次無意義的鎖競爭和 Map 刪除操作。
總結(jié):
- 主動調(diào)用(外部觸發(fā)):
removeFromParent = true。因為只有這一個節(jié)點要走,必須精準(zhǔn)地從父節(jié)點的名單里刪掉,防止內(nèi)存泄漏。 - 級聯(lián)取消(內(nèi)部遞歸):
removeFromParent = false。因為父節(jié)點隨后會直接清空整個名單,子節(jié)點不需要自己去注銷。
到此這篇關(guān)于深入理解Golang中Context的使用的文章就介紹到這了,更多相關(guān)Golang Context使用內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Go語言中函數(shù)可變參數(shù)(Variadic Parameter)詳解
在Python中,在函數(shù)參數(shù)不確定數(shù)量的情況下,可以動態(tài)在函數(shù)內(nèi)獲取參數(shù)。在Go語言中,也有類似的實現(xiàn)方式,本文就來為大家詳細(xì)講解一下2022-07-07
go語言調(diào)用c語言的so動態(tài)庫的實現(xiàn)
在Go語言開發(fā)過程中,有時需要調(diào)用C或C++編寫的so動態(tài)庫,本文介紹了如何在Go語言中調(diào)用so庫的步驟和注意事項,包括環(huán)境準(zhǔn)備、編譯生成.so文件、Go文件編寫、以及可能遇到的問題和解決方法,感興趣的可以了解一下2024-10-10

