Go語言Context(上下文)

2020-07-16 10:05:15
Context 在 Go1.7 之後就加入到了Go語言標準庫中,準確說它是 Goroutine 的上下文,包含 Goroutine 的執行狀態、環境、現場等資訊。

隨著 Context 包的引入,標準庫中很多介面因此加上了 Context 引數,例如 database/sql 包,Context 幾乎成為了並行控制和超時控制的標準做法。

什麼是 Context

Context 也叫作“上下文”,是一個比較抽象的概念,一般理解為程式單元的一個執行狀態、現場、快照。其中上下是指存在上下層的傳遞,上會把內容傳遞給下,程式單元則指的是 Goroutine。

每個 Goroutine 在執行之前,都要先知道程式當前的執行狀態,通常將這些執行狀態封裝在一個 Context 變數中,傳遞給要執行的 Goroutine 中。

在網路程式設計下,當接收到一個網路請求 Request,在處理 Request 時,我們可能需要開啟不同的 Goroutine 來獲取資料與邏輯處理,即一個請求 Request,會在多個 Goroutine 中處理。而這些 Goroutine 可能需要共用 Request 的一些資訊,同時當 Request 被取消或者超時的時候,所有從這個 Request 建立的所有 Goroutine 也應該被結束。

Context 介面

Context 包的核心就是 Context 介面,其定義如下:
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
其中:
  • Deadline 方法需要返回當前 Context 被取消的時間,也就是完成工作的截止時間(deadline);
  • Done 方法需要返回一個 Channel,這個 Channel 會在當前工作完成或者上下文被取消之後關閉,多次呼叫 Done 方法會返回同一個Channel;
  • Err 方法會返回當前 Context 結束的原因,它只會在 Done 返回的 Channel 被關閉時才會返回非空的值:
  • 如果當前 Context 被取消就會返回 Canceled 錯誤;
  • 如果當前 Context 超時就會返回 DeadlineExceeded 錯誤;
  • Value 方法會從 Context 中返回鍵對應的值,對於同一個上下文來說,多次呼叫 Value 並傳入相同的 Key 會返回相同的結果,該方法僅用於傳遞跨 API 和進程間跟請求域的資料。

Background()和TODO()

Go語言內建兩個函數:Background() 和 TODO(),這兩個函數分別返回一個實現了 Context 介面的 background 和 todo。

Background() 主要用於 main 函數、初始化以及測試程式碼中,作為 Context 這個樹結構的最頂層的 Context,也就是根 Context。

TODO(),它目前還不知道具體的使用場景,在不知道該使用什麼 Context 的時候,可以使用這個。

background 和 todo 本質上都是 emptyCtx 結構體型別,是一個不可取消,沒有設定截止時間,沒有攜帶任何值的 Context。

With 系列函數

此外,Context 包中還定義了四個 With 系列函數。

WithCancel

WithCancel 的函數簽名如下:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

WithCancel 返回帶有新 Done 通道的父節點的副本,當呼叫返回的 cancel 函數或當關閉父上下文的 Done 通道時,將關閉返回上下文的 Done 通道,無論先發生什麼情況。

取消此上下文將釋放與其關聯的資源,因此程式碼應該在此上下文中執行的操作完成後立即呼叫 cancel,範例程式碼如下:
package main

import (
    "context"
    "fmt"
)

func main() {
    gen := func(ctx context.Context) <-chan int {
        dst := make(chan int)
        n := 1
        go func() {
            for {
                select {
                case <-ctx.Done():
                    return // return結束該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 {
            break
        }
    }
}
上面的程式碼中,gen 函數在單獨的 Goroutine 中生成整數並將它們傳送到返回的通道,gen 的呼叫者在使用生成的整數之後需要取消上下文,以免 gen 啟動的內部 Goroutine 發生洩漏。

執行結果如下:

go run main.go
1
2
3
4
5

WithDeadline

WithDeadline 的函數簽名如下:

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

WithDeadline 函數會返回父上下文的副本,並將 deadline 調整為不遲於 d。如果父上下文的 deadline 已經早於 d,則 WithDeadline(parent, d) 在語意上等同於父上下文。當截止日過期時,當呼叫返回的 cancel 函數時,或者當父上下文的 Done 通道關閉時,返回上下文的 Done 通道將被關閉,以最先發生的情況為準。

取消此上下文將釋放與其關聯的資源,因此程式碼應該在此上下文中執行的操作完成後立即呼叫 cancel,範例程式碼如下:
package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    d := time.Now().Add(50 * time.Millisecond)
    ctx, cancel := context.WithDeadline(context.Background(), d)

    // 儘管ctx會過期,但在任何情況下呼叫它的cancel函數都是很好的實踐。
    // 如果不這樣做,可能會使上下文及其父類別存活的時間超過必要的時間。
    defer cancel()

    select {
    case <-time.After(1 * time.Second):
        fmt.Println("overslept")
    case <-ctx.Done():
        fmt.Println(ctx.Err())
    }
}
執行結果如下:

go run main.go
context deadline exceeded

上面的程式碼中,定義了一個 50 毫秒之後過期的 deadline,然後我們呼叫 context.WithDeadline(context.Background(), d) 得到一個上下文(ctx)和一個取消函數(cancel),然後使用一個 select 讓主程式陷入等待,等待 1 秒後列印 overslept 退出或者等待 ctx 過期後退出。因為 ctx 50 秒後就過期,所以 ctx.Done() 會先接收到值,然後列印 ctx.Err() 取消原因。

WithTimeout

WithTimeout 的函數簽名如下:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeout 函數返回 WithDeadline(parent, time.Now().Add(timeout))。

取消此上下文將釋放與其相關的資源,因此程式碼應該在此上下文中執行的操作完成後立即呼叫 cancel,範例程式碼如下:
package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // 傳遞帶有超時的上下文
    // 告訴阻塞函數在超時結束後應該放棄其工作。
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()

    select {
    case <-time.After(1 * time.Second):
        fmt.Println("overslept")
    case <-ctx.Done():
        fmt.Println(ctx.Err()) // 終端輸出"context deadline exceeded"
    }
}
執行結果如下:

go run main.go
context deadline exceeded

WithValue

WithValue 函數能夠將請求作用域的資料與 Context 物件建立關係。函數宣告如下:

func WithValue(parent Context, key, val interface{}) Context

WithValue 函數接收 context 並返回派生的 context,其中值 val 與 key 關聯,並通過 context 樹與 context 一起傳遞。這意味著一旦獲得帶有值的 context,從中派生的任何 context 都會獲得此值。不建議使用 context 值傳遞關鍵引數,函數應接收簽名中的那些值,使其顯式化。

所提供的鍵必須是可比較的,並且不應該是 string 型別或任何其他內建型別,以避免使用上下文在包之間發生衝突。WithValue 的使用者應該為鍵定義自己的型別,為了避免在分配給介面{ } 時進行分配,上下文鍵通常具有具體型別 struct{}。或者,匯出的上下文關鍵變數的靜態型別應該是指標或介面。
package main

import (
    "context"
    "fmt"
)

func main() {
    type favContextKey string // 定義一個key型別
    // f:一個從上下文中根據key取value的函數
    f := func(ctx context.Context, k favContextKey) {
        if v := ctx.Value(k); v != nil {
            fmt.Println("found value:", v)
            return
        }
        fmt.Println("key not found:", k)
    }
    k := favContextKey("language")
    // 建立一個攜帶key為k,value為"Go"的上下文
    ctx := context.WithValue(context.Background(), k, "Go")

    f(ctx, k)
    f(ctx, favContextKey("color"))
}
執行結果如下:

go run main.go
found value: Go
key not found: color

使用 Context 的注意事項:
  • 不要把 Context 放在結構體中,要以引數的方式顯示傳遞;
  • 以 Context 作為引數的函數方法,應該把 Context 作為第一個引數;
  • 給一個函數方法傳遞 Context 的時候,不要傳遞 nil,如果不知道傳遞什麼,就使用 context.TODO;
  • Context 的 Value 相關方法應該傳遞請求域的必要資料,不應該用於傳遞可選引數;
  • Context 是執行緒安全的,可以放心的在多個 Goroutine 中傳遞。

總結

Go語言中的 Context 的主要作用還是在多個 Goroutine 或者模組之間同步取消信號或者截止日期,用於減少對資源的消耗和長時間佔用,避免資源浪費,雖然傳值也是它的功能之一,但是這個功能我們還是很少用到。

在真正使用傳值的功能時我們也應該非常謹慎,不能將請求的所有引數都使用 Context 進行傳遞,這是一種非常差的設計,比較常見的使用場景是傳遞請求對應使用者的認證令牌以及用於進行分散式追蹤的請求 ID。