深入理解 Go Context
什么是 Context
Context 的最常見但也是最不準確的翻譯是 ‘上下文’(因為程序里通常只需要上文),其實譯為 ‘語境’ 更為合適,意思是當前說話的環境。最直觀的作用是提供一些必要的信息:
...
唐僧:“悟空~”
question:唐僧的“悟空” 表達了怎樣的心理?
answer:。。。去你的
Context 的概念本身比較寬泛,從系統角度說,線程/進程 的切換時,需要先保存當前寄存器和棧指針,然后載入下一個 進程/線程 需要的寄存器和棧。寄存器和棧就是進程/線程的 Context。
在不同編程語言中,也有不同體現:
如 c 語言的 errno (摘自某呼):
注意過 errno 這個全局變量的朋友會發現,這個全局變量其實有可能不是一個真正的變量.它返回了一個本地線程的存儲空間.它實際上是每個線程有一份.這里,其實 C 語言運行時已經悄悄變成了多份,而對應當前線程的實例用本地線程保存,它就是一個 context
又例如 Javascript 在瀏覽器中運行就有瀏覽器作為環境提供 window 對象,而在 node.js 環境下面運行就沒有 window 對象。
照此看來,Context 好像就是一個 ‘全局變量’,那為什么不直接聲明全局變量,非要用 Context 這個生澀的概念呢?
- 在軟件工程中,對全局變量基本持否定態度,一是是代碼變得耦合,二是暴露了多余的信息,三是全局變量在多線程環境下使用鎖浪費 CPU 資源。不過它有很好的效果:間接的提升了某些變量的作用域,保證了這些數據的生命周期
- 于是出現了 不那么全局的全局變量 ,例如 線程局部 的全局變量(可以做到線程安全)或者 包局部 的全局變量。很多語言的 this ,其實也是如此。
- 另外還有匿名形式的 閉包 局部的全局變量
再結合輪子哥說的:
每一段程序都有很多外部變量。只有像 Add 這種簡單的函數才是沒有外部變量的。一旦你的一段程序有了外部變量,這段程序就不完整,不能獨立運行。你為了使他們運行,就要給所有的外部變量一個一個寫一些值進去。這些值的集合就叫 Context
那么我們可以認為,Context 就是把一些信息打包聚合到一起,形成一個模塊交互的語境,各個模塊像傳遞包裹一樣取用它,而不是通過全局變量來訪問它。
Go 語言里的 Context
Context 的使用
Go 語言的 Context 在攜帶信息的基礎上,增加了非常實用的功能,設計也非常簡潔巧妙。標準庫提供了可攜帶 value 的 Context、可取消的 Context 和 可超時的 Context 。
攜帶 value 的 Context
前面提到 Context 最基本的作用是攜帶語境中的一些信息,比如一些參數。但是問題來了,所有參數都要放到 Context 嗎?哪些應該、哪些不應該?如果一個函數如下:
func a(key string, value interface, id int){
...
}
如果把參數全都放到 context:
func a(ctx context.Context){
...
}
前者我們可以一目了然的從函數簽名中獲取或猜出一些關于這個函數的大概信息,而后者只看函數簽名獲得不了什么信息,需要仔細的從代碼里讀。很明顯,前者可讀性更高。一個良好的 API 設計,應該從函數簽名就清晰的理解函數的邏輯。
使用 Context 攜帶參數會讓接口定義更加模糊。那么什么樣的信息應該放到 Context 里呢?官方注釋如下:
Use context values only for request-scoped data that transits processes and API boundaries, not for passing optional parameters to functions.
也就是說,應該保存 Request 范疇的值:
- 任何關于 Context 自身的都是 Request 范疇的(這倆同生共死)
- 從 Request 數據衍生出來,并且隨著 Request 的結束而終結
。。。好像這句話說了和沒說差不多?在處理請求的時候,難道不是所有的信息都來自 Request ?
其實通常來說, Context.Value 應該是 告知性質 的東西,而不是 控制性質 的東西。
哪些不是控制性質的?
- Request ID
- 只是給每個 RPC 調用一個 ID,而沒有實際意義
- 這就是個數字/字符串,反正你也不會用其作為邏輯判斷
- 一般也就是日志的時候需要記錄一下
- 而
logger
本身不是 Request 范疇,所以logger
不應該在Context
里 - 非 Request 范疇的
logger
應該只是利用Context
信息來修飾日志
- 而
- User ID ,比如可以在 jwt 中間件解析出 userID 然后帶在 Context 里再傳給 controller。
- Incoming Request ID
顯然是控制性質的:
- 數據庫連接
- 顯然會非常嚴重的影響邏輯
- 因此這應該在函數參數里,明確表示出來
- ...
關于 可攜帶 value 的 Context,還有一個值得注意的地方是:Context 本身是不可變的(immutable),讓一個 Context 攜帶新的參數并不是一個 “setter” 來修改 Context 值,而是通過“包含”的形式,生成一個新的 Context 包含原有 Context,形成鏈式結構。在下面實現的時候繼續討論。
可取消 和 可超時的 Context
為什么要取消(超時的本質也是取消,只不過通過計時器觸發取消操作)?
這和 Go 語言的 goroutine 有關。當你在 c 程序中 fork 一個新的進程,你會得到一個 PID,你可以通過這個 PID 向它發送信號來停止它的運行。
可是當你啟動一個 goroutine 時,你并不會得到一個這個‘線程‘的 ID,那么要如何才能關掉它呢?答案就是 可取消的 Context。
官方示例:
package main
import (
"context"
"fmt"
"time"
)
func main() {
d := time.Now().Add(50 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), d)
// Even though ctx will be expired, it is good practice to call its
// cancelation function in any case. Failure to do so may keep the
// context and its parent alive longer than necessary.
defer cancel()
select {
case <-time.After(1 * time.Second):
fmt.Println("overslept")
case <-ctx.Done():
fmt.Println(ctx.Err())
}
}
幾點問題
當你搜索關于 Go Context 的博客的時候,通常你會看到一些規則:
- 不要將 Context 放入結構體, Context 應該作為第一個參數傳入,命名為 ctx.
- 即使函數允許, 也不要傳入nil 的 Context. 如果不知道用哪種 Context,可以使用 context.TODO().
- 使用 context 的 Value 相關方法,只應該用于在程序和接口中傳遞和請求相關數據,不能用它來傳遞一些可選的參數
- 相同的 Context 可以傳遞給在不同的 goroutine; Context 是并發安全的.
可是有幾點問題:
-
為什么不應該放在結構體?
最開始已經說明了,Context 最基本的作用,是對一些 不那么全局的全局變量 的打包,把它放到結構體,其生存周期和作用域是無法控制的,相當于把它變成了它所在包的一個全局變量。理想情況下,
Context
存在于調用棧(Call Stack) 中,所以通過參數傳遞。 -
為什么 HTTP 包的 Request 結構體持有 context?
Request 本身就是一堆參數的集合,只不過參數太多單獨寫成結構體了而已,這堆參數在請求結束時或者讀寫超時時(conn readTimeout/writeTimeout)就應該釋放,需要一個可超時的 Context 來協助。那為什么不把請求參數都放在 Context 呢,這個問題前面已經討論過了,可讀性是非常重要的。
-
為什么是并發安全的?
Context 本身的實現是不可變的(immutable),既然不可變,那當然是線程安全的。并且通過 Context.Done() 返回的通道可以協調 goroutine 的行為。
Go 的 Context 實現
在標準庫里,Context 是一個接口:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
而我們常用的 context.Background() 返回的是一個最基本的全局 context:background,是一個什么功能也沒有的 emptyCtx:
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 interface{}) interface{} {
return nil
}
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
其他所有的 Context 都應該衍生自這兩個基本的 ctx,生成新的 context 的方式是找一個 ‘父親’ ,然后復制它,再結合 value 或者 timer 生成新的 context。
withValue
func WithValue(parent Context, key, val interface{}) Context {
if key == nil {
panic("nil key")
}
if !reflect.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val} // 返回的是一個指針
}
type valueCtx struct {
Context // 注意這里使用匿名域
key, val interface{}
}
每次添加 value 不是改變了context ,而是在原有的 context 基礎上重新生成一個,形成了一條鏈。獲取 value 的時候是逆序的:
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
先看最后一個節點的鍵值對,如果不是,那么沿著鏈往上查找:
withCancel
由于可超時的 Context 是基于可取消的 Context 實現的,所以這里只討論 cancelCtx:
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}
type cancelCtx struct {
Context
mu sync.Mutex // 由于多個線程都可能執行 ctx.Cancel(),要加鎖
done chan struct{} // created lazily, closed by first cancel call
children map[canceler]struct{} // 由于需要在父節點取消時取消其所有字節點,所以記錄其所有可取消子節點
err error // set to non-nil by the first cancel call
}
func (c *cancelCtx) Done() <-chan struct{} {
c.mu.Lock()
if c.done == nil {
c.done = make(chan struct{})
}
d := c.done
c.mu.Unlock()
return d
}
生成一個新的 可取消 Context 的時候,需要傳入一個父 Context 節點,并且通過父節點找到祖先節點里面最近的一個可取消的 Context 節點,然后把自己記錄在那個祖先節點的 children 里面,這樣在祖先被 cancel 的時候,新的這個 Context 也會被取消。不過為什么是祖先節點而不是父節點呢?因為可能有如下情況(圖中箭頭方向代表生長方向):
其父節點可能不是可取消的,所以沒法記錄 children,所以不難理解代碼了:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent) // 生成一個新的可取消節點
propagateCancel(parent, &c) // 找到可取消祖先并記錄自己到祖先的 children
return &c, func() { c.cancel(true, Canceled) }
}
// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
if parent.Done() == nil {
return // 這里尤其注意,parent.Done() 返回 nil,表示整個鏈上都沒有可取消/可超時的 context。因為新的 Context 在包含父節點的時候,都是采用匿名字段,也就是說,如果新的 Context 本身沒有某個函數,但是它的匿名字段上有那個函數,那么該函數是可以直接被新的 Context 調用的。如此就可以一直追溯到 background 節點,而正好這個根節點是有 Done() 這個函數,并且返回 nil。另外,不可能出現中間一個可取消 context 調用 Done() 返回 nil,看實現便知。
}
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 { // 沒想通的是這里,什么情況會走到這步呢?
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
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
}
}
}
知道如何注冊 cancelCtx,那么具體 cancel 的實現也很簡單了,就是先取消自己,然后根據 children 遞歸遍歷并取消所有可取消子節點。代碼就不貼了,有興趣自己看一遍完整源碼比較合適。
最后再放一張圖,更清楚的理解它們的關系: