Golang 如何正確使用 Context

視頻信息

How to correctly use package context
by Jack Lindamood
at Golang UK Conf. 2017

視頻:

https://www.youtube.com/watch?v=-_B5uQ4UGi0


博文:

https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39

爲什麼需要 Context

  • 每一個長請求都應該有個超時限制

  • 需要在調用中傳遞這個超時

    • 比如開始處理請求的時候我們說是 3 秒鐘超時

    • 那麼在函數調用中間,這個超時還剩多少時間了?

    • 需要在什麼地方存儲這個信息,這樣請求處理中間可以停止

如果進一步考慮。

640?wx_fmt=png

如上圖這樣的 RPC 調用,開始調用 RPC 1 後,裏面分別調用了 RPC 2, RPC 3, RPC 4,等所有 RPC 用成功後,返回結果。

這是正常的方式,但是如果 RPC 2 調用失敗了會發生什麼?

640?wx_fmt=png

RPC 2 失敗後,如果沒有 Context 的存在,那麼我們可能依舊會等所有的 RPC 執行完畢,但是由於 RPC 2 敗了,所以其實其它的 RPC 結果意義不大了,我們依舊需要給用戶返回錯誤。因此我們白白的浪費了 10ms,完全沒必要去等待其它 RPC 執行完畢。

那如果我們在 RPC 2 失敗後,就直接給用戶返回失敗呢?

640?wx_fmt=png

用戶是在 30ms 的位置收到了錯誤消息,可是 RPC 3 和 RPC 4 依然在沒意義的運行,還在浪費計算和IO資源。

640?wx_fmt=png

所以理想狀態應該是如上圖,當 RPC 2 出錯後,除了返回用戶錯誤信息外,我們也應該有某種方式可以通知 RPC 3 和 RPC 4,讓他們也停止運行,不再浪費資源。

所以解決方案就是:

  • 用信號的方式來通知請求該停了

  • 包含一些關於什麼時間請求可能會結束的提示(超時)

  • 用 channel 來通知請求結束了

那乾脆讓我們把變量也扔那吧。?

  • 在 Go 中沒有線程/go routine 變量

    • 其實挺合理的,因爲這樣就會讓 goroutine 互相產生依賴

  • 非常容易被濫用

Context 實現細節

context.Context:

  • 是不可變的(immutable)樹節點

  • Cancel 一個節點,會連帶 Cancel 其所有子節點 (從上到下

  • Context values 是一個節點

  • Value 查找是回溯樹的方式 (從下到上

示例 Context 鏈

完整代碼:https://play.golang.org/p/ddpofBV1QS

1
2
3
4
5
6
7
8
9
package main
func tree() {
ctx1 := context.Background()
ctx2, _ := context.WithCancel(ctx1)
ctx3, _ := context.WithTimeout(ctx2, time.Second * 5)
ctx4, _ := context.WithTimeout(ctx3, time.Second * 3)
ctx5, _ := context.WithTimeout(ctx3, time.Second * 6)
ctx6 := context.WithValue(ctx5, "userID", 12)
}

如果這樣構成的 Context 鏈,其形如下圖:

640?wx_fmt=png

那麼當 3 秒超時到了時候:

640?wx_fmt=png

可以看到 ctx4 超時退出了。

當 5秒鐘 超時到達時:

640?wx_fmt=png

可以看到,不僅僅 ctx3 退出了,其所有子節點,比如 ctx5 和 ctx6 也都退出了。

context.Context API

基本上是兩類操作:

  • 3個函數用於限定什麼時候你的子節點退出

  • 1個函數用於設置請求範疇的變量

    1
    2
    3
    4
    5
    6
    7
    8
    type Context interface {
    // 啥時候退出
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    // 設置變量
    Value(key interface{}) interface{}
    }

什麼時候應該使用 Context?

  • 每一個 RPC 調用都應該有超時退出的能力,這是比較合理的 API 設計

  • 不僅僅 是超時,你還需要有能力去結束那些不再需要操作的行爲

  • context.Context 是 Go 標準的解決方案

  • 任何函數可能被阻塞,或者需要很長時間來完成的,都應該有個 context.Context

如何創建 Context?

  • 在 RPC 開始的時候,使用 context.Background()

    • 有些人把在 main() 裏記錄一個 context.Background(),然後把這個放到服務器的某個變量裏,然後請求來了後從這個變量裏繼承 context。這麼做是不對的。直接每個請求,源自自己的 context.Background() 即可。

  • 如果你沒有 context,卻需要調用一個 context 的函數的話,用 context.TODO()

  • 如果某步操作需要自己的超時設置的話,給它一個獨立的 sub-context(如前面的例子)

如何集成到 API 裏?

  • 如果有 Context,將其作爲第一個變量

    • 如 func (d* Dialer) DialContext(ctx context.Context, network, address string) (Conn, error)

    • 有些人把 context 放到中間的某個變量裏去,這很不合習慣,不要那麼做,放到第一個去。

  • 將其作爲可選的方式,用 request 結構體方式。

    • 如:func (r *Request) WithContext(ctx context.Context) *Request

  • Context 的變量名請用 ctx(不要起一些詭異的名字?)

Context 放哪?

  • 把 Context 想象爲一條河流流過你的程序(另一個意思就是說不要喝河裏的水……?)

  • 理想情況下,Context 存在於調用棧(Call Stack) 中

  • 不要把 Context 存儲到一個 struct 裏

    • 除非你使用的是像 http.Request 中的 request 結構體的方式

  • request 結構體應該以 Request 結束爲生命終止

  • 當 RPC 請求處理結束後,應該去掉對 Context 變量的引用(Unreference)

  • Request 結束,Context 就應該結束。(這倆是一對兒,不求同年同月同日生,但求同年同月同日死……?)

Context 包的注意事項

  • 要養成關閉 Context 的習慣

    • 特別是 超時的 Contexts

  • 如果一個 context 被 GC 而不是 cancel 了,那一般是你做錯了

    1
    2
    ctx, cancel := context.WithTimeout(parentCtx, time.Second * 2)
    defer cancel()
  • 使用 Timeout 會導致內部使用 time.AfterFunc,從而會導致 context 在計時器到時之前都不會被垃圾回收。

  • 在建立之後,立即 defer cancel() 是一個好習慣。

終止請求 (Request Cancellation)

當你不再關心接下來獲取的結果的時候,有可能會 Cancel 一個 Context?

以 golang.org/x/sync/errgroup 爲例,errgroup 使用 Context 來提供 RPC 的終止行爲。

1
2
3
4
5
6
type Group struct {
cancel func()
wg sync.WaitGroup
errOnce sync.Once
err error
}

創建一個 group 和 context:

1
2
3
4
func WithContext(ctx context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancel(ctx)
return &Group{cancel: cancel}, ctx
}

這樣就返回了一個可以被提前 cancel 的 group。

而調用的時候,並不是直接調用 go func(),而是調用 Go(),將函數作爲參數傳進去,用高階函數的形式來調用,其內部纔是 go func() 開啓 goroutine。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (g *Group) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := f(); err != nil {
g.errOnce.Do(func() {
g.err = err
if g.cancel != nil {
g.cancel()
}
})
}
}()
}

當給入函數 f 返回錯誤,則使用 sync.Once 來 cancel context,而錯誤被保存於 g.err 之中,在隨後的 Wait() 函數中返回。

1
2
3
4
5
6
7
func (g *Group) Wait() error {
g.wg.Wait()
if g.cancel != nil {
g.cancel()
}
return g.err
}

注意:這裏在 Wait() 結束後,調用了一次 cancel()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main
func DoTwoRequestsAtOnce(ctx context.Context) error {
eg, egCtx := errgroup.WithContext(ctx)
var resp1, resp2 *http.Response
f := func(loc string, respIn **http.Response) func() error {
return func() error {
reqCtx, cancel := context.WithTimeout(egCtx, time.Second)
defer cancel()
req, _ := http.NewRequest("GET", loc, nil)
var err error
*respIn, err = http.DefaultClient.Do(req.WithContext(reqCtx))
if err == nil && (*respIn).StatusCode >= 500 {
return errors.New("unexpected!")
}
return err
}
}
eg.Go(f("http://localhost:8080/fast_request", &resp1))
eg.Go(f("http://localhost:8080/slow_request", &resp2))
return eg.Wait()
}

在這個例子中,同時發起了兩個 RPC 調用,當任何一個調用超時或者出錯後,會終止另一個 RPC 調用。這裏就是利用前面講到的 errgroup 來實現的,應對有很多並非請求,並需要集中處理超時、出錯終止其它併發任務的時候,這個 pattern 使用起來很方便。

Context.Value - Request 範疇的值

context.Value API 的萬金油(duct tape)

膠帶(duct tape) 幾乎可以修任何東西,從破箱子,到人的傷口,到汽車引擎,甚至到NASA登月任務中的阿波羅13號飛船(Yeah! True Story)。所以在西方文化裏,膠帶是個“萬能”的東西。在中文裏,恐怕萬金油是更合適的對應詞彙,從頭疼、腦熱,感冒發燒,到跌打損傷幾乎無所不治。

當然,治標不治本,這點東西方文化中的潛臺詞都是一樣的。這裏提及的 context.Value 對於 API 而言,就是這類性質的東西,啥都可以幹,但是治標不治本。

  • value 節點是 Context 鏈中的一個節點

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    package context
    type valueCtx struct {
    Context
    key, val interface{}
    }
    func WithValue(parent Context, key, val interface{}) Context {
    // ...
    return &valueCtx{parent, key, val}
    }
    func (c *valueCtx) Value(key interface{}) interface{} {
    if c.key == key {
    return c.val
    }
    return c.Context.Value(key)
    }

可以看到,WithValue() 實際上就是在 Context 樹形結構中,增加一個節點罷了。

Context 是 immutable 的。

約束 key 的空間

爲了防止樹形結構中出現重複的鍵,建議約束鍵的空間。比如使用私有類型,然後用 GetXxx() 和 WithXxxx() 來操作私有實體。

1
2
3
4
5
6
7
8
9
10
11
type privateCtxType string
var (
reqID = privateCtxType("req-id")
)
func GetRequestID(ctx context.Context) (int, bool) {
id, exists := ctx.Value(reqID).(int)
return id, exists
}
func WithRequestID(ctx context.Context, reqid int) context.Context {
return context.WithValue(ctx, reqID, reqid)
}

這裏使用 WithXxx 而不是 SetXxx 也是因爲 Context 實際上是 immutable 的,所以不是修改 Context 裏某個值,而是產生新的 Context 帶某個值

Context.Value 是 immutable 的

再多次的強調 Context.Value 是 immutable 的也不過分。

  • context.Context 從設計上就是按照 immutable (不可變的)模式設計的

  • 同樣,Context.Value 也是 immutable 的

  • 不要試圖在 Context.Value 裏存某個可變更的值,然後改變,期望別的 Context 可以看到這個改變

    • 更別指望着在 Context.Value 裏存可變的值,最後多個 goroutine 併發訪問沒競爭冒險啥的,因爲自始至終,就是按照不可變來設計的

    • 比如設置了超時,就別以爲可以改變這個設置的超時值

  • 在使用 Context.Value 的時候,一定要記住這一點

應該把什麼放到 Context.Value 裏?

  • 應該保存 Request 範疇的值

    • 任何關於 Context 自身的都是 Request 範疇的(這倆同生共死)

    • 從 Request 數據衍生出來,並且隨着 Request 的結束而終結

什麼東西不屬於 Request 範疇?

  • 在 Request 以外建立的,並且不隨着 Request 改變而變化

    • 比如你 func main() 裏建立的東西顯然不屬於 Request 範疇

  • 數據庫連接

    • 如果 User ID 在連接裏呢?(稍後會提及)

  • 全局 logger

    • 如果 logger 裏需要有 User ID 呢?(稍後會提及)

那麼用 Context.Value 有什麼問題?

  • 不幸的是,好像所有東西都是由請求衍生出來的

  • 那麼我們爲什麼還需要函數參數?然後乾脆只來一個 Context 就完了?

    1
    2
    3
    func Add(ctx context.Context) int {
    return ctx.Value("first").(int) + ctx.Value("second").(int)
    }

曾經看到過一個 API,就是這種形式:

1
2
3
4
func IsAdminUser(ctx context.Context) bool {
userID := GetUser(ctx)
return authSingleton.IsAdmin(userID)
}

這裏API實現內部從 context 中取得 UserID,然後再進行權限判斷。但是從函數簽名看,則完全無法理解這個函數具體需要什麼、以及做什麼。

代碼要以可讀性爲優先設計考慮。

別人拿到一個代碼,一般不是掉進函數實現細節裏去一行行的讀代碼,而是會先瀏覽一下函數接口。所以清晰的函數接口設計,會更加利於別人(或者是幾個月後的你自己)理解這段代碼。

一個良好的 API 設計,應該從函數簽名就清晰的理解函數的邏輯。如果我們將上面的接口改爲:

1
func IsAdminUser(ctx context.Context, userID string, authenticator auth.Service) bool

我們從這個函數簽名就可以清楚的知道:

  • 這個函數很可能可以提前被 cancel

  • 這個函數需要 User ID

  • 這個函數需要一個authenticator來

  • 而且由於 authenticator 是傳入參數,而不是依賴於隱式的某個東西,我們知道,測試的時候就很容易傳入一個模擬認證函數來做測試

  • userID 是傳入值,因此我們可以修改它,不用擔心影響別的東西

所有這些信息,都是從函數簽名得到的,而無需打開函數實現一行行去看。

那什麼可以放到 Context.Value 裏去?

現在知道 Context.Value 會讓接口定義更加模糊,似乎不應該使用。那麼又回到了原來的問題,到底什麼可以放到 Context.Value 裏去?換個角度去想,什麼不是衍生於 Request?

  • Context.Value 應該是告知性質的東西,而不是控制性質的東西

  • 應該永遠都不需要寫進文檔作爲必須存在的輸入數據

  • 如果你發現你的函數在某些 Context.Value 下無法正確工作,那就說明這個 Context.Value 裏的信息不應該放在裏面,而應該放在接口上。因爲已經讓接口太模糊了。

什麼東西不是控制性質的東西?

  • Request ID

    • 而 logger 本身不是 Request 範疇,所以 logger 不應該在 Context 裏

    • 非 Request 範疇的 logger 應該只是利用 Context 信息來修飾日誌

    • 只是給每個 RPC 調用一個 ID,而沒有實際意義

    • 這就是個數字/字符串,反正你也不會用其作爲邏輯判斷

    • 一般也就是日誌的時候需要記錄一下

  • User ID (如果僅僅是作爲日誌用)

  • Incoming Request ID

什麼顯然是控制性質的東西?

  • 數據庫連接

    • 顯然會非常嚴重的影響邏輯

    • 因此這應該在函數參數裏,明確表示出來

  • 認證服務(Authentication)

    • 顯然不同的認證服務導致的邏輯不同

    • 也應該放到函數參數裏,明確表示出來

例子

調試性質的 Context.Value - net/http/httptrace

https://medium.com/@cep21/go-1-7-httptrace-and-context-debug-patterns-608ae887224a

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main
func trace(req *http.Request, c *http.Client) {
trace := &httptrace.ClientTrace{
GotConn: func(connInfo httptrace.GotConnInfo) {
fmt.Println("Got Conn")
},
ConnectStart: func(network, addr string) {
fmt.Println("Dial Start")
},
ConnectDone: func(network, addr string, err error) {
fmt.Println("Dial done")
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
c.Do(req)
}

net/http 是怎麼使用 httptrace 的?

  • 如果有 trace 存在的話,就執行 trace 回調函數

  • 這只是告知性質,而不是控制性質

    • http 不會因爲存在 trace 與否就有不同的執行邏輯

    • 這裏只是告知 API 的用戶,幫助用戶記錄日誌或者調試

    • 因此這裏的 trace 是存在於 Context 裏的

      1
      2
      3
      4
      5
      6
      7
      8
      9
      package http
      func (req *Request) write(w io.Writer, usingProxy bool, extraHeaders Header, waitForContinue func() bool) (err error) {
      // ...
      trace := httptrace.ContextClientTrace(req.Context())
      // ...
      if trace != nil && trace.WroteHeaders != nil {
      trace.WroteHeaders()
      }
      }

迴避依賴注入 - github.com/golang/oauth2

  • 這裏比較詭異,使用 ctx.Value 來定位依賴

  • 不推薦這樣做

    • 這裏這樣做基本上只是爲了滿足測試需求

      1
      2
      3
      4
      5
      6
      7
      8
      package main
      import "github.com/golang/oauth2"
      func oauth() {
      c := &http.Client{Transport: &mockTransport{}}
      ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c)
      conf := &oauth2.Config{ /* ... */ }
      conf.Exchange(ctx, "code")
      }

人們濫用 Context.Value 的原因

  • 中間件的抽象

  • 很深的函數調用棧

  • 混亂的設計

context.Value 並沒有讓你的 API 更簡潔,那是假象,相反,它讓你的 API 定義更加模糊。

總結 Context.Value

  • 對於調試非常方便

  • 將必須的信息放入 Context.Value 中,會讓接口定義更加不透明

  • 如果可以儘量明確定義在接口

  • 儘量不要用 Context.Value

總結 Context

  • 所有的長的、阻塞的操作都需要 Context

  • errgroup 是構架於 Context 之上很好的抽象

  • 當 Request 的結束的時候,Cancel Context

  • Context.Value 應該被用於告知性質的事物,而不是控制性質的事物

  • 約束 Context.Value 的鍵空間

  • Context 以及 Context.Value 應該是不可變的(immutable),並且應該是線程安全

  • Context 應該隨 Request 消亡而消亡

Q&A

數據庫的訪問也用 Context 麼?

之前說過長時間、可阻塞的操作都用 Context,數據庫操作也是如此。不過對於超時 Cancel 操作來說,一般不會對寫操作進行 cancel;但是對於讀操作,一般會有 Cancel 操作。

原文

https://blog.lab99.org/post/golang-2017-10-27-video-how-to-correctly-use-package-context.html

< END >

喜歡就點個在看 or 轉發個朋友圈唄

            640?wx_fmt=jpeg

衣舞晨風


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章