天天看點

Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

[TOC]

1 Context的初衷

In Go servers, each incoming request is handled in its own goroutine. Request handlers often start additional goroutines to access backends such as databases and RPC services. The set of goroutines working on a request typically needs access to request-specific values such as the identity of the end user, authorization tokens, and the request's deadline. When a request is canceled or times out, all the goroutines working on that request should exit quickly so the system can reclaim any resources they are using.           
Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

如上圖,很多時候,尤其是分布式架構環境中,一個請求到達服務端後,會被拆分為若幹個請求轉發至相關的服務單元處理,如果一個服務單元傳回結束資訊(通常是錯誤造成的),其他服務單元都應該及時結束該請求的處理,以避免資源浪費在無意義的請求處理上。

正是因于此,Google開發了context包,提供對使用一組相同上下文(context)的goroutine的管理,及時結束無意義的請求處理goroutine。

1.1 如何下發取消(結束)指令?

這就成為一個亟待解決的問題。我們都知道在Go語言中,提倡“通過通信共享記憶體資源”,那麼下發取消指令最簡單直接的辦法就是建立一個結束通道(done channel),各個服務單元(goroutine)根據channel來擷取結束指令。

1.2 如何根據channel來擷取結束指令呢?

So easy,讀值呗!有值就表示結束啊!           

哈哈,事實并非如此,通道有非緩沖通道和緩沖通道,應該選擇哪一種?通道中寫什麼值呢?是有值即結束還是根據值判斷呢?

1.2.1 使用非緩沖通道

type Result struct {
    status bool
    value int
}

func thirtyAPI(done chan struct{}, num int, dst chan Result){
    fmt.Printf("我正在調用第三方API:%d\n", num)
    tc := make(chan int, 1)
    i := 0
    for {
        // 業務邏輯代碼
        select {
        case <-done:
            fmt.Printf("%d: 我要結束了,Bye ThirtyAPI\n", num)
            dst <- Result{status:false, value:num}
            return
        case tc <- i:
            if num == 3 {
                time.Sleep(time.Second)
                dst <- Result{status: false, value:num}
                return
            }
            i = <-tc
            i++
        }
    }
}

func eg3() {
    dst := make(chan Result, 5)
    done := make(chan struct{})
    for i:=0; i<5; i++{
        go thirtyAPI(done, i, dst)
    }

    for result := range dst {
        if result.status == false {
            // 第一個false到來時,必須釋出取消指令
            fmt.Printf("%d: I met error\n", result.value)
            done <- struct{}{}
            break
        }
    }
}

func main() {
    eg3()
    time.Sleep(time.Second*5)
    fmt.Printf("Current Active Goroutine Num: %d\n",runtime.NumGoroutine())
}           

分析一下運作結果,我們發現隻有一個goroutine接收到

結束指令

,其他的goroutine都未結束運作。這是因為代碼中使用非緩沖通道造成的。

Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

1.2.2 使用緩沖通道

type Result struct {
    status bool
    value int
}

func thirtyAPI(done chan struct{}, num int, dst chan Result){
    fmt.Printf("我正在調用第三方API:%d\n", num)
    tc := make(chan int, 1)
    i := 0
    for {
        // 業務邏輯代碼
        select {
        case <-done:
            fmt.Printf("%d: 我要結束了,Bye ThirtyAPI\n", num)
            dst <- Result{status:false, value:num}
            return
        case tc <- i:
            if num == 3 {
                time.Sleep(time.Second)
                dst <- Result{status: false, value:num}
                return
            }
            if num == 4 {
                dst <- Result{status: true, value: num}
                return
            }
            i = <-tc
            i++
        }
    }
}

func eg4() {
    dst := make(chan Result, 5)
    done := make(chan struct{}, 5)
    for i:=0; i<5; i++{
        go thirtyAPI(done, i, dst)
    }

    for result := range dst {
        if result.status == false {
            // 第一個false到來時,必須釋出取消指令
            fmt.Printf("%d: I met error\n", result.value)
            done <- struct{}{}
            done <- struct{}{}
            done <- struct{}{}
            done <- struct{}{}
            done <- struct{}{}
            break
        } else {
            fmt.Printf("%d: I have success\n", result.value)
        }
    }
}

func main() {
    eg4()

    time.Sleep(time.Second*5)
    fmt.Printf("Current Active Goroutine Num: %d\n",runtime.NumGoroutine())
}           

分析一下結果,令人欣慰的是所有的goroutine都結束了,但是有兩點缺陷,第一,寫了五行

done <- struct{}{}

是不是很垃圾?第二,在代碼中實際受done通道訓示結束運作的goroutine隻有三條,是不是資源浪費?

其實,最緻命的問題是采用緩存通道并不能真正的結束所有該退出的goroutine,想一想,如果在thirtyAPI中繼續調用其他API怎麼辦?我們并不能在預知有多少個goroutine在運作!!!

1.2.3 借助closed channel特性

在1.2.2中,我們知道我們無法預知實際有多少goroutine該執行結束,因而無法确定done channel的長度。

問題似乎不可解,我們不妨換個思路,既然寫這條路走不通,那麼可否不寫呢?

A receive operation on a closed channel can always proceed immediately, yielding the element type's zero value.           

當需要下發取消指令時,下發端隻需要關閉done channel即可,這樣所有需要退出的goroutine都能從done channel讀取零值,也就都退出啦!

type Result struct {
    status bool
    value int
}

func thirtyAPI(done chan struct{}, num int, dst chan Result){
    fmt.Printf("我正在調用第三方API:%d\n", num)
    tc := make(chan int, 1)
    i := 0
    for {
        // 業務邏輯代碼
        select {
        case <-done:
            fmt.Printf("%d: 我要結束了,Bye ThirtyAPI\n", num)
            dst <- Result{status:false, value:num}
            return
        case tc <- i:
            if num == 3 {
                time.Sleep(time.Second)
                dst <- Result{status: false, value:num}
                return
            }
            if num == 4 {
                dst <- Result{status: true, value: num}
                return
            }
            i = <-tc
            i++
        }
    }
}

func eg4() {
    dst := make(chan Result, 5)
    done := make(chan struct{}, 5)
    defer close(done)
    for i:=0; i<5; i++{
        go thirtyAPI(done, i, dst)
    }

    for result := range dst {
        if result.status == false {
            // 第一個false到來時,必須釋出取消指令
            fmt.Printf("%d: I met error\n", result.value)
            break
        } else {
            fmt.Printf("%d: I have success\n", result.value)
        }
    }
}

func main() {
    eg4()

    time.Sleep(time.Second*5)
    fmt.Printf("Current Active Goroutine Num: %d\n",runtime.NumGoroutine())
}           
Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

其實,Context也正是基于closed channel這個特性實作的。

2 解讀Context

2.1 Context接口

type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}           
  • Done():該方法傳回一個channel,該channel扮演取消信号角色,當該channel被關閉時,所有應該退出的goroutine均可從Done()讀值,故而結束執行。
  • Err():列印錯誤資訊,解釋為什麼context被取消。
  • Deadline(): 傳回該context的截止時間,依賴函數可以根據該時間節點為IO操作設定逾時時間。
  • Value(key): 該方法根據key傳回context對應的屬性值,這些值在goroutine之間共享。

2.1.1 基于Context改寫1.2.3代碼

type Result struct {
    status bool
    value int
}

func thirtyAPI(ctx context.Context, num int, dst chan Result){
    fmt.Printf("我正在調用第三方API:%d\n", num)
    tc := make(chan int, 1)
    i := 0
    for {
        // 業務邏輯代碼
        select {
        case <-ctx.Done():
            fmt.Printf("%d: 我要結束了,Error資訊: %s\n", num, ctx.Err())
            dst <- Result{status:false, value:num}
            return
        case tc <- i:
            if num == 3 {
                time.Sleep(time.Second)
                dst <- Result{status: false, value:num}
                return
            }
            if num == 4 {
                dst <- Result{status: true, value: num}
                return
            }
            i = <-tc
            i++
        }
    }
}

func eg4() {
    dst := make(chan Result, 5)

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    for i:=0; i<5; i++{
        go thirtyAPI(ctx, i, dst)
    }

    for result := range dst {
        if result.status == false {
            // 第一個false到來時,必須釋出取消指令
            fmt.Printf("%d: I met error\n", result.value)
            break
        } else {
            fmt.Printf("%d: I have success\n", result.value)
        }
    }
}

func main() {
    eg4()

    time.Sleep(time.Second*5)
    fmt.Printf("Current Active Goroutine Num: %d\n",runtime.NumGoroutine())
}           

2.1.2 Deadline Demo

func gofunc(ctx context.Context) {
    d, _ := ctx.Deadline()

    for {
        select {
        case <-time.After(1 * time.Second):
            fmt.Printf("Deadline:%v, Now:%v\n",d, time.Now())
        case <-ctx.Done():
            fmt.Println(ctx.Err())
            return
        }
    }
}

func main() {
    d := time.Now().Add(5 * time.Second)
    ctx, cancel := context.WithDeadline(context.Background(), d)
    fmt.Printf("Deadline:%v\n", d)
    defer cancel()
    go gofunc(ctx)

    time.Sleep(time.Second*10)
    fmt.Printf("Current Active Goroutine Num: %d\n",runtime.NumGoroutine())
}           

2.1.3 Value Demo

func main() {
    type favContextKey string

    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")
    ctx := context.WithValue(context.Background(), k, "Go")

    f(ctx, k)
    f(ctx, favContextKey("color"))

}           

2.2 context的函數與Context接口關系

Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

2.2.1 Background vs TODO

Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

3 答疑與最佳實踐

3.1 答疑

3.1.1 Context衍生樹

The context package provides functions to derive new Context values from existing ones. These values form a tree: when a Context is canceled, all Contexts derived from it are also canceled.

WithCancel and WithTimeout return derived Context values that can be canceled sooner than the parent Context.           
Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

對子context的cancel操作,隻會影響該子context及其子孫,并不影響其父輩及兄弟context。

package main

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

func child(ctx context.Context, p, c int) {
    fmt.Printf("Child Goroutine:%d-%d\n", p, c)
    select {
    case <-ctx.Done():
        fmt.Printf("Child %d-%d: exited reason: %s\n", p, c, ctx.Err())
    }
}

func parent(ctx context.Context, p int) {
    fmt.Printf("Parent Goroutine:%d\n", p)
    cctx, cancel := context.WithCancel(ctx)
    defer cancel()
    for i:=0; i<3; i++ {
        go child(cctx, p, i)
    }

    if p==3 {
        return
    }

    select {
    case <- ctx.Done():
        fmt.Printf("Parent %d: exited reason: %s\n", p, ctx.Err())
        return
    }
}

func main() {

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    for i:=0; i<5; i++ {
        go parent(ctx, i)
    }

    time.Sleep(time.Second*3)
    fmt.Printf("Current Active Goroutine Num: %d\n",runtime.NumGoroutine())
}           
Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

3.1.2 上下層Goroutine

A Context does not have a Cancel method for the same reason the Done channel is receive-only: the function receiving a cancelation signal is usually not the one that sends the signal. In particular, when a parent operation starts goroutines for sub-operations, those sub-operations should not be able to cancel the parent. Instead, the WithCancel function (described below) provides a way to cancel a new Context value.           

Context自身是沒有cancel方法的,主要原因是Done channel是隻讀通道。一般而言,接收取消信号的方法不應該是下發取消信号的。故而,父Goroutine不應該被其建立的子Goroutine取消。

但是,如果在子Goroutine中調用cancel函數,是不是也能取消父Goroutine呢?

package main

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

func SubGor(ctx context.Context, p, c int, cancel context.CancelFunc) {
    fmt.Printf("Child Goroutine:%d-%d\n", p, c)
    if p==2 && c==2 {
        cancel()
    }

    select {
    case <-ctx.Done():
        fmt.Printf("Child %d-%d: exited reason: %s\n", p, c, ctx.Err())
    }
}

func Gor(ctx context.Context, p int,cancel context.CancelFunc) {
    fmt.Printf("Goroutine:%d\n", p)
    for i:=0; i<3; i++ {
        go SubGor(ctx, p, i, cancel)
    }


    select {
    case <- ctx.Done():
        fmt.Printf("Parent %d: exited reason: %s\n", p, ctx.Err())
        return
    }
}


func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    for i:=0; i<3; i++ {
        go Gor(ctx, i, cancel)
    }

    time.Sleep(time.Second*3)
    fmt.Printf("Current Active Goroutine Num: %d\n",runtime.NumGoroutine())
}           
Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

由示例代碼可知,如果在子Goroutine調用cancel函數時,一樣可以關閉父類Goroutine。但是,不建議這麼做,因為它不符合邏輯,cancel應該交給具有cancel權限的人去做,千萬不要越俎代庖。

Question:有沒有想過context cancel的執行邏輯是什麼樣子的?

3.1.3 如果goroutine func中不做ctx.Done處理,是不是不會被取消呢?

package main

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

func dealDone(ctx context.Context, i int){
    fmt.Printf("%d: deal done chan\n", i)
    select{
    case <-ctx.Done():
        fmt.Printf("%d: exited, reason: %s\n", i, ctx.Err())
        return
    }
}

func notDealDone(ctx context.Context, i int) {
    fmt.Printf("%d: not deal done chan\n",i)
    for{
        i++
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    for i:=0; i<5; i++ {
        if i==4 {
            go notDealDone(ctx, i)
        } else {
            go dealDone(ctx, i)
        }
    }
    time.Sleep(time.Second*3)
    fmt.Println("Execute Cancel Func")
    cancel()

    time.Sleep(time.Second*3)
    fmt.Printf("Current Active Goroutine Num: %d\n",runtime.NumGoroutine())
}           
Go Context解讀與實踐1 Context的初衷2 解讀Context3 答疑與最佳實踐4 參考連結

3.2 最佳實踐

Programs that use Contexts should follow these rules to keep interfaces consistent across packages and enable static analysis tools to check context propagation:

  • Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx:
func DoSomething(ctx context.Context, arg Arg) error {
    // ... use ctx ...
}           
  • Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use.
  • Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
  • The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.

4 參考連結