學會使用context取消goroutine執行的方法

shankusu2017發表於2020-12-11

以下內容轉載自 https://mp.weixin.qq.com/s?__biz=MzUzNTY5MzU2MA==&mid=2247484375&idx=1&sn=a05843cb73c64103eec21e68fc5ca956&chksm=fa80d240cdf75b5689e1ad2ea279ff02a97b585a83869e237cb4e436a858d48ab50bf48cde84&scene=21#wechat_redirect

Go語言裡每一個併發的執行單元叫做goroutine,當一個用Go語言編寫的程式啟動時,其main函式在一個單獨的goroutine中執行。main函式返回時,所有的goroutine都會被直接打斷,程式退出。除此之外如果想通過程式設計的方法讓一個goroutine中斷其他goroutine的執行,只能是在多個goroutine間通過context上下文物件同步取消訊號的方式來實現。

這篇文章將介紹一些使用context物件同步訊號,取消goroutine執行的常用模式和最佳實踐,從而讓我們能構建更迅捷、健壯的應用程式。如果對context物件不太瞭解的同學建議先仔細看看《Golang 併發程式設計之Context》瞭解一下基礎。

為什麼需要取消功能

簡單來說,我們需要取消功能來防止系統做一些不必要的工作。

考慮以下常見的場景:一個HTTP伺服器查詢資料庫並將查詢到的資料作為響應返回給客戶端:

圖片

客戶端請求

如果一切正常,時序圖將如下所示:

圖片

請求處理時序圖

但是,如果客戶端在中途取消了請求會發生什麼?這種情況可以發生在,比如使用者在請求中途關閉了瀏覽器。如果不支援取消功能,HTTP伺服器和資料庫會繼續工作,由於客戶端已經關閉所以他們工作的成果也就被浪費了。這種情況的時序圖如下所示:

圖片

不支援取消的處理時序圖

理想情況下,如果我們知道某個處理過程(在此示例中為HTTP請求)已停止,則希望該過程的所有下游元件都停止執行:

圖片

支援取消的處理時序圖

使用context實現取消功能

現在我們知道了應用程式為什麼需要取消功能,接下來我們開始探究在Go中如何實現它。因為“取消事件”與正在執行的操作高度相關,因此很自然地會將它與上下文捆綁在一起。

取消功能需要從兩方面實現才能完成:

  • 監聽取消事件

  • 發出取消事件

監聽取消事件

Go語言context標準庫的Context型別提供了一個Done()方法,該方法返回一個型別為<-chan struct{}channel。每次context收到取消事件後這個channel都會接收到一個struct{}型別的值。所以在Go語言裡監聽取消事件就是等待接收<-ctx.Done()

舉例來說,假設一個HTTP伺服器需要花費兩秒鐘來處理一個請求。如果在處理完成之前請求被取消,我們想讓程式能立即中斷不再繼續執行下去:

func main() {
    // 建立一個監聽8000埠的伺服器
    http.ListenAndServe(":8000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        // 輸出到STDOUT展示處理已經開始
        fmt.Fprint(os.Stdout, "processing request\n")
        // 通過select監聽多個channel
        select {
        case <-time.After(2 * time.Second):
            // 如果兩秒後接受到了一個訊息後,意味請求已經處理完成
            // 我們寫入"request processed"作為響應
            w.Write([]byte("request processed"))
        case <-ctx.Done():

            // 如果處理完成前取消了,在STDERR中記錄請求被取消的訊息
            fmt.Fprint(os.Stderr, "request cancelled\n")
        }
    }))
}

你可以通過執行伺服器並在瀏覽器中開啟localhost:8000進行測試。如果你在2秒鐘前關閉瀏覽器,則應該在終端視窗上看到“request cancelled”字樣。

發出取消事件

如果你有一個可以取消的操作,則必須通過context發出取消事件。可以通過context包的WithCancel函式返回的取消函式來完成此操作(withCancel還會返回一個支援取消功能的上下文物件)。該函式不接受引數也不返回任何內容,當需要取消上下文時會呼叫該函式,發出取消事件。

考慮有兩個相互依賴的操作的情況。在這裡,“依賴”是指如果其中一個失敗,那麼另一個就沒有意義,而不是第二個操作依賴第一個操作的結果(那種情況下,兩個操作不能並行)。在這種情況下,如果我們很早就知道其中一個操作失敗,那麼我們就會希望能取消所有相關的操作。

func operation1(ctx context.Context) error {
    // 讓我們假設這個操作會因為某種原因失敗
    // 我們使用time.Sleep來模擬一個資源密集型操作
    time.Sleep(100 * time.Millisecond)
    return errors.New("failed")
}

func operation2(ctx context.Context) {
    // 我們使用在前面HTTP伺服器例子裡使用過的類似模式
    select {
    case <-time.After(500 * time.Millisecond):
        fmt.Println("done")
    case <-ctx.Done():
        fmt.Println("halted operation2")
    }
}

func main() {
    // 新建一個上下文
    ctx := context.Background()
    // 在初始上下文的基礎上建立一個有取消功能的上下文
    ctx, cancel := context.WithCancel(ctx)
    // 在不同的goroutine中執行operation2
    go func() {
      operation2(ctx)
    }()

  err := operation1(ctx)
    // 如果這個操作返回錯誤,取消所有使用相同上下文的操作
    if err != nil {
        cancel()
    }
}

基於時間的取消

任何需要在請求的最大持續時間內維持SLA(服務水平協議)的應用程式,都應使用基於時間的取消。該API與前面的示例幾乎相同,但有一些補充:

// 這個上下文將會在3秒後被取消
// 如果需要在到期前就取消可以像前面的例子那樣使用cancel函式
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)

// 上下文將在2009-11-10 23:00:00被取消
ctx, cancel := context.WithDeadline(ctx, time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC))

例如,程式在對外部服務進行HTTP API呼叫時設定超時時間。如果被呼叫服務花費的時間太長,到時間後就會取消請求:

func main() {
    // 建立一個超時時間為100毫秒的上下文
    ctx := context.Background()
    ctx, _ = context.WithTimeout(ctx, 100*time.Millisecond)

    // 建立一個訪問Google主頁的請求
    req, _ := http.NewRequest(http.MethodGet, "http://google.com", nil)
    // 將超時上下文關聯到建立的請求上
    req = req.WithContext(ctx)

    // 建立一個HTTP客戶端並執行請求
    client := &http.Client{}
    res, err := client.Do(req)
    // 如果請求失敗了,記錄到STDOUT
    if err != nil {
        fmt.Println("Request failed:", err)
        return
    }
    // 請求成功後列印狀態碼
    fmt.Println("Response received, status code:", res.StatusCode)
}

根據Google主頁響應你請求的速度,你將收到:

Response received, status code: 200

或者:

Request failed: Get http://google.com: context deadline exceeded

對於我們來說通常都會收到第二條訊息:)

context使用上的一些陷阱

儘管Go中的上下文取消功能是一種多功能工具,但是在繼續操作之前,你需要牢記一些注意事項。其中最重要的是,上下文只能被取消一次。如果您想在同一操作中傳播多個錯誤,那麼使用上下文取消可能不是最佳選擇。使用取消上下文的場景是你實際上確實要取消某項操作,而不僅僅是通知下游程式發生了錯誤。還需要記住的另一件事是,應該將相同的上下文例項傳遞給你可能要取消的所有函式和goroutine

WithTimeoutWithCancel包裝一個已經支援取消功能的上下文將會造成多種可能會導致你的上下文被取消的情況,應該避免這種二次包裝。

近期文章推薦

聊聊在Go語言裡使用繼承的翻車經歷

Go Web程式設計--使用bcrpyt雜湊使用者密碼

相關文章