Golang理解-Context包


為什么需要context

先舉個例子:

在 Go http包的Server中,每一個請求在都有一個對應的 goroutine 去處理。請求處理函數通常會啟動額外的 goroutine 用來訪問后端服務,比如數據庫和RPC服務。用來處理一個請求的 goroutine 通常需要訪問一些與請求特定的數據,比如終端用戶的身份認證信息、驗證相關的token、請求的截止時間。 當一個請求被取消或超時時,所有用來處理該請求的 goroutine 都應該迅速退出,然后系統才能釋放這些 goroutine 占用的資源。

對於多並發的情況下,傳統的方案:等待組sync.WaitGroup以及通過通道channel的方式的問題就會顯現出來;

對於等待組控制多並發的情況:只有所有的goroutine都結束了才算結束,只要有一個goroutine沒有結束, 那么就會一直等,這顯然對資源的釋放是緩慢的;

而對於通道Channel的方式下:通過在main goroutine中像chan中發送關閉停止指令,並配合select,從而達到關閉goroutine的目的,這種方式顯然比等待組優雅的多,但是在goroutine中在嵌套goroutine的情況就變得異常復雜。

等待組例子:

package main

import (
	  "fmt"
    "sync"
    "time"
    "strconv"
)

var wg sync.WaitGroup

func run(task string) {
    fmt.Println(task, "start。。。")
    time.Sleep(time.Second * 2)
    // 每個goroutine運行完畢后就釋放等待組的計數器
    wg.Done()
}

func main() {
    wg.Add(2)			// 需要開啟幾個goroutine就給等待組的計數器賦值為多少,這里為2
    for i := 1; i < 3; i++ {
        taskName := "task" + strconv.Itoa(i)
				go run(taskName)
    }
    // 等待,等待所有的任務都釋放
    wg.Wait()
    fmt.Println("所有任務結束。。。")
}

輸出結果:
task2 start。。。
task1 start。。。
所有任務結束。。。

上面例子中,一個任務結束了必須等待另外一個任務也結束了才算全部結束了,先完成的必須等待其他未完成的,所有的goroutine都要全部完成才OK。

這種方式的優點:使用等待組的並發控制模型,尤其適用於好多個goroutine協同做一件事情的時候,因為每個goroutine做的都是這件事情的一部分,只有全部的goroutine都完成,這件事情才算完成;

這種方式的缺陷:在實際生產中,需要我們主動的通知某一個 goroutine 結束。

比如我們開啟一個后台 goroutine 一直做事情,比如監控,現在不需要了,就需要通知這個監控 goroutine 結束,不然它會一直跑,就泄漏了。

通道➕select的方式

在等待組例子的最后拋出了一個問題,針對這種問題有2種辦法:

  1. 設置全局變量,在我們需要通知goroutine要停止的時候,我們為全局變量賦值,但是這樣我們必須保證線程安全,不可避免的我們要為全局變量加鎖,在便利性及性能上稍顯不足;
  2. 使用chan+select多路復用的方式,就會優雅許多;
package main

import (
	"fmt"
    "time"
)

func main() {
    stop := make(chan bool)
    // 開啟goroutine
    go func() {
        for {
            select {
            case <- stop:
                fmt.Println("任務1 結束了。。。")
            default:
                fmt.Println(" 任務1 正在運行中。")
                time.Sleep(time.Second * 2)
            }
        }
    }()
    
    // 運行10s后停止
    time.Sleep(time.Second * 10)
    fmt.Println("需要停止任務1。。。")
  	stop <- true
    time.Sleep(time.Second * 3)
}

運行結果:
 任務1 正在運行中。
 任務1 正在運行中。
 任務1 正在運行中。
 任務1 正在運行中。
 任務1 正在運行中。
需要停止任務1。。。
任務1 結束了。。。

上面例子中:我們定義一個 stop 的 chan,通知它結束后台 goroutine。

實現也非常簡單,在后台 goroutine 中,使用 select 判斷 stop 是否可以接收到值,如果可以接收到,就表示可以退出停止了;如果沒有接收到,就會執行 default 里的邏輯,繼續運行,直到收到 stop 的通知。

發送了 stop<- true 結束的指令后,我這里使用 time.Sleep(3 * time.Second) 故意停頓 3 秒來檢測我們結束任務goroutine 是否成功。

如果成功的話,不會再有 "任務1 正在運行中。" 的輸出了;如果沒有成功,監控 goroutine 就會繼續打印 "任務1 正在運行中。" 輸出。

channel配合select方式的優點:比較優雅,

channel配合select方式的劣勢:如果有很多 goroutine 都需要控制結束怎么辦?, 如果這些 goroutine 又衍生了其它更多的goroutine 怎么辦?

context的加入

context是GO1.7版本加入的一個標准庫,它定義了Context類型,專門用來簡化 對於處理單個請求的多個 goroutine 之間與請求域的數據、取消信號、截止時間等相關操作,這些操作可能涉及多個 API 調用。

對服務器傳入的請求應該創建上下文,而對服務器的傳出調用應該接受上下文。它們之間的函數調用鏈必須傳遞上下文,或者可以使用WithCancelWithDeadlineWithTimeoutWithValue創建的派生上下文。當一個上下文被取消時,它派生的所有上下文也被取消。

當一個goroutine在衍生一個goroutine時,context可以跟蹤到子goroutine,從而達到控制他們的目的;

使用context重寫上面的select例子

package main

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

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    // 開啟goroutine,傳入ctx
    go func(ctx context.Context) {
        for {
            select {
            case <- ctx.Done():
                fmt.Println("任務1 結束了。。。")
                return
            default:
                fmt.Println(" 任務1 正在運行中。")
                time.Sleep(time.Second * 2)
            }
        }
    }(ctx)
    
    // 運行10s后停止
    time.Sleep(time.Second * 10)
    fmt.Println("需要停止任務1。。。")
    // 使用context的cancel函數停止goroutine
    cancel()
    // 為了檢測監控過是否停止,如果沒有監控輸出,就表示停止了
    time.Sleep(time.Second * 3)
}

重寫比較簡單,就是把原來的 chan stop 換成 Context,使用 Context 跟蹤 goroutine,以便進行控制,比如結束等。

context.Background() 返回一個空的 Context,這個空的 Context 一般用於整個 Context 樹的根節點。然后我們使用 context.WithCancel(parent) 函數,創建一個可取消的子 Context,然后當作參數傳給 goroutine 使用,這樣就可以使用這個子 Context 跟蹤這個 goroutine。

在 goroutine 中,使用 select 調用<-ctx.Done()判斷是否要結束,如果接受到值的話,就可以返回結束 goroutine 了;如果接收不到,就會繼續進行運行任務。

那么是如何發送結束指令的呢?這就是示例中的 cancel 函數啦,它是我們調用context.WithCancel(parent) 函數生成子 Context 的時候返回的,第二個返回值就是這個取消函數,它是 CancelFunc 類型的。我們調用它就可以發出取消指令,然后我們的監控 goroutine 就會收到信號,就會返回結束。

Context控制多個goroutine

package main

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

// 使用context控制多個goroutine
func watch(ctx context.Context, name string) {
    for {
        select {
        case <- ctx.Done():
            fmt.Println(name, "退出 ,停止了。。。")
            return
        default:
            fmt.Println(name, "運行中。。。")
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go watch(ctx, "【任務1】")
    go watch(ctx, "【任務2】")
    go watch(ctx, "【任務3】")

    time.Sleep(time.Second * 10)
    fmt.Println("通知任務停止。。。。")
    cancel()
    time.Sleep(time.Second * 5)
    fmt.Println("真的停止了。。。")
}

上面例子中,啟動了 3 個監控 goroutine 進行不斷的運行任務,每一個都使用了 Context 進行跟蹤,當我們使用 cancel 函數通知取消時,這 3 個 goroutine 都會被結束。這就是 Context 的控制能力,它就像一個控制器一樣,按下開關后,所有基於這個 Context 或者衍生的子 Context 都會收到通知,這時就可以進行清理操作了,最終釋放 goroutine,這就優雅的解決了 goroutine 啟動后不可控的問題。

context接口

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

Context 的接口定義的比較簡潔,這個接口共有 4 個方法;

  1. Deadline :是獲取設置的截止時間的意思,第一個返回值是截止時間,到了這個時間點,Context 會自動發起取消請求;第二個返回值 ok==false 時表示沒有設置截止時間,如果需要取消的話,需要調用取消函數進行取消。
  2. Done:該方法返回一個只讀的 chan,類型為 struct{},我們在 goroutine 中,如果該方法返回的 chan 可以讀取,則意味着parent context已經發起了取消請求,我們通過 Done 方法收到這個信號后,就應該做清理操作,然后退出 goroutine,釋放資源。
  3. Err 方法返回取消的錯誤原因,因為什么 Context 被取消。
  4. Value方法獲取該 Context 上綁定的值,是一個鍵值對,所以要通過一個 Key 才可以獲取對應的值,這個值一般是線程安全的。

四個方法中常用的就是 Done 了,如果 Context 取消的時候,我們就可以得到一個關閉的 chan,關閉的 chan 是可以讀取的,所以只要可以讀取的時候,就意味着收到 Context 取消的信號了,以下是這個方法的經典用法。

func Stream(ctx context.Context, out chan<- Value) error {
  	for {
        v, err := DoSomething(ctx)
        if err != nil {
          return err
        }
        select {
        case <-ctx.Done():
          return ctx.Err()
        case out <- v:
        }
  	}
  }

Context 接口並不需要我們實現,Go 內置已經幫我們實現了 2 個,我們代碼中最開始都是以這兩個內置的作為最頂層的 partent context,衍生出更多的子 Context。

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

func Background() Context {
		return background
}

func TODO() Context {
		return todo
}
  1. Background()主要用於 main 函數、初始化以及測試代碼中,作為 Context 這個樹結構的最頂層的 Context,也就是根 Context。
  2. TODO(),它目前還不知道具體的使用場景,如果我們不知道該使用什么 Context 的時候,可以使用這個。

它們兩個本質上都是 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
}

這就是 emptyCtx 實現 Context 接口的方法,可以看到,這些方法什么都沒做,返回的都是 nil 或者零值。

Context的繼承衍生

有了如上的根 Context,那么是如何衍生更多的子 Context 的呢?這就要靠 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

這四個 With 函數,接收的都有一個 partent 參數,就是父 Context,我們要基於這個父 Context 創建出子 Context 的意思,這種方式可以理解為子 Context 對父 Context 的繼承,也可以理解為基於父 Context 的衍生。通過這些函數,就創建了一顆 Context 樹,樹的每個節點都可以有任意多個子節點,節點層級可以有任意多個。

  1. WithCancel 函數,傳遞一個父 Context 作為參數,返回子 Context,以及一個取消函數用來取消 Context。 WithDeadline 函數,和 WithCancel 差不多,它會多傳遞一個截止時間參數,意味着到了這個時間點,會自動取消 Context,當然我們也可以不等到這個時候,可以提前通過取消函數進行取消。

  2. WithTimeout WithDeadline 基本上一樣,這個表示是超時自動取消,是多少時間后自動取消 Context 的意思。

  3. WithValue 函數和取消 Context 無關,它是為了生成一個綁定了一個鍵值對數據的 Context,即給context設置值,這個綁定的數據可以通過 Context.Value 方法訪問到.

上面3個函數都會返回一個取消函數CancelFunc,這是一個函數類型,它的定義非常簡單type CancelFunc func(),該函數可以取消一個 Context,以及這個節點 Context下所有的所有的 Context,不管有多少層級。

Context傳遞元數據

package main

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

var key string = "name"

// 使用通過context向goroutinue傳遞值
func watch(ctx context.Context) {
    for {
        select{
        case <- ctx.Done():
            fmt.Println(ctx.Value(key), "退出,停止了。。。")
            return
        default:
            fmt.Println(ctx.Value(key), "運行中...")
            time.Sleep(2 * time.Second)
        }
    }
} 

func main() {
    ctx, cancel := context.WithCancel(context.Background())
  	// 給ctx綁定鍵值,傳遞給goroutine
    valuectx := context.WithValue(ctx, key, "【監控1】")
  	// 啟動goroutine
    go watch(valuectx)

    time.Sleep(time.Second * 10)
    fmt.Println("該結束了。。。")
  	// 運行結束函數
    cancel()
    time.Sleep(time.Second * 3)
    fmt.Println("真的結束了。。")
}

注意點

  1. context.WithValue 方法附加一對 K-V 的鍵值對,這里 Key 必須是等價性的,也就是具有可比性;Value 值要是線程安全的。
  2. 在使用值的時候,可以通過 Value 方法讀取 ctx.Value(key)。
  3. 使用 WithValue 傳值,一般是必須的值,不要什么值都傳遞。

Context最佳實戰

  1. 不要把 Context 放在結構體中,要以參數的方式傳遞
  2. 以 Context 作為參數的函數方法,應該把 Context 作為第一個參數,放在第一位
  3. 給一個函數方法傳遞 Context 的時候,不要傳遞 nil,如果不知道傳遞什么,就使用 context.TODO
  4. Context 的 Value 相關方法應該傳遞必須的數據,不要什么數據都使用這個傳遞
  5. Context 是線程安全的,可以放心的在多個 goroutine 中傳遞

參考博客
https://zhuanlan.zhihu.com/p/58967892
https://www.liwenzhou.com/posts/Go/go_context/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM