深入理解Golang中Context的使用
理解 cancelCtx 的源碼,其實(shí)就是理解 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,同時自己維護(hù)了一套“家譜”關(guān)系。
// 源碼簡化版
type cancelCtx struct {
Context // 1. 嵌入父 Context,保證能調(diào)用父類方法
mu sync.Mutex // 2. 互斥鎖,保證并發(fā)安全(保護(hù)下面的字段)
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 都會收到信號(讀到零值)。 這就是它的“廣播”機(jī)制。children:這是一個 Set(用 Map 實(shí)現(xiàn))。當(dāng)父節(jié)點(diǎn)取消時,它需要遍歷這個 Map,把所有的子節(jié)點(diǎn)也干掉。
2 第二步:掛載機(jī)制 —— 如何與父節(jié)點(diǎn)建立聯(lián)系?
當(dāng)你調(diào)用 context.WithCancel(parent) 時,核心邏輯在于如何把自己“掛”到父節(jié)點(diǎn)的 children 列表里。
這主要依賴于內(nèi)部函數(shù) propagateCancel(傳播取消)。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent) // 創(chuàng)建自己
propagateCancel(parent, &c) // 【關(guān)鍵】把自己掛到父節(jié)點(diǎn)上
return &c, func() { c.cancel(true, Canceled) }
}
propagateCancel 的邏輯如下:
- 判斷父節(jié)點(diǎn)狀態(tài):如果父節(jié)點(diǎn)
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
}
- 判斷是否實(shí)現(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 第三步:取消機(jī)制 —— 信號是如何傳播的?
源碼中的 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 補(bǔ)充
我們主動調(diào)用 cancel 的時候,removeFromParent 是不是傳 true ?
回答:是的
當(dāng)我們我們在業(yè)務(wù)代碼中調(diào)用那個由 context.WithCancel(或 WithTimeout、WithDeadline)返回的 cancel() 函數(shù)時,傳入 removeFromParent 的參數(shù)確實(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é)點(diǎn)
// 【注意看這里】
// 返回的閉包函數(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é)點(diǎn)就會一直保留著指向這個子節(jié)點(diǎn)的引用,直到父節(jié)點(diǎn)自己被取消。如果父節(jié)點(diǎn)是一個全局的 Background 或者生命周期很長的 Context,那么這些早已死掉的子節(jié)點(diǎn)就會一直堆積在內(nèi)存里,導(dǎo)致內(nèi)存泄漏。
2. 對比:什么時候傳false?
在 cancel 方法內(nèi)部,當(dāng)它遞歸去取消自己的 子節(jié)點(diǎn) 時,傳的是 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é)點(diǎn)取消了。
- 父節(jié)點(diǎn)需要通知它下面的 1000 個子節(jié)點(diǎn)也取消。
- 父節(jié)點(diǎn)遍歷這 1000 個子節(jié)點(diǎn),調(diào)用它們的
cancel。 - 關(guān)鍵點(diǎn):父節(jié)點(diǎn)在通知完所有孩子后,緊接著有一行
c.children = nil,它會直接把整個“孩子名冊”撕碎丟進(jìn)垃圾桶。
如果此時給子節(jié)點(diǎn)傳 true,那么這 1000 個子節(jié)點(diǎn)每一個都會回頭去搶父節(jié)點(diǎn)的鎖(c.Context),試圖從父節(jié)點(diǎn)的 map 中刪除自己。 但在父節(jié)點(diǎn)看來:“你們不用一個個來辭職了,反正整個部門我都要裁掉,名單我已經(jīng)準(zhǔn)備銷毀了。”
所以,傳 false 避免了成百上千次無意義的鎖競爭和 Map 刪除操作。
總結(jié):
- 主動調(diào)用(外部觸發(fā)):
removeFromParent = true。因為只有這一個節(jié)點(diǎn)要走,必須精準(zhǔn)地從父節(jié)點(diǎn)的名單里刪掉,防止內(nèi)存泄漏。 - 級聯(lián)取消(內(nèi)部遞歸):
removeFromParent = false。因為父節(jié)點(diǎn)隨后會直接清空整個名單,子節(jié)點(diǎn)不需要自己去注銷。
到此這篇關(guān)于深入理解Golang中Context的使用的文章就介紹到這了,更多相關(guān)Golang Context使用內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- 深入Golang之context的用法詳解
- GOLANG使用Context實(shí)現(xiàn)傳值、超時和取消的方法
- GoLang之使用Context控制請求超時的實(shí)現(xiàn)
- golang中context的作用詳解
- 詳解Golang中Context的三個常見應(yīng)用場景
- golang通過context控制并發(fā)的應(yīng)用場景實(shí)現(xiàn)
- 提升Golang應(yīng)用性能:深入理解Context的應(yīng)用
- golang context接口類型方法介紹
- golang中context使用小結(jié)
- 詳解Golang中Context的原理和使用技巧
相關(guān)文章
Go語言中函數(shù)可變參數(shù)(Variadic Parameter)詳解
在Python中,在函數(shù)參數(shù)不確定數(shù)量的情況下,可以動態(tài)在函數(shù)內(nèi)獲取參數(shù)。在Go語言中,也有類似的實(shí)現(xiàn)方式,本文就來為大家詳細(xì)講解一下2022-07-07
Go語言開發(fā)實(shí)現(xiàn)一個圖片批量壓縮工具
在日常開發(fā)和辦公中,圖片體積過大會帶來不少困擾,本文將使用Go語言編寫一個圖片批量壓縮工具,文中的示例代碼講解詳細(xì),需要的可以了解下2025-09-09
go語言調(diào)用c語言的so動態(tài)庫的實(shí)現(xiàn)
在Go語言開發(fā)過程中,有時需要調(diào)用C或C++編寫的so動態(tài)庫,本文介紹了如何在Go語言中調(diào)用so庫的步驟和注意事項,包括環(huán)境準(zhǔn)備、編譯生成.so文件、Go文件編寫、以及可能遇到的問題和解決方法,感興趣的可以了解一下2024-10-10

