輕鬆上手!手把手帶你掌握從Context到go設計理念

語言: CN / TW / HK

導語 |  本文推選自騰訊雲開發者社群-【技思廣益 · 騰訊技術人原創集】專欄。該專欄是騰訊雲開發者社群為騰訊技術人與廣泛開發者打造的分享交流視窗。欄目邀約騰訊技術人分享原創的技術積澱,與廣泛開發者互啟迪共成長。 本文作者是 騰訊後端開發工程師陳雪鋒。

context包比較小,是閱讀原始碼比較理想的一個入手,並且裡面也涵蓋了許多go設計理念可以學習。

go的Context作為go併發方式的一種,無論是在原始碼net/http中,開源框架例如gin中,還是內部框架trpc-go中都是一個比較重要的存在,而整個 context 的實現也就不到600行,所以也想借著這次機會來學習學習,本文基於go 1.18.4。話不多說, 例:

為了使可能對context不太熟悉的同學有個熟悉,先來個example ,摘自原始碼:

我們利用WithCancel建立一個可取消的Context,並且遍歷頻道輸出,當 n==5時,主動呼叫cancel來取消。

而在gen func 中有個協程來監聽ctx當監聽到ctx.Done()即被取消後就退出協程。

func main(){
gen := func(ctx context.Context) <-chan int {
dst := make(chan int)
n := 1
go func() {
for {
select {
case <-ctx.Done():
close(dst)
return // returning not to leak the goroutine
case dst <- n:
n++
}
}
}()
return dst
}


ctx, cancel := context.WithCancel(context.Background())
// defer cancel() // 實際使用中應該在這裡呼叫 cancel


for n := range gen(ctx) {
fmt.Println(n)
if n == 5 {
cancel() // 這裡為了使不熟悉 go 的更能明白在這裡呼叫了 cancel()
break
}
}
// Output:
// 1
// 2
// 3
// 4
// 5
}

這是最基本的使用方法。

概覽

對於context包先上一張圖,便於大家有個初步瞭解(內部函式並未全列舉,後續會逐一講解):

最重要的就是右邊的介面部分,可以看到有幾個比較重要的介面,下面逐一來說下:

type Context interface{


Deadline() (deadline time.Time, ok bool)


Done() <-chan struct{}
Err() error


Value(key any) any


}

首先就是Context介面,這是整個context包的核心介面,就包含了四個 method,分別是:

Deadline() (deadline time.Time, ok bool) // 獲取 deadline 時間,如果沒有的話 ok 會返回 false
Done() <-chan struct{} // 返回的是一個 channel ,用來應用監聽任務是否已經完成
Err() error // 返回取消原因 例如:Canceled\DeadlineExceeded
Value(key any) any // 根據指定的 key 獲取是否存在其 value 有則返回

可以看到這個介面非常清晰簡單明瞭,並且沒有過多的Method,這也是go 設計理念, 介面儘量簡單、小巧,通過組合來實現豐富的功能,後面會看到如何組合的

再來看另一個介面canceler,這是一個取消介面,其中一個非匯出 method cancel,接收一個bool和一個error,bool用來決定是否將其從父Context中移除,err用來標明被取消的原因。還有個Done()和Context介面一樣,這個介面為何這麼設計,後面再揭曉。

type canceler interface{
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}

接下來看這兩個介面的實現者都有誰,首先Context直接實現者有 *valueCtx(比較簡單放最後講)和*emptyCtx

而canceler直接實現者有*cancelCtx和*timerCtx ,並且這兩個同時也實現了Context介面(記住我前面說得另外兩個是直接實現,這倆是巢狀介面實現松耦合,後面再說具體好處),下面逐一講解每個實現。

空的

見名知義,這是一個空實現,事實也的確如此,可以看到啥啥都沒有,就是個空實現,為何要寫呢?

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
}


func (e *emptyCtx) String() string {
switch e {
case background:
return "context.Background"
case todo:
return "context.TODO"
}
return "unknown empty Context"
}

再往下讀原始碼會發現兩個有意思的變數,底層一模一樣,一個取名叫 background,一個取名叫todo,為何呢?耐心的可以看看解釋,其實是為了方便大家區分使用,背景 是在入口處來傳遞最初始的context,而todo 則是當你不知道用啥,或者你的函式雖然接收ctontext引數,但是並沒有做任何實現時,那麼就使用todo即可。後續如果有具體實現再傳入具體的上下文。所以上面才定義了一個空實現,就為了給這倆使用呢,這倆也是我們最常在入口處使用的。

var (
background = new(emptyCtx)
todo = new(emptyCtx)
)


// Background returns a non-nil, empty Context. It is never canceled, has no
// values, and has no deadline. It is typically used by the main function,
// initialization, and tests, and as the top-level Context for incoming
// requests.
func Background() Context {
return background
}


// TODO returns a non-nil, empty Context. Code should use context.TODO when
// it's unclear which Context to use or it is not yet available (because the
// surrounding function has not yet been extended to accept a Context
// parameter).
func TODO() Context {
return todo
}

下面再看看具體的定義吧。

cancelCtx與timerCtx、valueCtx

type cancelCtx struct{
Context
mu sync.Mutex // 鎖住下面欄位的操作
// 存放的是 chan struct{}, 懶建立,
// 只有第一次被 cancel 時才會關閉
done atomic.Value
// children 存放的是子 Context canceler ,並且當第一次被 cancel 時會被
// 設為 nil
children map[canceler]struct{}
// 第一次被呼叫 cancel 時,會被設定
err error
}


type timerCtx struct{
cancelCtx
timer *time.Timer // 定時器,用來監聽是否超時該取消
deadline time.Time // 終止時間
}


type valueCtx struct {
Context
key, val any
}

這裡就看出來為何cancelCtx為非匯出了,因為它通過內嵌Context介面也也是實現了Context的。並且通過這種方式實現了松耦合,可以通過 WithCancel(父Context) (ctx Context,cancel CancelFunc) 來傳遞任何自定義的Context實現。

而timerCtx是巢狀的cancelCtx,同樣他也可以同時呼叫Context介面所有 method與cancelCtx所有method ,並且還可以重寫部分方法。而 valueCtx和上面兩個比較獨立,所以直接巢狀的Context。

這裡應該也看明白了為何canceler為何一個可匯出Done一個不可匯出 cancel,Done是重寫Context的method會由上層呼叫,所以要可匯出, cancel則是由return func(){c.cancel(false,DeadlineExeceed) 類似的封裝匯出,所以不應該匯出。

這是go中推崇的 通過組合而非繼承來編寫程式碼 。其中欄位解釋我已在後面註明,後面也會講到。看懂了大的一個設計理念,下面我們就逐一擊破,通過上面可以看到timerCtx其實是複用了cancelCtx能力,所以cancelCtx最為重要,下面我們就先將cancelCtx實現。

取消

它非匯出,是通過一個方法來直接返回Context型別的,這也是go理念之一,不暴露實現者,只暴露介面(前提是實現者中的可匯出method不包含介面之外的method, 否則匯出的method外面也無法呼叫)。

先看看外部建構函式WithCancel,

  • 先判斷parent是否為nil,如果為nil就panic,這是為了避免到處判斷是否為nil。所以永遠不要使用nil來作為一個Context傳遞。

  • 接著將父Context封裝到cancelCtx並返回,這沒啥說得,雖然只有一行程式碼,但是多處使用,所以做了封裝,並且後續如果要更改行為呼叫者也無需更改。很方便。

  • 呼叫propagateCancel,這個函式作用就是當parent是可以被取消的時候就會對子Context也進行取消的取消或者準備取消動作。

  • 返回Context與CancelFunc type >CancelFunc func()就是一個 type func別名,底層封裝的是c.cancel方法,為何這麼做呢?這是為了給上層應用一個統一的呼叫,cancelCtx與timerCtx以及其他可以實現不同的cancel但是對上層是透明並且一致的行為就可。這個func應該是協程安全並且多次呼叫只有第一次呼叫才有效果。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc){
if parent == nil {
panic("cannot create context from nil parent")
}
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return&c, func() { c.cancel(true, Canceled) }
}


func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}

接下來就來到比較重要的func  propagateCancel,我們看看它做了啥,

首先是判斷父context的Done()方法返回的channel是否為nil,如果是則直接返回啥也不做了。這是因為父Context從來不會被取消的話,那就沒必要進行下面動作。這也表名我們使用.與貓(上下文。Background()) 這個函式是不會做任何動作的。

 done := parent.Done()
if done == nil {
return // parent is never canceled
}

接下里就是一個select ,如果父Context已經被取消了的話,那就直接取消子Context就好了,這個也理所應當,父親都被取消了,兒子當然也應該取消,沒有存在必要了。

select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err())
return
default:
}

如果父 Context 沒有被取消,這裡就會做個判斷,

  • 看看parent是否是一個*cancelCtx,如果是的話就返回其p,再次檢查 p.err是否為nil,如果不為nil就說明parent被取消,接著取消 子 Context,如果沒被取消的話,就將其加入到p.children中,看到這裡的 map是個canceler,可以接收任何實現取消器 的型別。這裡為何要加鎖呢?因為要對p.err以及p.children進行讀取與寫入操作,要確保協程安全所以才加的鎖。

  • 如果不是*cancelCtx型別就說明parent是個被封裝的其他實現 Context 介面的型別,則會將goroutines是個int加1這是為了測試使用的,可以不管它。並且會啟動個協程,監聽父Context ,如果父Context被取消,則取消子Context,如果監聽到子Context已經結束(可能是上層主動呼叫CancelFunc)則就啥也不用做了。

  if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// parent has already been canceled
child.cancel(false, p.err)
} else {
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的實現:它是為了找尋parent底下的 *cancelCtx,

它首先檢查parent.Done()如果是一個closedchan這個頻道 在初始化時已經是個一個被關閉的通道或者未nil的話(emptyCtx)那就直接返回 nil,false。

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
done := parent.Done()
if done == closedchan || done == nil {
return nil, false
}
var closedchan = make(chan struct{})


func init() {
close(closedchan)
}
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {
return nil, false
}

接著判斷是否parent是*cancelCtx型別,如果不是則返回nil,false,這裡呼叫了parent.Value方法,並最終可能會落到value方法:

func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
case *cancelCtx:
if key == &cancelCtxKey {
return c
}
c = ctx.Context
case *timerCtx:
if key == &cancelCtxKey {
return &ctx.cancelCtx
}
c = ctx.Context
case *emptyCtx:
return nil
default:
return c.Value(key)
}
}
}
  • 如果是*valueCtx,並且key==ctx.key則返回,否則會將c賦值為 ctx.Context,繼續下一個迴圈

  • 如果是*cancelCtx並且key==&cancelCtxKey則說明找到了,直接返回,否則c= ctx.上下文繼續

  • 如果是*timerCtx,並且key== &cancelCtxKey則會返回內部的*cancelCtx

  • 如果是*emptyCtx 則直接返回nil,

  • 預設即如果是使用者自定義實現則呼叫對應的Value找尋

可以 發現如果巢狀實現過多的話這個方法其實是一個遞迴呼叫。

如果是則要繼續判斷p.done與parent.Done()是否相等,如果沒有則說明:*cancelCtx已經被包裝在一個自定義實現中,提供了一個不同的包裝,在這種情況下就返回nil,false:

pdone, _ := p.done.Load().(chan struct{})
if pdone != done {
return nil, false
}
return p, true

構造算是結束了,接下來看看如何取消的:

  • 檢查err是否為nil

   if err == nil {
panic("context: internal error: missing cancel error")
}
  • 由於要對err、cancelCtx.done以及children進行操作,所以要加鎖

  • 如果c.err不為nil則說明已經取消過了,直接返回。否則將c.err=err賦值,這裡看到只有第一次呼叫才會賦值,多次呼叫由於已經有 != nil+鎖的檢查,所以會直接返回,不會重複賦值

c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // already canceled
}
c.err = err
  • 會嘗試從c.done獲取,如果為nil,則儲存一個closedchan,否則就關閉d,這樣當你context.Done()方法返回的channel才會返回。

d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
  • 迴圈遍歷c.children去關閉子Context,可以看到釋放子context時會獲取 子Context的鎖,同時也會獲取父Context的鎖。所以才是執行緒安全的。結束後釋放鎖

     for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
  • 如果要將其從父Context刪除為true,則將其從父上下文刪除

if removeFromParent {
removeChild(c.Context, c)
}

removeChild也比較簡單,當為*cancelCtx就將其從Children內刪除,為了保證執行緒安全也是加鎖的。

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()
}

Done就是返回一個channel用於告知應用程式任務已經終止:這一步是隻讀沒有加鎖,如果沒有讀取到則嘗試加鎖,再讀一次,還沒讀到則建立一個chan,可以看到這是一個懶建立的過程。所以當用戶主動呼叫CancelFunc時,其實根本就是將c.done記憶體儲的chan close掉,這其中可能牽扯到父關閉,也要迴圈關閉子Context過程。

func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}

cancelCtx主要內容就這麼多,接下里就是timerCtx了

計時器

回顧下timerCtx定義,就是內嵌了一個cancelCtx另外多了兩個欄位timer和deadline,這也是組合的體現。

type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.


deadline time.Time
}

下面就看看兩個建構函式,WithDeadline與WithTimeout,WithTimeout就是對WithDealine的一層簡單封裝。

檢查不多說了, 第二個檢查如果父context的截止時間比傳遞進來的早的話,這個時間就無用了,那麼就退化成cancelCtx了。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
return WithCancel(parent)
}
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}

構造timerCtx並呼叫propagateCancel,這個已經在上面介紹過了。

 c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c)

接著會看,會先利用time.直到(d.分時。Now()) 來判斷傳入的 deadlineTime與當前時間差值,如果在當前時間之前的話說明已經該取消了,所以會直接呼叫cancel函式進行取消,並且將其從父Context中刪除。否則就建立一個定時器,當時間到達會呼叫取消函式,這裡是定時呼叫,也可能使用者主動呼叫。

dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded)
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)
})
}
return c, func() { c.cancel(true, Canceled) }

下面看看cancel實現吧,相比較cancelCtx就比較簡單了,先取消 cancelCtx,也要加鎖,將c.timer停止並賦值nil,這裡也是第一次呼叫才會賦值nil,因為外層還有個c.timer !=nil的判斷,所以多次呼叫只有一次賦值。

func (c *timerCtx) cancel(removeFromParent bool, err error) {
c.cancelCtx.cancel(false, err)
if removeFromParent {
// Remove this timerCtx from its parent cancelCtx's children.
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}

相比較於cancelCtx還覆蓋實現了一個Deadline(),就是返回當前 Context的終止時間。

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}

下面就到了最後一個內建的valueCtx了。

結構器就更加加單,就多了key,val

type valueCtx struct {
Context
key, val any
}

也就有個Value method不同,可以看到底層使用的就是我們上面介紹的value函式,重複複用

func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}

幾個主要的講解完了,可以看到不到600行程式碼,就實現了這麼多功能,其中蘊含了組合、封裝、結構體巢狀介面等許多理念,值得好好琢磨。下面我們再看看其中有些有意思的地方。我們一般列印字串都是使用 fmt 包,那麼不使用fmt包該如何列印呢?context包裡就有相應實現,也很簡單,就是 switch case來判斷v型別並返回,它這麼做的原因也有說:

“因為我們不希望上下文依賴於unicode表”,這句話我還沒理解,有知道的小夥伴可以在底下評論,或者等我有時間看看fmt包實現。

func stringify(v any) string {
switch s := v.(type) {
case stringer:
return s.String()
case string:
return s
}
return "<not Stringer>"
}


func (c *valueCtx) String() string {
return contextName(c.Context) + ".WithValue(type " +
reflectlite.TypeOf(c.key).String() +
", val " + stringify(c.val) + ")"
}

使用Context的幾個原則

直接在函式引數傳遞,不要在struct傳遞,要明確傳遞,並且作為第一個引數,因為這樣可以由呼叫方來傳遞不同的上下文在不同的方法上,如果你在 struct內使用context則一個例項是公用一個context也就導致了協程不安全,這也是為何net包Request要拷貝一個新的Request WithRequest(context go 1.7 才被引入),net包牽扯過多,要做到相容才嵌入到 struct內。

不要使用nil而當你不知道使用什麼時則使用TODO,如果你用了nil則會 panic。避免到處判斷是否為nil。

WithValue不應該傳遞業務資訊,只應該傳遞類似request-id之類的請求資訊。

無論用哪個型別的Context,在構建後,一定要加上:defer cancel(),因為這個函式是可以多次呼叫的,但是如果沒有呼叫則可能導致Context沒有被取消繼而其關聯的上下文資源也得不到釋放。

在使用WithValue時,包應該將鍵定義為未匯出的型別以避免發生碰撞,這裡貼個官網的例子:

// package user 這裡為了演示直接在 main 包定義
// User 是儲存在 Context 值
type User struct {
Name string
Age int
}


// key 是非匯出的,可以防止碰撞
type key int


// userKey 是儲存 User 型別的鍵值,也是非匯出的。
var userKey key


// NewContext 建立一個新的 Context,攜帶 *User
func NewContext(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, userKey, u)
}


// FromContext 返回儲存在 ctx 中的 *User
func FromContext(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}

那怎麼能夠防止碰撞呢?可以做個示例:看最後輸出,我們在第一行就用 userKey的值0,儲存了一個值“a”。

然後再利用NewContext儲存了&User,底層實際用的是 context.WithValue(ctx,userKey,u)

讀取時用的是FromContext,兩次儲存即使底層的key值都為0, 但是互不影響,這是為什麼呢?

還記得WithValue怎麼實現的麼?你每呼叫一次都會包一層,並且一層一層解析,而且它會比較c.key==key,這裡記住go的==比較是比較值和型別的,二者都相等才為true,而我們使用type key int所以userKey與0底層值雖然一樣,但是型別已經不一樣了(這裡就是main.userKey與0),所以外部無論定義何種型別都無法影響包內的型別。這也是容易令人迷惑的地方

package main


import (
"context"
"fmt"
)


func main() {
ctx := context.WithValue(context.Background(), , "a")
ctx = NewContext(ctx, &User{})
v, _ := FromContext(ctx)
fmt.Println(ctx.Value(0), v) // Output: a, &{ 0}
}

作者簡介

陳雪鋒

騰訊後端開發工程師

騰訊後端開發工程師,有多年golang以及雲原生開發經驗,對雲原生、容器排程、監控系統、API閘道器也多有涉獵。

推薦閱讀

深入淺出帶你走進Redis!

揭祕KVM年度核心技術突破的背後原理!

避坑指南!如何在TKE上安裝KubeSphere?

一站式DevOps真的能提速增效嗎?TVP吐槽大會邀您來驗證!

9 月 24 日 CODING DevOps 專題 TVP 吐槽大會火爆開啟 ,一同見證領域大咖巔峰對決!

掃碼立即參會贏好禮:point_down:

:point_down: 點選 「閱讀原文」 註冊成為社 區創作者,認識大咖,打造你的技術影響力!