天天看點

32. 了解 Go 語言中的 Context

Hi,大家好,我是明哥。

在自己學習 Golang 的這段時間裡,我寫了詳細的學習筆記放在我的個人微信公衆号 《Go程式設計時光》,對于 Go 語言,我也算是個初學者,是以寫的東西應該會比較适合剛接觸的同學,如果你也是剛學習 Go 語言,不防關注一下,一起學習,一起成長。

我的線上部落格:http://golang.iswbm.com

我的 Github:github.com/iswbm/GolangCodingTime

1. 什麼是 Context?

在 Go 1.7 版本之前,context 還是非編制的,它存在于 golang.org/x/net/context 包中。

後來,Golang 團隊發現 context 還挺好用的,就把 context 收編了,在 Go 1.7 版本正式納入了标準庫。

Context,也叫上下文,它的接口定義如下

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

可以看到 Context 接口共有 4 個方法

  • Deadline

    :傳回的第一個值是 截止時間,到了這個時間點,Context 會自動觸發 Cancel 動作。傳回的第二個值是 一個布爾值,true 表示設定了截止時間,false 表示沒有設定截止時間,如果沒有設定截止時間,就要手動調用 cancel 函數取消 Context。
  • Done

    :傳回一個隻讀的通道(隻有在被cancel後才會傳回),類型為

    struct{}

    。當這個通道可讀時,意味着parent context已經發起了取消請求,根據這個信号,開發者就可以做一些清理動作,退出goroutine。
  • Err

    :傳回 context 被 cancel 的原因。
  • Value

    :傳回被綁定到 Context 的值,是一個鍵值對,是以要通過一個Key才可以擷取對應的值,這個值一般是線程安全的。

2. 為何需要 Context?

當一個協程(goroutine)開啟後,我們是無法強制關閉它的。

常見的關閉協程的原因有如下幾種:

  1. goroutine 自己跑完結束退出
  2. 主程序crash退出,goroutine 被迫退出
  3. 通過通道發送信号,引導協程的關閉。

第一種,屬于正常關閉,不在今天讨論範圍之内。

第二種,屬于異常關閉,應當優化代碼。

第三種,才是開發者可以手動控制協程的方法,代碼示例如下:

func main() {
	stop := make(chan bool)

	go func() {
		for {
			select {
			case <-stop:
				fmt.Println("監控退出,停止了...")
				return
			default:
				fmt.Println("goroutine監控中...")
				time.Sleep(2 * time.Second)
			}
		}
	}()

	time.Sleep(10 * time.Second)
	fmt.Println("可以了,通知監控停止")
	stop<- true
	//為了檢測監控過是否停止,如果沒有監控輸出,就表示停止了
	time.Sleep(5 * time.Second)

}
           

例子中我們定義一個

stop

的chan,通知他結束背景goroutine。實作也非常簡單,在背景goroutine中,使用select判斷

stop

是否可以接收到值,如果可以接收到,就表示可以退出停止了;如果沒有接收到,就會執行

default

裡的監控邏輯,繼續監控,隻到收到

stop

的通知。

以上是一個 goroutine 的場景,如果是多個 goroutine ,每個goroutine 底下又開啟了多個 goroutine 的場景呢?在 飛雪無情的部落格 裡關于為何要使用 Context,他是這麼說的

chan+select的方式,是比較優雅的結束一個goroutine的方式,不過這種方式也有局限性,如果有很多goroutine都需要控制結束怎麼辦呢?如果這些goroutine又衍生了其他更多的goroutine怎麼辦呢?如果一層層的無窮盡的goroutine呢?這就非常複雜了,即使我們定義很多chan也很難解決這個問題,因為goroutine的關系鍊就導緻了這種場景非常複雜。

在這裡我不是很贊同他說的話,因為我覺得就算隻使用一個通道也能達到控制(取消)多個 goroutine 的目的。下面就用例子來驗證一下。

該例子的原理是:使用 close 關閉通道後,如果該通道是無緩沖的,則它會從原來的阻塞變成非阻塞,也就是可讀的,隻不過讀到的會一直是零值,是以根據這個特性就可以判斷 擁有該通道的 goroutine 是否要關閉。

package main

import (
    "fmt"
    "time"
)

func monitor(ch chan bool, number int)  {
    for {
        select {
        case v := <-ch:
            // 僅當 ch 通道被 close,或者有資料發過來(無論是true還是false)才會走到這個分支
            fmt.Printf("監控器%v,接收到通道值為:%v,監控結束。\n", number,v)
            return
        default:
            fmt.Printf("監控器%v,正在監控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    stopSingal := make(chan bool)

    for i :=1 ; i <= 5; i++ {
        go monitor(stopSingal, i)
    }

    time.Sleep( 1 * time.Second)
    // 關閉所有 goroutine
    close(stopSingal)

    // 等待5s,若此時螢幕沒有輸出 <正在監控中> 就說明所有的goroutine都已經關閉
    time.Sleep( 5 * time.Second)

    fmt.Println("主程式退出!!")

}
           

輸出如下

監控器4,正在監控中...
監控器1,正在監控中...
監控器2,正在監控中...
監控器3,正在監控中...
監控器5,正在監控中...
監控器2,接收到通道值為:false,監控結束。
監控器3,接收到通道值為:false,監控結束。
監控器5,接收到通道值為:false,監控結束。
監控器1,接收到通道值為:false,監控結束。
監控器4,接收到通道值為:false,監控結束。
主程式退出!!
           

上面的例子,說明當我們定義一個無緩沖通道時,如果要對所有的 goroutine 進行關閉,可以使用 close 關閉通道,然後在所有的 goroutine 裡不斷檢查通道是否關閉(前提你得約定好,該通道你隻會進行 close 而不會發送其他資料,否則發送一次資料就會關閉一個goroutine,這樣會不符合咱們的預期,是以最好你對這個通道再做一層封裝做個限制)來決定是否結束 goroutine。

是以你看到這裡,我做為初學者還是沒有找到使用 Context 的必然理由,我隻能說 Context 是個很好用的東西,使用它友善了我們在處理并發時候的一些問題,但是它并不是不可或缺的。

換句話說,它解決的并不是 能不能 的問題,而是解決 更好用 的問題。

3. 簡單使用 Context

如果不使用上面 close 通道的方式,還有沒有其他更優雅的方法來實作呢?

有,那就是本文要講的 Context

我使用 Context 對上面的例子進行了一番改造。

package main

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

func monitor(ctx context.Context, number int)  {
    for {
        select {
        // 其實可以寫成 case <- ctx.Done()
        // 這裡僅是為了讓你看到 Done 傳回的内容
        case v :=<- ctx.Done():
            fmt.Printf("監控器%v,接收到通道值為:%v,監控結束。\n", number,v)
            return
        default:
            fmt.Printf("監控器%v,正在監控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx, i)
    }

    time.Sleep( 1 * time.Second)
    // 關閉所有 goroutine
    cancel()

    // 等待5s,若此時螢幕沒有輸出 <正在監控中> 就說明所有的goroutine都已經關閉
    time.Sleep( 5 * time.Second)

    fmt.Println("主程式退出!!")

}
           

這裡面的關鍵代碼,也就三行

第一行:以 context.Background() 為 parent context 定義一個可取消的 context

第二行:然後你可以在所有的goroutine 裡利用 for + select 搭配來不斷檢查 ctx.Done() 是否可讀,可讀就說明該 context 已經取消,你可以清理 goroutine 并退出了。

第三行:當你想到取消 context 的時候,隻要調用一下 cancel 方法即可。這個 cancel 就是我們在建立 ctx 的時候傳回的第二個值。

運作結果輸出如下。可以發現我們實作了和 close 通道一樣的效果。

監控器3,正在監控中...
監控器4,正在監控中...
監控器1,正在監控中...
監控器2,正在監控中...
監控器2,接收到通道值為:{},監控結束。
監控器5,接收到通道值為:{},監控結束。
監控器4,接收到通道值為:{},監控結束。
監控器1,接收到通道值為:{},監控結束。
監控器3,接收到通道值為:{},監控結束。
主程式退出!!
           

4. 根Context 是什麼?

建立 Context 必須要指定一個 父 Context,當我們要建立第一個Context時該怎麼辦呢?

不用擔心,Go 已經幫我們實作了2個,我們代碼中最開始都是以這兩個内置的context作為最頂層的parent context,衍生出更多的子Context。

var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}
           

一個是Background,主要用于main函數、初始化以及測試代碼中,作為Context這個樹結構的最頂層的Context,也就是根Context,它不能被取消。

一個是TODO,如果我們不知道該使用什麼Context的時候,可以使用這個,但是實際應用中,暫時還沒有使用過這個TODO。

他們兩個本質上都是emptyCtx結構體類型,是一個不可取消,沒有設定截止時間,沒有攜帶任何值的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 interface{}) interface{} {
	return nil
}

           

5. Context 的繼承衍生

上面在定義我們自己的 Context 時,我們使用的是

WithCancel

這個方法。

除它之外,context 包還有其他幾個 With 系列的函數

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。

通過一次繼承,就多實作了一個功能,比如使用 WithCancel 函數傳入 根context ,就建立出了一個子 context,該子context 相比 父context,就多了一個 cancel context 的功能。

如果此時,我們再以上面的子context(context01)做為父context,并将它做為第一個參數傳入WithDeadline函數,獲得的子子context(context02),相比子context(context01)而言,又多出了一個超過 deadline 時間後,自動 cancel context 的功能。

接下來我會舉例介紹一下這幾種 context,其中 WithCancel 在上面已經講過了,下面就不再舉例了

例子 1:WithDeadline

package main

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

func monitor(ctx context.Context, number int)  {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("監控器%v,監控結束。\n", number)
            return
        default:
            fmt.Printf("監控器%v,正在監控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx01, cancel := context.WithCancel(context.Background())
    ctx02, cancel := context.WithDeadline(ctx01, time.Now().Add(1 * time.Second))

    defer cancel()

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx02, i)
    }

    time.Sleep(5  * time.Second)
    if ctx02.Err() != nil {
        fmt.Println("監控器取消的原因: ", ctx02.Err())
    }

    fmt.Println("主程式退出!!")
}
           

輸出如下

監控器5,正在監控中...
監控器1,正在監控中...
監控器2,正在監控中...
監控器3,正在監控中...
監控器4,正在監控中...
監控器3,監控結束。
監控器4,監控結束。
監控器2,監控結束。
監控器1,監控結束。
監控器5,監控結束。
監控器取消的原因:  context deadline exceeded
主程式退出!!
           

例子 2:WithTimeout

WithTimeout 和 WithDeadline 使用方法及功能基本一緻,都是表示超過一定的時間會自動 cancel context。

唯一不同的地方,我們可以從函數的定義看出

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
           

WithDeadline 傳入的第二個參數是 time.Time 類型,它是一個絕對的時間,意思是在什麼時間點逾時取消。

而 WithTimeout 傳入的第二個參數是 time.Duration 類型,它是一個相對的時間,意思是多長時間後逾時取消。

package main

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

func monitor(ctx context.Context, number int)  {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("監控器%v,監控結束。\n", number)
            return
        default:
            fmt.Printf("監控器%v,正在監控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx01, cancel := context.WithCancel(context.Background())
  
  	// 相比例子1,僅有這一行改動
    ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)

    defer cancel()

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx02, i)
    }

    time.Sleep(5  * time.Second)
    if ctx02.Err() != nil {
        fmt.Println("監控器取消的原因: ", ctx02.Err())
    }

    fmt.Println("主程式退出!!")
}
           

輸出的結果和上面一樣

監控器1,正在監控中...
監控器5,正在監控中...
監控器3,正在監控中...
監控器2,正在監控中...
監控器4,正在監控中...
監控器4,監控結束。
監控器2,監控結束。
監控器5,監控結束。
監控器1,監控結束。
監控器3,監控結束。
監控器取消的原因:  context deadline exceeded
主程式退出!!
           

例子 3:WithValue

通過Context我們也可以傳遞一些必須的中繼資料,這些資料會附加在Context上以供使用。

中繼資料以 Key-Value 的方式傳入,Key 必須有可比性,Value 必須是線程安全的。

還是用上面的例子,以 ctx02 為父 context,再建立一個能攜帶 value 的ctx03,由于他的父context 是 ctx02,是以 ctx03 也具備逾時自動取消的功能。

package main

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

func monitor(ctx context.Context, number int)  {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("監控器%v,監控結束。\n", number)
            return
        default:
          	// 擷取 item 的值
            value := ctx.Value("item")
            fmt.Printf("監控器%v,正在監控 %v \n", number, value)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx01, cancel := context.WithCancel(context.Background())
    ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)
    ctx03 := context.WithValue(ctx02, "item", "CPU")

    defer cancel()

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx03, i)
    }

    time.Sleep(5  * time.Second)
    if ctx02.Err() != nil {
        fmt.Println("監控器取消的原因: ", ctx02.Err())
    }

    fmt.Println("主程式退出!!")
}
           

輸出如下

監控器4,正在監控 CPU 
監控器5,正在監控 CPU 
監控器1,正在監控 CPU 
監控器3,正在監控 CPU 
監控器2,正在監控 CPU 
監控器2,監控結束。
監控器5,監控結束。
監控器3,監控結束。
監控器1,監控結束。
監控器4,監控結束。
監控器取消的原因:  context deadline exceeded
主程式退出!!
           

6. Context 使用注意事項

  1. 通常 Context 都是做為函數的第一個參數進行傳遞(規範性做法),并且變量名建議統一叫 ctx
  2. Context 是線程安全的,可以放心地在多個 goroutine 中使用。
  3. 當你把 Context 傳遞給多個 goroutine 使用時,隻要執行一次 cancel 操作,所有的 goroutine 就可以收到 取消的信号
  4. 不要把原本可以由函數參數來傳遞的變量,交給 Context 的 Value 來傳遞。
  5. 當一個函數需要接收一個 Context 時,但是此時你還不知道要傳遞什麼 Context 時,可以先用 context.TODO 來代替,而不要選擇傳遞一個 nil。
  6. 當一個 Context 被 cancel 時,繼承自該 Context 的所有 子 Context 都會被 cancel。

7. 參考文章

  • 飛雪無情的部落格

系列導讀

01. 開發環境的搭建(Goland & VS Code)

02. 學習五種變量建立的方法

**03. 詳解資料類型:**整形與浮點型

04. 詳解資料類型:byte、rune與string

05. 詳解資料類型:數組與切片

06. 詳解資料類型:字典與布爾類型

07. 詳解資料類型:指針

08. 面向對象程式設計:結構體與繼承

09. 一篇文章了解 Go 裡的函數

10. Go語言流程控制:if-else 條件語句

11. Go語言流程控制:switch-case 選擇語句

12. Go語言流程控制:for 循環語句

13. Go語言流程控制:goto 無條件跳轉

14. Go語言流程控制:defer 延遲調用

15. 面向對象程式設計:接口與多态

16. 關鍵字:make 和 new 的差別?

17. 一篇文章了解 Go 裡的語句塊與作用域

18. 學習 Go 協程:goroutine

19. 學習 Go 協程:詳解信道/通道

20. 幾個信道死鎖經典錯誤案例詳解

21. 學習 Go 協程:WaitGroup

22. 學習 Go 協程:互斥鎖和讀寫鎖

23. Go 裡的異常處理:panic 和 recover

24. 超詳細解讀 Go Modules 前世今生及入門使用

25. Go 語言中關于包導入必學的 8 個知識點

26. 如何開源自己寫的子產品給别人用?

27. 說說 Go 語言中的類型斷言?

28. 這五點帶你了解Go語言的select用法

32. 了解 Go 語言中的 Context