Context 源碼,再度重相逢
各位讀者朋友們大家好,我是隨波逐流的薯條。深秋了,前幾天氣溫驟降,北京的人和狗都不願意出門,趴在窩裏凍的打寒顫。我的書房裏沒裝空調,暖氣要十一月中旬纔來,每次想學習都得下很大的決心,所以這篇文章發出來時比預期又晚了幾天~
最近我的心也是冰冰的,我目前做在線數據開發,如果大家幹過這活肯定知道,數據開發最重要的是數據口徑,開發前一定得對清楚... 本人作爲在這上面踩了很多坑的人這幾天接需求時又掉進去了,一個需求在已經上線的情況下,不同來源的數據做 diff 總是對不上,一查就是口徑不對,來來回回改了根據口徑改了一遍邏輯,搞得我 tm 的真想和提供口徑的人打一架,md。
有點扯遠了,言歸正傳,這篇文章接着上次的 Context 這三個應用場景,你知嗎 繼續看看 context 源碼,讀者可能覺得【Context 源碼,再度重相逢】這個標題比較奇怪。起這個題目是因爲在下 讀 context 源碼時找了一些資料,最好的中文資料應是【碼農桃花源】qcrao 在 19 年寫過的一篇關於 context 解析的文章,所以我在猶豫要不要寫我的這篇,說實話代碼都看完了不寫出來吹吹牛逼總覺得有點虧。好在 rao 老闆分析 context 源碼基於的 Go 版本是 1.9.2,如今 Go 已經 1.17 了,context 的源碼也有很多更新,於是不才就來一篇基於 1.17.2 的 context 源碼分析,不多說了,發車!
-
源碼分析
-
ctx 存儲鍵值對
-
ctx 的取消機制
-
源碼賞析
-
if >= 2 用 switch 替換
-
atomic.Value 替換 chan struct 減少鎖使用
-
加鎖前,先獲取值避免加鎖
-
String 邏輯賞析
-
一個 Bug
-
總結
源碼分析
context 的核心作用是存儲鍵值對和取消機制。存儲鍵值對比較簡單,取消機制比較複雜,先來看一下 Context 抽象出來的接口:
type Context interface {
// 如果是timerCtx或者自定義的ctx實現了此方法,返回截止時間和true,否則返回false
Deadline() (deadline time.Time, ok bool)
// 這裏監聽取消信號
Done() <-chan struct{}
// ctx取消時,返回對應錯誤,有context canceled和context deadline exceeded
Err() error
// 返回key的val
Value(key interface{}) interface{}
}
ctx 存儲鍵值對
鍵值對 ctx 比較簡單,先來看一下它的邏輯:要新建一個存儲鍵值對的 ctx,需要調用WithValue
,它返回一個valueCtx
地址對象。valueCtx 結構體內部很簡單,有個 Context 接口和 k-v 對:
type valueCtx struct {
Context
key, val interface{}
}
valueCtx
實現了Value
方法,邏輯也很簡單:
func (c *valueCtx) Value(key interface{}) interface{} {
// key相同則返回key
if c.key == key {
return c.val
}
// 否則從父節點中調用Value方法繼續尋找key
return c.Context.Value(key)
}
寫一段代碼看一下從valueCtx
中查找某個 key 的過程:
func main() {
ctx := context.Background()
ctx1 := context.WithValue(ctx, "name", "uutc")
ctx2 := context.WithValue(ctx1, "age", "18")
ctx3 := context.WithValue(ctx2, "traceID", "89asd7yu9asghd")
fmt.Println(ctx3.Value("name"))
}
valueCtx
是個鏈表模型,當我們從 ctx3 中查找 name 這個 key, 最終要走到 ctx1 中才能返回對應的 value,如圖所示:
雖然鏈表的查找效率是 O(n) 的,但貌似一個請求裏面也不會有 1000 個 ctx,個人認爲 ctx 鏈表的查找效率可以接受,讀者有興趣可以給 go 團隊提個 pr,把鏈表改成紅黑樹試試,嘿嘿~
ctx 的取消機制
context 的取消機制我個人認爲可以分成兩種:第一種是普通取消,需要取消 ctx 時調用 cancel 函數。第二個是根據時間取消,用戶可以定義一個過期 time 或一個 deadline,到這個時間時自動取消。
普通取消
現在假裝沒看源碼,聯想一下如果我們自己實現。該如何寫取消。 建立 ctx 時, 是在 parent 的基礎上 copy 一份,然後添加自己的屬性, 不同協程可能持有不同的 ctx, 若想在請求層面做協程取消,就需要廣播機制,比如在下圖中:
img
若我們要取消 ctx2,應分爲向上取消和向下取消兩部分,向下取消要把當前節點的子節點都幹掉,在這裏需要 tx4、ctx5 都取消掉;而向上取消需要把他在父節點中刪除,在這裏需要把自己 (ctx2) 從父節點 ctx 的子節點列表中刪除;
取消這個動作本身並沒有神奇的地方。ctx 創建一個 channel,然後協程通過 select 去監聽這個 channel,沒數據時處於阻塞狀態,當調用取消函數,函數內部執行 close(chan) 操作, select 監聽到關閉信號執行 return,達到取消協程的目的,寫個 demo:
func main() {
done := make(chan struct{})
go func() {
close(done)
}()
select {
case <-done:
println("exit!")
return
}
}
下面來看 go 源碼是怎麼實現的取消,首先抽象出了一個canceler
接口,這個接口裏最重要的就是 cancel 方法,調用這個方法可以發送取消信號,有兩個結構體實現了這個接口,分別是 *cancelCtx(普通取消) 和 *timerCtx(時間取消)。
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}
cancelCtx 對應前文說的普通取消機制,它是 context 取消機制的基石,也是源碼中比較難理解的地方,先來看一下它的結構體:
type cancelCtx struct {
Context
mu sync.Mutex // context號稱併發安全的基石
done atomic.Value // 用於接收ctx的取消信號,這個數據的類型做過優化,之前是 chan struct 類型
children map[canceler]struct{} // 儲存此節點的實現取消接口的子節點,在根節點取消時,遍歷它給子節點發送取消信息
err error // 調用取消函數時會賦值這個變量
}
若我們要生成一個可取消的 ctx,需要調用 WithCancel 函數,這個函數的內部邏輯是:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
c := newCancelCtx(parent) // 基於父節點,new一個CancelCtx對象
propagateCancel(parent, &c) // 掛載c到parent上
return &c, func() { c.cancel(true, Canceled) } // 返回子ctx,和返回函數
}
這裏邏輯比較重的地方是 propagateCancel 函數和 cancel 方法,propagateCancel 函數主要工作是把子節點掛載父節點上,下面來看看它的源碼:
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
// 判斷父節點的done是否爲nil,若爲nil則爲不可取消的ctx, 直接返回
if done == nil {
return
}
// 看能否從done裏面讀到數據,若能說明父節點已取消,取消子節點,返回即可,不能的話繼續流轉到後續邏輯
select {
case <-done:
child.cancel(false, parent.Err())
return
default:
}
// 調用parentCancelCtx函數,看是否能找到ctx上層最接近的可取消的父節點
if p, ok := parentCancelCtx(parent); ok {
//這裏是可以找到的情況
p.mu.Lock()
// 父節點有err,說明已經取消,直接取消子節點
if p.err != nil {
child.cancel(false, p.err)
} else {
// 把本節點掛載到父節點的children map中
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 若沒有可取消的父節點掛載
atomic.AddInt32(&goroutines, +1)
// 新起一個協程
go func() {
select {
// 監聽到父節點取消時,取消子節點
case <-parent.Done():
child.cancel(false, parent.Err())
// 監聽到子節點取消時,什麼都不做,退出協程
case <-child.Done():
}
}()
}
}
我看這段源碼時產生了兩個問題:
-
函數內部的 parentCancelCtx 函數中有個 else 分支,什麼條件下會走到這裏
-
調用 cancel 方法需要傳遞 bool 值,何時傳 true,何時傳 false
經過一番研究,大概解決了這倆問題,下面依次做解答。
什麼條件下會走到 parentCancelCtx 函數的 else 分支
首先看下 parentCancelCtx 函數的邏輯。parentCancelCtx 函數用來查找 ctx 最近的一個可取消的父節點,這個函數也經過了優化,原代碼是:
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
for {
switch c := parent.(type) {
case *cancelCtx:
return c, true
case *timerCtx:
return &c.cancelCtx, true
case *valueCtx:
parent = c.Context
default:
return nil, false
}
}
}
這段代碼比較簡單,起了一個 for 循環,遇到*cancelCtx
和*timerCtx
類型就返回,遇到*valueCtx
則繼續向上查找 parent,直到找到了節點或者找不到爲止。
最新版本的代碼放棄粗暴的使用 for{} 循環尋找父節點,而是用 parent.Value 方法查到父節點是否能找到特定的 key,由於 Value 是遞歸的,所以這裏表面上看不出來循環的足跡:
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
done := parent.Done()
if done == closedchan || done == nil {
return nil, false
}
// Value是遞歸向上查找,直到找到有*cancelCtxKey 的ctx爲止
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {
return nil, false
}
pdone, _ := p.done.Load().(chan struct{})
if pdone != done {
return nil, false
}
return p, true
}
知道了這個前提,我們繼續研究**什麼條件下會走到 parentCancelCtx 函數的 else 分支。**我自己實現了一個 Context,代碼如下
type ContextCancel struct {
context.Context
}
func (*ContextCancel) Done() <-chan struct{} {
ch := make(chan struct{}, 100)
return ch
}
當調用這段代碼時,即可走到 else 分支,寫個 demo:
func main() {
ctx := context.Background()
ctx1, _ := context.WithCancel(ctx)
ctx2 := context.WithValue(ctx1, "hello", "world")
ctx3 := ContextCancel{ctx2}
ctx4, _ := context.WithCancel(&ctx3) // 這裏可以走到else分支
println(ctx4)
}
與源碼中 CancelCtx 不同的是,我這裏的 Done 方法只是簡單返回,並沒有把 done 的值存到 Context 中去。所以在執行 parentCancelCtx 時,這裏會判斷失敗,返回 false:
pdone, _ := p.done.Load().(chan struct{})
if pdone != done {
return nil, false
}
通過parent.Value(&cancelCtxKey).(*cancelCtx)
雖然找到了 cancelCtx,但是在 load Done 方法值的時候卻鎩羽而歸,parentCancelCtx 這裏判斷失敗,最終返回 nil 和 false,最終走到 else 分支。所以這個 else 分支主要是預防用戶自己實現了一個定製的 Ctx 中,隨意提供了一個 Done chan 的情況的,由於找不到可取消的父節點的,只好新起一個協程做監聽。
調用 cancel 方法需要傳遞 bool 值,何時傳 true,何時傳 false
要明白這個問題,先來看一下 * cancelCtx 類型的 cancel 方法實現:
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
// 取消時必須傳入err,不然panic
if err == nil {
panic("context: internal error: missing cancel error")
}
c.mu.Lock()
// 已經出錯了,說明已取消,直接返回
if c.err != nil {
c.mu.Unlock()
return
}
// 用戶傳進來的err賦給c.err
c.err = err
d, _ := c.done.Load().(chan struct{})
if d == nil {
// 這裏其實和關閉chan差不多,因爲後續會用closedchan作判斷
c.done.Store(closedchan)
} else {
// 關閉chan
close(d)
}
// 這裏是向下取消,依次取消此節點所有的子節點
for child := range c.children {
child.cancel(false, err)
}
// 清空子節點
c.children = nil
c.mu.Unlock()
// 這裏是向上取消,取消此節點和父節點的聯繫
if removeFromParent {
removeChild(c.Context, c)
}
}
removeChild 函數的邏輯比較簡單,核心就是調用 delete 方法,在父節點的子節點中清空自己。
func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child) // 這裏只是刪除一個
}
p.mu.Unlock()
}
看完這倆函數的邏輯後,這個問題也可以回答。當父節點調用 cancel 函數時傳遞 true, 其他情況傳遞 false。
true 用來向上刪除,核心邏輯是調用removeChild
函數里面的的:
if p.children != nil {
delete(p.children, child) // 這裏只是刪除一個
}
而 false 就是用來非向上刪除,只需要執行完 cancel 方法這段代碼即可:
for child := range c.children {
child.cancel(false, err) // 這裏把子節點都幹掉
}
看到這裏,ctx 的普通取消機制基本差不多了,下面來看一下基於時間的取消機制。
時間取消
時間取消 ctx 可傳入兩種時間,第一種是傳入超時時間戳;第二種是傳入 ctx 持續時間,比如 2s 之後 ctx 取消,持續時間在實現上是在 time.Now 的基礎上加了個 timeout 湊個超時時間戳,本質上都是調用的WithDeadline
函數。
WithDeadline 函數內部 new 了一個timerCtx
,先來看一下這個結構體的內容:
type timerCtx struct {
cancelCtx
timer *time.Timer // 一個統一的計時器,後續通過 time.AfterFunc 使用
deadline time.Time // 過期時間戳
}
可以看到 timerCtx 內嵌了 cancelCtx,實際上在超時取消這件事上,timerCtx 更多負責的是超時相關的邏輯,而取消主要調用的 cancelCtx 的 cancel 方法。先來看一下WithDeadline
函數的邏輯,看如何返回一個時間 Ctx:
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// 若父節點爲nil,panic
if parent == nil {
panic("cannot create context from nil parent")
}
// 如果parent有超時時間、且過期時間早於參數d,那parent取消時,child 一定需要取消,直接通過WithCancel走起
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// The current deadline is already sooner than the new one.
return WithCancel(parent)
}
// 構造一個timerCtx, 主要傳入一個過期時間
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
// 把這個節點掛載到父節點上
propagateCancel(parent, c)
dur := time.Until(d)
// 若子節點已過期,直接取消
if dur <= 0 {
c.cancel(true, DeadlineExceeded) // deadline has already passed
return c, func() { c.cancel(false, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
// 否則等到過期時間時,執行取消操作
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
// 返回一個ctx和一個取消函數
return c, func() { c.cancel(true, Canceled) }
}
看完源碼可以知道,除了基於時間的取消,當調用 CancelFunc 時,也能取消超時 ctx。再來看一下*timerCtx
的 cancel 方法的源碼:
func (c *timerCtx) cancel(removeFromParent bool, err error) {
// 調用cancel的cancel取消掉它下游的ctx
c.cancelCtx.cancel(false, err)
// 取消掉它上游的ctx的連接
if removeFromParent {
removeChild(c.cancelCtx.Context, c)
}
// 把timer停掉
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
至此,context 源碼分析的差不多了,關於這塊還有個挺常見的問題,Context.TODO 和 Context.Backend 的區別。從代碼上看,他倆沒啥區別,都只是emptyCtx
的對象而已,emptyCtx
源碼很簡單,這裏不再贅述。
源碼賞析
寫這篇文章時,我在想看源碼的好處什麼。個人認爲有兩點,第一可以從源碼角度看到一個概念的全部細節,第二個是可以學習大牛寫代碼的思路。實際上 context 的代碼也有個迭代過程,下面列舉一些閱讀源碼時學習到的點:
if >= 2 用 switch 替換
String() string
方法。
atomic.Value 替換 chan struct 減少鎖使用
cancelCtx
源碼,用 atomic.Value 類型替換了 chan struct{}。
加鎖前,先獲取值避免加鎖
這點在 go 源碼中隨處可見,簡單列舉幾處:
String 邏輯賞析
一個 Bug
截止日期已經過了,cancel 已經執行過了,沒必要在返回取消函數中再從父 ctx 中取消自己了. 感覺 removeFromParent 有點沒抽象好,這不,作者自己都掉坑裏去了。
總結
個人感覺 context 代碼挺值得一看的:struct 裏面嵌套 interface,struct 並不對外暴露,而是提供多個Withxxx
方法新建對象;interface 對外暴露,用戶可以根據需要構建自己的 Context;timerCtx struct 裏面嵌套 CancelCtx struct 以此來複用 cancel 的邏輯等等。關於這塊讀者有什麼心得可以在留言區評論,我會挑選三個最有價值的評論,每個評論發 20 塊紅包給你加個餐~
最後,給自己打個廣告
歡迎加入 隨波逐流的薯條 微信羣。
薯條目前有草帽羣、木葉羣、琦玉羣,羣交流內容不限於技術、投資、趣聞分享等話題。歡迎感興趣的同學入羣交流。
入羣請加薯條的個人微信:709834997。並備註:加入薯條微信羣。
歡迎關注我的公衆號~
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/myE5-b4okPOsg3elEsIlog