golang Context應用舉例

張朝陽發表於2023-09-27

 

Context本質

golang標準庫裡Context實際上是一個介面(即一種程式設計規範、 一種約定)。

type Context interface {
      Deadline() (deadline time.Time, ok bool)
      Done() <-chan struct{}
      Err() error
      Value(key any) any
}

 

透過檢視原始碼裡的註釋,我們得到如下約定:

  1. Done()函式返回一個只讀管道,且管道里不存放任何元素(struct{}),所以用這個管道就是為了實現阻塞
  2. Deadline()用來記錄到期時間,以及是否到期。
  3. Err()用來記錄Done()管道關閉的原因,比如可能是因為超時,也可能是因為被強行Cancel了。
  4. 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

當業務邏輯比較複雜,函式呼叫鏈很長時,引數傳遞會很複雜,如下圖:

golang Context應用舉例

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的壽命。比如:

  1. 父Context設定了10號到期,5號誕生了子Context,子Context設定了100天后到期,則實際上10號的時候子Context也會到期。
  2. 父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)
}

 

 

相關文章