Context本質
golang標準庫裡Context實際上是一個介面(即一種程式設計規範、 一種約定)。
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key any) any }
透過檢視原始碼裡的註釋,我們得到如下約定:
- Done()函式返回一個只讀管道,且管道里不存放任何元素(struct{}),所以用這個管道就是為了實現阻塞
- Deadline()用來記錄到期時間,以及是否到期。
- Err()用來記錄Done()管道關閉的原因,比如可能是因為超時,也可能是因為被強行Cancel了。
- Value()用來返回key對應的value,你可以想像成Context內部維護了一個map。
Context實現
go原始碼裡提供了Context介面的一個具體實現,遺憾的是它只是一個空的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 }
emptyCtx以小寫開頭,包外不可見,所以golang又提供了Background和TODO這2個函式讓我們能獲取到emptyCtx。
var ( background = new(emptyCtx) todo = new(emptyCtx) ) func Background() Context { return background } func TODO() Context { return todo }
backgroud和todo明明是一模一樣的東西,就是emptyCtx,為什麼要搞2個呢?真心求教,知道的同學請在評論區告訴我。
emptyCtx有什麼用?建立Context時通常需要傳遞一個父Context,emptyCtx用來充當最初的那個Root Context。
With Value
當業務邏輯比較複雜,函式呼叫鏈很長時,引數傳遞會很複雜,如下圖:
f1產生的引數b要傳給f2,雖然f2並不需要引數b,但f3需要,所以b還是得往後傳。
如果把每一步產生的新變數都放到Context這個大容器裡,函式之間只傳遞Context,需要什麼變數時直接從Context裡取,如下圖:
f2能從context裡取到a和b,f4能從context裡取到a、b、c、d。
package main import ( "context" "fmt" ) func step1(ctx context.Context) context.Context { //根據父context建立子context,建立context時允許設定一個<key,value>對,key和value可以是任意資料型別 child := context.WithValue(ctx, "name", "大臉貓") return child } func step2(ctx context.Context) context.Context { fmt.Printf("name %s\n", ctx.Value("name")) //子context繼承了父context裡的所有key value child := context.WithValue(ctx, "age", 18) return child } func step3(ctx context.Context) { fmt.Printf("name %s\n", ctx.Value("name")) //取出key對應的value fmt.Printf("age %d\n", ctx.Value("age")) } func main1() { grandpa := context.Background() //空context father := step1(grandpa) //father裡有一對<key,value> grandson := step2(father) //grandson裡有兩對<key,value> step3(grandson) }
Timeout
在影片 https://www.bilibili.com/video/BV1C14y127sv/ 裡介紹了超時實現的核心原理,影片中演示的done管道可以用Context的Done()來替代,Context的Done()管道什麼時候會被關係呢?2種情況:
1. 透過context.WithCancel建立一個context,呼叫cancel()時會關閉context.Done()管道。
func f1() { ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(100 * time.Millisecond) cancel() //呼叫cancel,觸發Done }() select { case <-time.After(300 * time.Millisecond): fmt.Println("未超時") case <-ctx.Done(): //ctx.Done()是一個管道,呼叫了cancel()都會關閉這個管道,然後讀操作就會立即返回 err := ctx.Err() //如果發生Done(管道被關閉),Err返回Done的原因,可能是被Cancel了,也可能是超時了 fmt.Println("超時:", err) //context canceled } }
2. 透過context.WithTimeout建立一個context,當超過指定的時間或者呼叫cancel()時會關閉context.Done()管道。
func f2() { ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100) //超時後會自動呼叫context的Deadline,Deadline會,觸發Done defer cancel() select { case <-time.After(300 * time.Millisecond): fmt.Println("未超時") case <-ctx.Done(): //ctx.Done()是一個管道,context超時或者呼叫了cancel()都會關閉這個管道,然後讀操作就會立即返回 err := ctx.Err() //如果發生Done(管道被關閉),Err返回Done的原因,可能是被Cancel了,也可能是超時了 fmt.Println("超時:", err) //context deadline exceeded } }
Timeout的繼承問題
透過context.WithTimeout建立的Context,其壽命不會超過父Context的壽命。比如:
- 父Context設定了10號到期,5號誕生了子Context,子Context設定了100天后到期,則實際上10號的時候子Context也會到期。
- 父Context設定了10號到期,5號誕生了子Context,子Context設定了1天后到期,則實際上6號的時候子Context就會到期。
func inherit_timeout() { parent, cancel1 := context.WithTimeout(context.Background(), time.Millisecond*1000) //parent設定100ms超時 t0 := time.Now() defer cancel1() time.Sleep(500 * time.Millisecond) //消耗掉500ms // child, cancel2 := context.WithTimeout(parent, time.Millisecond*1000) //parent還剩500ms,child設定了1000ms之後到期,child.Done()管道的關閉時刻以較早的為準,即500ms後到期 child, cancel2 := context.WithTimeout(parent, time.Millisecond*100) //parent還剩500ms,child設定了100ms之後到期,child.Done()管道的關閉時刻以較早的為準,即100ms後到期 t1 := time.Now() defer cancel2() select { case <-child.Done(): t2 := time.Now() fmt.Println(t2.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds()) fmt.Println(child.Err()) //context deadline exceeded } }
context超時在http請求中的實際應用
定心丸來了,最後說一遍:”context在實踐中真的很有用“
客戶端發起http請求時設定了一個2秒的超時時間:
package main import ( "fmt" "io/ioutil" "net/http" "time" ) func main() { client := http.Client{ Timeout: 2 * time.Second, //小於10秒,導致請求超時,會觸發Server端的http.Request.Context的Done } if resp, err := client.Get("http://127.0.0.1:5678/"); err == nil { defer resp.Body.Close() fmt.Println(resp.StatusCode) if bs, err := ioutil.ReadAll(resp.Body); err == nil { fmt.Println(string(bs)) } } else { fmt.Println(err) //Get "http://127.0.0.1:5678/": context deadline exceeded (Client.Timeout exceeded while awaiting headers) } }
服務端從Request裡取提context,故意休息10秒鐘,同時監聽context.Done()管道有沒有關閉。由於Request的context是2秒超時,所以服務端還沒休息夠context.Done()管道就關閉了。
package main import ( "fmt" "net/http" "time" ) func welcome(w http.ResponseWriter, req *http.Request) { ctx := req.Context() //取得request的context select { case <-time.After(10 * time.Second): //故意慢一點,10秒後才返回結果 fmt.Fprintf(w, "welcome") case <-ctx.Done(): //超時後client會撤銷請求,觸發ctx.cancel(),從而關閉Done()管道 err := ctx.Err() //如果發生Done(管道被關閉),Err返回Done的原因,可能是被Cancel了,也可能是超時了 fmt.Println("server:", err) //context canceled } } func main() { http.HandleFunc("/", welcome) http.ListenAndServe(":5678", nil) }