Go語言context上下文管理的使用

context 有什麼作用

context 主要用來在goroutine 之間傳遞上下文信息,包括:取消信號、超時時間、截止時間、k-v 等。

Go 常用來寫後臺服務,通常隻需要幾行代碼,就可以搭建一個 http server。

在 Go 的 server 裡,通常每來一個請求都會啟動若幹個 goroutine 同時工作:有些去數據庫拿數據,有些調用下遊接口獲取相關數據……

這些 goroutine 需要共享這個請求的基本數據,例如登陸的 token,處理請求的最大超時時間(如果超過此值再返回數據,請求方因為超時接收不到)等等。當請求被取消或是處理時間太長,這有可能是使用者關閉瞭瀏覽器或是已經超過瞭請求方規定的超時時間,請求方直接放棄瞭這次請求結果。這時,所有正在為這個請求工作的 goroutine 需要快速退出,因為它們的“工作成果”不再被需要瞭。在相關聯的 goroutine 都退出後,系統就可以回收相關的資源。

在Go 裡,我們不能直接殺死協程,協程的關閉一般會用 channel+select 方式來控制。但是在某些場景下,例如處理一個請求衍生瞭很多協程,這些協程之間是相互關聯的:需要共享一些全局變量、有共同的 deadline 等,而且可以同時被關閉。再用 channel+select 就會比較麻煩,這時就可以通過 context 來實現。

一句話:context 用來解決 goroutine 之間退出通知元數據傳遞的功能。

context 使用起來非常方便。源碼裡對外提供瞭一個創建根節點 context 的函數:

func Background() Context

background 是一個空的 context, 它不能被取消,沒有值,也沒有超時時間。 有瞭根節點 context,又提供瞭四個函數創建子節點 context:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

context 會在函數傳遞間傳遞。隻需要在適當的時間調用 cancel 函數向 goroutines 發出取消信號或者調用 Value 函數取出 context 中的值。

  • 不要將 Context 塞到結構體裡。直接將 Context 類型作為函數的第一參數,而且一般都命名為ctx
  • 不要向函數傳入一個 nil 的 context,如果你實在不知道傳什麼,標準庫給你準備好瞭一個 context:todo
  • 不要把本應該作為函數參數的類型塞到 context 中,context 存儲的應該是一些共同的數據。例如:登陸的 session、cookie 等。
  • 同一個 context 可能會被傳遞到多個 goroutine,別擔心,context 是並發安全的。

傳遞共享的數據

對於 Web 服務端開發,往往希望將一個請求處理的整個過程串起來,這就非常依賴於 Thread Local(對於 Go 可理解為單個協程所獨有) 的變量,而在 Go 語言中並沒有這個概念,因此需要在函數調用的時候傳遞 context

package main

import (
    "context"
    "fmt"
)
func main() {
    ctx := context.Background()
    process(ctx)
    ctx = context.WithValue(ctx, "traceId", "qcrao-2019")
    process(ctx)
}
func process(ctx context.Context) {
    traceId, ok := ctx.Value("traceId").(string)
    if ok {
        fmt.Printf("process over. trace_id=%s\n", traceId)
    } else {
        fmt.Printf("process over. no trace_id\n")
    }
}

運行結果:

process over. no trace_id
process over. trace_id=qcrao-2019

第一次調用 process 函數時,ctx 是一個空的 context,自然取不出來 traceId。第二次,通過 WithValue 函數創建瞭一個 context,並賦上瞭 traceId 這個 key,自然就能取出來傳入的 value 值。

取消 goroutine

我們先來設想一個場景:打開外賣的訂單頁,地圖上顯示外賣小哥的位置,而且是每秒更新 1 次。app 端向後臺發起 websocket 連接(現實中可能是輪詢)請求後,後臺啟動一個協程,每隔 1 秒計算 1 次小哥的位置,並發送給端。如果用戶退出此頁面,則後臺需要“取消”此過程,退出 goroutine,系統回收資源。

func Perform() {
    for {
        calculatePos()
        sendResult()
        time.Sleep(time.Second)
    }
}

如果需要實現“取消”功能,並且在不瞭解 context 功能的前提下,可能會這樣做:給函數增加一個指針型的 bool 變量,在 for 語句的開始處判斷 bool 變量是發由 true 變為 false,如果改變,則退出循環。

上面給出的簡單做法,可以實現想要的效果,沒有問題,但是並不優雅,並且一旦協程數量多瞭之後,並且各種嵌套,就會很麻煩。優雅的做法,自然就要用到 context。

func Perform(ctx context.Context) {
    for {
        calculatePos()
        sendResult()
        select {
        case <-ctx.Done():
            // 被取消,直接返回
            return
        case <-time.After(time.Second):
            // block 1 秒鐘 
        }
    }
}

主流程可能是這樣的:

ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
go Perform(ctx)
// ……
// app 端返回頁面,調用cancel 函數
cancel()

註意一個細節,WithTimeout 函數返回的 context 和 cancelFun 是分開的。context 本身並沒有取消函數,這樣做的原因是取消函數隻能由外層函數調用,防止子節點 context 調用取消函數,從而嚴格控制信息的流向:由父節點 context 流向子節點 context。

防止 goroutine 泄漏

前面那個例子裡,goroutine 還是會執行完,最後返回,可能多浪費一些系統資源。這裡改編一個 “如果不用 context 取消,goroutine 就會泄漏的例子”

func gen() <-chan int {
    ch := make(chan int)
    go func() {
        var n int
        for {
            ch <- n
            n++
            time.Sleep(time.Second)
        }
    }()
    return ch
}

這是一個可以生成無限整數的協程,但如果我隻需要它產生的前 5 個數,那麼就會發生 goroutine 泄漏:

func main() {
    for n := range gen() {
        fmt.Println(n)
        if n == 5 {
            break
        }
    }
    // ……
}

當 n == 5 的時候,直接 break 掉。那麼 gen 函數的協程就會執行無限循環,永遠不會停下來。發生瞭 goroutine 泄漏。

用 context 改進這個例子:

func gen(ctx context.Context) <-chan int {
    ch := make(chan int)
    go func() {
        var n int
        for {
            select {
            case <-ctx.Done():
                return
            case ch <- n:
                n++
                time.Sleep(time.Second)
            }
        }
    }()
    return ch
}
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 避免其他地方忘記 cancel,且重復調用不影響
    for n := range gen(ctx) {
        fmt.Println(n)
        if n == 5 {
            cancel()
            break
        }
    }
    // ……
}

增加一個 context,在 break 前調用 cancel 函數,取消 goroutine。gen 函數在接收到取消信號後,直接退出,系統回收資源。

context.Value 的查找過程是怎樣的

和鏈表有點像,隻是它的方向相反:Context 指向它的父節點,鏈表則指向下一個節點。通過 WithValue 函數,可以創建層層的 valueCtx,存儲 goroutine 間可以共享的變量。

查找的時候,會向上查找到最後一個掛載的 context 節點,也就是離得比較近的一個父節點 context

到此這篇關於Go語言context上下文管理的使用的文章就介紹到這瞭,更多相關Go語言context上下文管理內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: