Go進階01:golang context 上下文用法詳解(比較好理解)

技术颜良發表於2024-09-23

1 前言

最近實現系統的分散式日誌與事務管理時,在尋求所謂的全域性唯一Goroutine ID無果之後,決定還是簡單利用Context機制實現了基本的想法,不夠高明,但是好用.於是對它當初的設計比較好奇,便有了此文.

Context是golang官方定義的一個package,它定義了Context型別,裡面包含了Deadline/Done/Err方法以及繫結到Context上的成員變數值Value,具體定義如下:

type Context interface {
    // 返回Context的超時時間(超時返回場景)
    Deadline() (deadline time.Time, ok bool)
    // 在Context超時或取消時(即結束了)返回一個關閉的channel
    // 即如果當前Context超時或取消時,Done方法會返回一個channel,然後其他地方就可以透過判斷Done方法是否有返回(channel),如果有則說明Context已結束
    // 故其可以作為廣播通知其他相關方本Context已結束,請做相關處理.
    Done() <-chan struct{}

    // 返回Context取消的原因
    Err() error

    // 返回Context相關資料
    Value(key interface{}) interface{}
}

那麼到底什麼Context?

可以字面意思可以理解為上下文,比較熟悉的有程序/執行緒上線文,關於golang中的上下文,一句話概括就是: goroutine的相關環境快照,其中包含函式呼叫以及涉及的相關的變數值.
透過Context可以區分不同的goroutine請求,因為在golang Severs中,每個請求都是在單個goroutine中完成的.

最近在公司分析gRPC原始碼,proto檔案生成的程式碼,介面函式第一個引數統一是ctx context.Context介面,公司不少同事都不瞭解這樣設計的出發點是什麼,其實我也不瞭解其背後的原理.今天趁著妮妲颱風妹子正面登陸深圳,全市停工,停課,停業,在家休息找了一些資料研究把玩一把.

Context通常被譯作上下文,它是一個比較抽象的概念.在公司技術討論時也經常會提到上下文.一般理解為程式單元的一個執行狀態,現場,快照,而翻譯中上下又很好地詮釋了其本質,上下上下則是存在上下層的傳遞,上會把內容傳遞給下.在Go語言中,程式單元也就指的是Goroutine.

每個Goroutine在執行之前,都要先知道程式當前的執行狀態,通常將這些執行狀態封裝在一個Context變數中,傳遞給要執行的Goroutine中. 上下文則幾乎已經成為傳遞與請求同生存週期變數的標準方法.在網路程式設計下,當接收到一個網路請求Request,處理Request時,我們可能需要開啟不同的Goroutine來獲取資料與邏輯處理,即一個請求Request,會在多個Goroutine中處理. 而這些Goroutine可能需要共享Request的一些資訊;同時當Request被取消或者超時的時候,所有從這個Request建立的所有Goroutine也應該被結束.

注:關於goroutine的理解可以移步這裡.

2 為什麼使用context

由於在golang severs中,每個request都是在單個goroutine中完成,並且在單個goroutine(不妨稱之為A)中也會有請求其他服務(啟動另一個goroutine(稱之為B)去完成)的場景,這就會涉及多個Goroutine之間的呼叫.如果某一時刻請求其他服務被取消或者超時,則作為深陷其中的當前goroutine B需要立即退出,然後系統才可回收B所佔用的資源.
即一個request中通常包含多個goroutine,這些goroutine之間通常會有互動.

那麼,如何有效管理這些goroutine成為一個問題(主要是退出通知和後設資料傳遞問題),Google的解決方法是Context機制,相互呼叫的goroutine之間透過傳遞context變數保持關聯,這樣在不用暴露各goroutine內部實現細節的前提下,有效地控制各goroutine的執行.

如此一來,透過傳遞Context就可以追蹤goroutine呼叫樹,並在這些呼叫樹之間傳遞通知和後設資料.
雖然goroutine之間是平行的,沒有繼承關係,但是Context設計成是包含父子關係的,這樣可以更好的描述goroutine呼叫之間的樹型關係.

3 怎麼使用context

生成一個Context主要有兩類方法:

3.1 頂層Context:Background

要建立Context樹,首先就是要建立根節點

// 返回一個空的Context,它作為所有由此繼承Context的根節點
func Background() Context

該Context通常由接收request的第一個goroutine建立,它不能被取消,沒有值,也沒有過期時間,常作為處理request的頂層context存在.

3.2 下層Context:WithCancel/WithDeadline/WithTimeout

有了根節點之後,接下來就是建立子孫節點.為了可以很好的控制子孫節點,Context包提供的建立方法均是帶有第二返回值(CancelFunc型別),它相當於一個Hook,在子goroutine執行過程中,可以透過觸發Hook來達到控制子goroutine的目的(通常是取消,即讓其停下來).再配合Context提供的Done方法,子goroutine可以檢查自身是否被父級節點Cancel:

select { 
    case <-ctx.Done(): 
        // do some clean… 
}

注:父節點Context可以主動透過呼叫cancel方法取消子節點Context,而子節點Context只能被動等待.同時父節點Context自身一旦被取消(如其上級節點Cancel),其下的所有子節點Context均會自動被取消.

有三種建立方法:

// 帶cancel返回值的Context,一旦cancel被呼叫,即取消該建立的context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) 

// 帶有效期cancel返回值的Context,即必須到達指定時間點呼叫的cacel方法才會被執行
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) 

// 帶超時時間cancel返回值的Context,類似Deadline,前者是時間點,後者為時間間隔
// 相當於WithDeadline(parent, time.Now().Add(timeout)).
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

下面來看改編自Advanced Go Concurrency Patterns影片提供的一個簡單例子:

package main

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

func someHandler() {
    // 建立繼承Background的子節點Context
    ctx, cancel := context.WithCancel(context.Background())
    go doSth(ctx)

    //模擬程式執行 - Sleep 5秒
    time.Sleep(5 * time.Second)
    cancel()
}

//每1秒work一下,同時會判斷ctx是否被取消,如果是就退出
func doSth(ctx context.Context) {
    var i = 1
    for {
        time.Sleep(1 * time.Second)
        select {
        case <-ctx.Done():
            fmt.Println("done")
            return
        default:
            fmt.Printf("work %d seconds: \n", i)
        }
        i++
    }
}

func main() {
    fmt.Println("start...")
    someHandler()
    fmt.Println("end.")
}

輸出結果:

注意,此時doSth方法中case之done的fmt.Println("done")並沒有被列印出來.

超時場景:

package main

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

func timeoutHandler() {
    // 建立繼承Background的子節點Context
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    go doSth(ctx)

    //模擬程式執行 - Sleep 10秒
    time.Sleep(10 * time.Second)
    cancel() // 3秒後將提前取消 doSth goroutine
}

//每1秒work一下,同時會判斷ctx是否被取消,如果是就退出
func doSth(ctx context.Context) {
    var i = 1
    for {
        time.Sleep(1 * time.Second)
        select {
        case <-ctx.Done():
            fmt.Println("done")
            return
        default:
            fmt.Printf("work %d seconds: \n", i)
        }
        i++
    }
}

func main() {
    fmt.Println("start...")
    timeoutHandler()
    fmt.Println("end.")
}

輸出結果:

4 context是一個優雅的設計嗎?

確實,透過引入Context包,一個request範圍內所有goroutine執行時的取消可以得到有R效的控制.但是這種解決方式卻不夠優雅.

4.1 context 像病毒一樣擴散

一旦程式碼中某處用到了Context,傳遞Context變數(通常作為函式的第一個引數)會像病毒一樣蔓延在各處呼叫它的地方. 比如在一個request中實現資料庫事務或者分散式日誌記錄, 建立的context,會作為引數傳遞到任何有資料庫操作或日誌記錄需求的函式程式碼處. 即每一個相關函式都必須增加一個context.Context型別的引數,且作為第一個引數,這對無關程式碼完全是侵入式的.

更多詳細內容可參見:Michal Strba 的context-should-go-away-go2文章

Google Group上的討論可移步這裡.

4.2 Context 不僅僅只是cancel訊號

Context機制最核心的功能是在goroutine之間傳遞cancel訊號,但是它的實現是不完全的.

Cancel可以細分為主動與被動兩種,透過傳遞context引數,讓呼叫goroutine可以主動cancel被呼叫goroutine.但是如何得知被呼叫goroutine什麼時候執行完畢,這部分Context機制是沒有實現的.而現實中的確又有一些這樣的場景,比如一個組裝資料的goroutine必須等待其他goroutine完成才可開始執行,這是context明顯不夠用了,必須藉助sync.WaitGroup.

func serve(l net.Listener) error {
        var wg sync.WaitGroup
        var conn net.Conn
        var err error
        for {
                conn, err = l.Accept()
                if err != nil {
                        break
                }
                wg.Add(1)
                go func(c net.Conn) {
                        defer wg.Done()
                        handle(c)
                }(conn)
        }
        wg.Wait()
        return err
}

4.3 context.value

context.Value相當於goroutine的TLS(Thread Local Storage),但它不是靜態型別安全的,任何結構體變數都必須作為字串形式儲存.同時,所有context都會在其中定義變數,很容易造成命名衝突.

5 總結

context包透過構建樹型關係的Context,來達到上一層Goroutine能對傳遞給下一層Goroutine的控制.對於處理一個Request請求操作,需要採用context來層層控制Goroutine,以及傳遞一些變數來共享.

Context物件的生存週期一般僅為一個請求的處理週期.即針對一個請求建立一個Context變數(它為Context樹結構的根);在請求處理結束後,撤銷此ctx變數,釋放資源.

每次建立一個Goroutine,要麼將原有的Context傳遞給Goroutine,要麼建立一個子Context並傳遞給Goroutine.

Context能靈活地儲存不同型別,不同數目的值,並且使多個Goroutine安全地讀寫其中的值.

當透過父Context物件建立子Context物件時,可同時獲得子Context的一個撤銷函式,這樣父Context物件的建立環境就獲得了對子Context將要被傳遞到的Goroutine的撤銷權.

在子Context被傳遞到的goroutine中,應該對該子Context的Done通道(channel)進行監控,一旦該通道被關閉(即上層執行環境撤銷了本goroutine的執行),應主動終止對當前請求資訊的處理,釋放資源並返回.

相關文章