深入解釋了Go context使用方式

banq發表於2024-06-15


這篇文章詳細介紹了Go 語言中context 函式背後的實現細節和程式碼,幫助開發人員瞭解上下文包的底層工作原理。


我們來看一個使用 context 包的簡單示例:該函式接受一個上下文並將其傳遞給另一個函式,因為對於大多數人來說,這就是上下文的全部,只是在函式需要時傳遞的東西。

func main() {
    bigFunc(context.Background())
}

func bigFunc(ctx context.Context) {
    smallFunc(ctx)
}

func smallFunc(ctx context.Context) {
    // I don't know what to do with it, let' just print it
    fmt.Println(ctx)
}

將列印出 context.Background。這是因為 context.Background 返回的內容滿足 Stringer 介面的要求,而 Stringer 介面在呼叫 String.Background 時只會返回該內容。

context介面
讓我們從最基本的開始。我們使用的 context.Context 型別是一個介面,下面是它的定義。

type Context interface {
    Deadline() (deadline time.Time, ok bool) // 趕時間
    Done() <-chan struct{}                   // 獲取取消時關閉的通道
    Err() error                              // 如果 Done 通道已關閉,則返回非零
    Value(key any) any                       // 從上下文儲存中獲取值
}

任何滿足此介面的結構都是有效的上下文物件。如果註釋中沒有說明,讓我們快速瞭解一下它們分別是什麼。

  • Deadline:該函式返回設定為截止日期的時間,例如,如果上下文是使用 context.WithDeadline 建立的。
  • Done:該函式返回一個在取消上下文時關閉的通道。
  • Err:如果取消已發生,則返回非零。
  • Value:值:該函式用於獲取儲存在上下文例項中的值。

如果你想建立一個 "Context",這些就是你所需要的,而且你可以很容易地建立它們。儘管如此,stdlib 還是為我們提供了一些有用的 "上下文"。

emptyCtx 結構
這是一個結構體,滿足成為 Context 的最基本要求。程式碼如下

type emptyCtx struct{}

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
}

如您所見,它什麼也不做,但這就是 context.Background 和 context.TODO 中的主要內容。

context.Background 和 context.TODO
這兩種方法都只是 emptyCtx 加上一個 String 方法,以滿足 Stringer 介面的要求。它們提供了一種建立空基礎上下文的方法。它們之間唯一的區別就是名稱不同。

  • 當你知道需要一個空上下文時,比如在剛剛開始執行的 main 中,你可以使用 context.Background;
  • 當你不知道使用什麼上下文或還沒有接好線時,你可以使用 context.TODO。

您可以將 context.TODO 視為類似於在程式碼中新增 // TODO 註釋。

 context.Background:

type backgroundCtx struct{ emptyCtx }

func (backgroundCtx) String() string {
    return "context.Background"
}

func Background() Context {
    return backgroundCtx{}
}

 context.TODO:

type todoCtx struct{ emptyCtx }

func (todoCtx) String() string {
    return "context.TODO"
}

func TODO() Context {
    return todoCtx{}
}


context.WithValue
現在,我們將進入 context 軟體包的更多實用案例。如果想使用 context 傳遞一個值,可以使用 context.WithValue。您可能見過日誌或網路框架使用這種方法。

讓我們看看它的內部結構:

type valueCtx struct {
    Context
    key, val any
}

func WithValue(parent Context, key, val any) Context {
    return &valueCtx{parent, key, val}
}

它只是返回一個包含父上下文、鍵和值的結構體。

如果你注意到,該例項只能包含一個鍵和一個值,但你可能在網路框架中看到過,它們會從 ctx 引數中提取多個值。由於我們將父上下文嵌入到了較新的上下文中,因此可以向上遞迴搜尋以獲取其他任何值。

bgCtx := context.Background()
v1Ctx := context.WithValue(bgCtx, "one", "uno")
v2Ctx := context.WithValue(v1Ctx, "two", "dos")

現在,如果我們要從 v2Ctx 中獲取 "one "的值,可以呼叫 v2Ctx.Value("one")。
這將首先檢查 v2Ctx 中的鍵是否為 "one",
如果不是,則將檢查父節點(v1Ctx)的鍵是否為 "one"。
既然 v1Ctx 中的鍵是 "one",我們就返回上下文中的值。

下面程式碼遞迴搜尋父上下文,檢視其中是否有匹配鍵的上下文,然後返回其值。

func (c *valueCtx) Value(key any) any {
    // If it this one, just return it
    if c.key == key {
        return c.val
    }
    return value(c.Context, key)
}

func value(c Context, key any) any {
    for {
        switch ctx := c.(type) {
        case *valueCtx:
            // 如果父節點是`valueCtx`,則檢查其鍵
            if key == ctx.key {
                return ctx.val
            }
            c = ctx.Context
        case backgroundCtx, todoCtx:
            // 如果我們已到達頂部,即基本上下文
          // 返回,因為我們什麼也沒找到。
            return nil
        default:
           如果是其他上下文,
        //只需呼叫其`.Value`方法。
            return c.Value(key)
        }
    }
}

context.WithCancel
讓我們來看看更有用的東西。您可以使用上下文包建立一個 ctx,用來向下遊函式發出取消訊號。

讓我們來看一個如何使用的示例:

func doWork(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
        }

        time.Sleep(1 * time.Second)
        fmt.Println("doing work...")
    }
}

func main() {
    bgCtx := context.Background()
    innerCtx, cancel := context.WithCancel(bgCtx)

    go doWork(innerCtx) // call goroutine
    time.Sleep(3 * time.Second) // do work in main

    // well, if `doWork` is still not done, just cancel it
    cancel()
}

在這種情況下,您可以透過在主函式中呼叫 cancel 來向 doWork 函式發出停止工作的訊號。

現在來看看它是如何工作的。讓我們從函式定義開始(我們很快就會講到結構定義):

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    c := &cancelCtx{}
    c.propagateCancel(parent, c)
    return c, func() { c.cancel(true, Canceled, nil) }
}

當您呼叫 context.WithCancel 時,它會返回一個 cancelCtx 例項和一個函式,您可以呼叫該函式來取消上下文。由此我們可以推斷,cancelCtx 是一個具有取消函式的上下文,該函式可用來 "取消 "上下文。

如果你忘記了,取消上下文只是意味著關閉 Done() 返回的通道。

順便說一下,在此上下文中,propagateCancel 函式的主要作用是建立一個 cancelCtx,以及在建立之前確保父節點尚未被取消。

好了,現在讓我們來看看結構圖,之後我們將瞭解它是如何工作的(source).

type cancelCtx struct {
    Context

    mu       sync.Mutex            // protects following fields
    done     atomic.Value          // chan struct{} for Done
    children map[canceler]struct{}
    err      error
    cause    error
}

這裡:

  • Context:儲存父上下文
  • mu sync.Mutex:你知道這是什麼吧
  • done atomic.Value:儲存將由 Done() 函式返回的 chan struct{}
  • err error:儲存導致取消的錯誤資訊
  • cause error:儲存取消的原因,即取消函式的最後一個引數

對了,這是取消功能(source):

func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
    if cause == nil {
        cause = err
    }

    c.err = err
    c.cause = cause

    // 從 atomic.Value 中載入 chan struct{} 並關閉它
    d, _ := c.done.Load().(chan struct{})
    if d == nil {
        //如果不存在,則儲存一個封閉通道
        c.done.Store(closedchan)
    } else {
        // 如果存在,則關閉
        close(d)
    }

    // 對所有子代呼叫取消,以傳播取消資訊
    for child := range c.children {
        child.cancel(false, err, cause)
    }
    c.children = nil

    // 從父節點移除
    if removeFromParent {
        removeChild(c.Context, c)
    }
}

首先,我們在例項上設定原因和錯誤,然後關閉 Done 返回的通道。之後,它會取消所有子例項,最後將自己從父例項中移除。

Context.WithDeadline 和context.WithTimeout 
當您想建立一個在到達截止日期時自動取消的上下文時,這些功能就非常有用。這對於執行伺服器超時等操作非常有用。

首先,context.WithTimeout 只是計算截止時間並呼叫 context.WithDeadline。事實上,這就是它的全部程式碼:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

現在進入細節部分。有些人可能已經猜到,WithDeadline 基本上就是一個普通的 WithCancel 上下文,只不過是由上下文包來處理取消。

讓我們看看程式碼的作用。這裡有函式 WithDeadlineCause 的程式碼,它是 WithDeadline 的一個變體,但增加了傳遞取消的 "原因 "的功能。順便提一下,其他上下文包函式也可以使用 Cause 變體,而像 WithDeadline 這樣的非 Cause 變體只是在呼叫 Cause 變體時將 cause 設為 nil。

func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        // 如果父節點的截止時間早於子節點,
        // 只需返回一個 cancelCtx
        return WithCancel(parent)
    }

    // create a new timerCtx
    c := &timerCtx{
        deadline: d,
    }
    c.cancelCtx.propagateCancel(parent, c)

    dur := time.Until(d)
    if dur <= 0 {
        c.cancel(true, DeadlineExceeded, cause) // deadline has already passed
        return c, func() { c.cancel(false, Canceled, nil) }
    }

    // 如果一切正常,則設定新的計時器,並返回取消函式
    if c.err == nil {
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded, cause)
        })
    }
    return c, func() { c.cancel(true, Canceled, nil) }
}

  • 如果父節點的截止日期早於子節點,則返回從父節點建立的簡單 cancelCtx
  • 如果不是,則建立一個新的 timeCtx(結構定義如下)
  • 現在檢查是否已超過截止時間,如果是,則取消已建立的上下文並返回
  • 如果沒有,我們將使用 time.AfterFunc 設定一個計時器來執行取消操作,然後返回

timerCtx 只是一個帶有計時器的 cancelCtx:

type timerCtx struct {
    cancelCtx
    timer *time.Timer
    deadline time.Time
}

 

相關文章