Go之context包的分析

context是Go語言官方定義的一個包,稱之爲上下文。

Go中的context包在與API和慢進程交互時可以派上用場,特別是在提供Web請求的生產級系統中。在哪裏,您可能想要通知所有goroutines停止工作並返回。

這是一個基本教程,介紹如何在項目中使用它以及一些最佳實踐和陷阱。

先決條件

在瞭解上下文之前,請先了解以下概念

Context

在Go語言中 context 包允許您傳遞一個 “context” 到您的程序,如超時或截止日期(deadline)或通道(channel),以及指示停止運行和返回等。例如,如果您正在執行Web請求或運行系統命令,那麼對生產級系統進行超時控制通常是個好主意。因爲,如果您依賴的API運行緩慢,您不希望在系統上備份請求,這可能最終會增加負載並降低您所服務的所有請求的性能。導致級聯效應。這是超時或截止日期context可以派上用場的地方。

這裏我們先來分析context源碼( https://golang.org/src/context/context.go)。

context包的核心就是Context接口,其定義如下:

type Context interface {

    Deadline() (deadline time.Time, ok bool)

    Done() <-chan struct{}
    Err() error   
    Value(key interface{}) interface{}
}

這個接口共有4個方法:

  • Deadline`方法是獲取設置的截止時間的意思,第一個返回式是截止時間,到了這個時間點,Context會自動發起取消請求;第二個返回值ok==false時表示沒有設置截止時間,如果需要取消的話,需要調用取消函數進行取消。
  • Done方法返回一個只讀的chan,類型爲struct{},我們在goroutine中,如果該方法返回的chan可以讀取,則意味着parent context已經發起了取消請求,我們通過Done方法收到這個信號後,就應該做清理操作,然後退出goroutine,釋放資源。
  • Err方法返回取消的錯誤原因,因爲什麼Context被取消。
  • Value方法獲取該Context上綁定的值,是一個鍵值對,所以要通過一個Key纔可以獲取對應的值,這個值一般是線程安全的。但使用這些數據的時候要注意同步,比如返回了一個map,而這個map的讀寫則要加鎖。

以上四個方法中常用的就是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個(Background、TODO),我們代碼中最開始都是以這兩個內置的作爲最頂層的partent context(即根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的場景,可能當前函數以後會更新以便使用 context。

這兩個函數的本質是emptyCtx結構體類型。

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系列的函數了。

1、取消函數

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

此函數接收一個parent Context參數,父 context 可以是後臺 context 或傳遞給函數的 context。

返回派生 context 和取消函數。只有創建它的函數才能調用取消函數來取消此 context。如果您願意,可以傳遞取消函數,但是,強烈建議不要這樣做。這可能導致取消函數的調用者沒有意識到取消 context 的下游影響。可能存在源自此的其他 context,這可能導致程序以意外的方式運行。簡而言之,永遠不要傳遞取消函數。

示例

package main

import (
   "fmt"
   "time"
   "golang.org/x/net/context"
)

func main() {
   //創建一個可取消子context,context.Background():返回一個空的Context,這個空的Context一般用於整個Context樹的根節點。
   ctx, cancel := context.WithCancel(context.Background())
   go func(ctx context.Context) {
      for {
         select {
         //使用select調用<-ctx.Done()判斷是否要結束
         case <-ctx.Done():
            fmt.Println("goroutine exit")
            return
         default:
            fmt.Println("goroutine running.")
            time.Sleep(2 * time.Second)
         }
      }
   }(ctx)

   time.Sleep(10 * time.Second)
   fmt.Println("main fun exit")
   //取消context
   cancel()
   time.Sleep(5 * time.Second)

 }

2、超時控制

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

此函數返回其父項的派生 context,當截止日期超過或取消函數被調用時,該 context 將被取消。例如,您可以創建一個將在以後的某個時間自動取消的 context,並在子函數中傳遞它。當因爲截止日期耗盡而取消該 context 時,獲此 context 的所有函數都會收到通知去停止運行並返回。

示例

package main

import (
   "fmt"
   "golang.org/x/net/context"
   "time"
)

func main() {
   d := time.Now().Add(2 * time.Second)
   //設置超時控制WithDeadline,超時時間2
   ctx, cancel := context.WithDeadline(context.Background(), d)

   defer cancel()
   select {
   case <-time.After(3 * time.Second):
      fmt.Println("timeout")
   case <-ctx.Done():
      //2到了到了,執行該代碼
      fmt.Println(ctx.Err())
   }

}

3、超時控制

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

此函數類似於 context.WithDeadline。不同之處在於它將持續時間作爲參數輸入而不是時間對象。此函數返回派生 context,如果調用取消函數或超出超時持續時間,則會取消該派生 context。

package main

import (
   "fmt"
   "golang.org/x/net/context"
   "time"
)

func main() {

   //設置超時控制WithDeadline,超時時間2
   ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)

   defer cancel()
   select {
   case <-time.After(3 * time.Second):
      fmt.Println("timeout")
   case <-ctx.Done():
      //2到了到了,執行該代碼
      fmt.Println(ctx.Err())
   }

}

4、返回派生的context

func WithValue(parent Context, key, val interface{}) Context:

此函數接收 context 並返回派生 context,其中值 val 與 key 關聯,並通過 context 樹與 context 一起傳遞。這意味着一旦獲得帶有值的 context,從中派生的任何 context 都會獲得此值。不建議使用 context 值傳遞關鍵參數,而是函數應接收簽名中的那些值,使其顯式化。

示例

package main

import (
   "context"
   "fmt"
)

func Route(ctx context.Context) {
   ret, ok := ctx.Value("id").(int)
   if !ok {
      ret = 1
   }
   fmt.Printf("id:%d\n", ret)
   s, _ := ctx.Value("name").(string)
   fmt.Printf("name:%s\n", s)
}

func main() {
   ctx := context.WithValue(context.Background(), "id", 123)
   ctx = context.WithValue(ctx, "name", "jerry")
   Route(ctx)
}

在函數中接受和使用context

在下面的示例中,您可以看到接受context的函數啓動goroutine並等待返回該goroutine或取消該context。select語句幫助我們選擇先發生的任何情況並返回。

<-ctx.Done()關閉“完成”通道後,將case <-ctx.Done():選中該通道。一旦發生這種情況,該功能應該放棄工作並準備返回。這意味着您應該關閉所有打開的管道,釋放資源並從函數返回。有些情況下,釋放資源可以阻止返回,比如做一些掛起的清理等等。在處理context返回時,你應該注意任何這樣的可能性。

本節後面的示例有一個完整的go程序,它說明了超時和取消功能。

//Function that does slow processing with a context
//Note that context is the first argument
func sleepRandomContext(ctx context.Context, ch chan bool) {

  //Cleanup tasks
  //There are no contexts being created here
  //Hence, no canceling needed
  defer func() {
    fmt.Println("sleepRandomContext complete")
    ch <- true
  }()

  //Make a channel
  sleeptimeChan := make(chan int)

  //Start slow processing in a goroutine
  //Send a channel for communication
  go sleepRandom("sleepRandomContext", sleeptimeChan)

  //Use a select statement to exit out if context expires
  select {
  case <-ctx.Done():
    //If context expires, this case is selected
    //Free up resources that may no longer be needed because of aborting the work
    //Signal all the goroutines that should stop work (use channels)
    //Usually, you would send something on channel,
    //wait for goroutines to exit and then return
    //Or, use wait groups instead of channels for synchronization
    fmt.Println("Time to return")
  case sleeptime := <-sleeptimeChan:
    //This case is selected when processing finishes before the context is cancelled
    fmt.Println("Slept for ", sleeptime, "ms")
  }
}

例子

到目前爲止,我們已經看到使用 context 可以設置截止日期,超時或調用取消函數來通知所有使用任何派生 context 的函數來停止運行並返回。以下是它如何工作的示例:

main 函數

  • 用 cancel 創建一個 context
  • 隨機超時後調用取消函數

doWorkContext 函數

  • 派生一個超時 context
  • 這個 context 將被取消當
    • main 調用取消函數或
    • 超時到或
    • doWorkContext 調用它的取消函數
  • 啓動 goroutine 傳入派生context執行一些慢處理
  • 等待 goroutine 完成或context被 main goroutine 取消,以優先發生者爲準

sleepRandomContext 函數

  • 開啓一個 goroutine 去做些緩慢的處理
  • 等待該 goroutine 完成或,
  • 等待 context 被 main goroutine 取消,操時或它自己的取消函數被調用

sleepRandom 函數

  • 隨機時間休眠
  • 此示例使用休眠來模擬隨機處理時間,在實際示例中,您可以使用通道來通知此函數,以開始清理並在通道上等待它,以確認清理已完成。

Playground: https://play.golang.org/p/grQAUN3MBlg (看起來我使用的隨機種子,在 playground 時間沒有真正改變,您需要在你本機執行去看隨機性)

Github: https://github.com/pagnihotry/golang_samples/blob/master/go_context_sample.go

package main

import (
  "context"
  "fmt"
  "math/rand"
  "time"
)

//Slow function
func sleepRandom(fromFunction string, ch chan int) {
  //defer cleanup
  defer func() { fmt.Println(fromFunction, "sleepRandom complete") }()

  //Perform a slow task
  //For illustration purpose,
  //Sleep here for random ms
  seed := time.Now().UnixNano()
  r := rand.New(rand.NewSource(seed))
  randomNumber := r.Intn(100)
  sleeptime := randomNumber + 100
  fmt.Println(fromFunction, "Starting sleep for", sleeptime, "ms")
  time.Sleep(time.Duration(sleeptime) * time.Millisecond)
  fmt.Println(fromFunction, "Waking up, slept for ", sleeptime, "ms")

  //write on the channel if it was passed in
  if ch != nil {
    ch <- sleeptime
  }
}

//Function that does slow processing with a context
//Note that context is the first argument
func sleepRandomContext(ctx context.Context, ch chan bool) {

  //Cleanup tasks
  //There are no contexts being created here
  //Hence, no canceling needed
  defer func() {
    fmt.Println("sleepRandomContext complete")
    ch <- true
  }()

  //Make a channel
  sleeptimeChan := make(chan int)

  //Start slow processing in a goroutine
  //Send a channel for communication
  go sleepRandom("sleepRandomContext", sleeptimeChan)

  //Use a select statement to exit out if context expires
  select {
  case <-ctx.Done():
    //If context is cancelled, this case is selected
    //This can happen if the timeout doWorkContext expires or
    //doWorkContext calls cancelFunction or main calls cancelFunction
    //Free up resources that may no longer be needed because of aborting the work
    //Signal all the goroutines that should stop work (use channels)
    //Usually, you would send something on channel, 
    //wait for goroutines to exit and then return
    //Or, use wait groups instead of channels for synchronization
    fmt.Println("sleepRandomContext: Time to return")
  case sleeptime := <-sleeptimeChan:
    //This case is selected when processing finishes before the context is cancelled
    fmt.Println("Slept for ", sleeptime, "ms")
  }
}

//A helper function, this can, in the real world do various things.
//In this example, it is just calling one function.
//Here, this could have just lived in main
func doWorkContext(ctx context.Context) {

  //Derive a timeout context from context with cancel
  //Timeout in 150 ms
  //All the contexts derived from this will returns in 150 ms
  ctxWithTimeout, cancelFunction := context.WithTimeout(ctx, time.Duration(150)*time.Millisecond)

  //Cancel to release resources once the function is complete
  defer func() {
    fmt.Println("doWorkContext complete")
    cancelFunction()
  }()

  //Make channel and call context function
  //Can use wait groups as well for this particular case
  //As we do not use the return value sent on channel
  ch := make(chan bool)
  go sleepRandomContext(ctxWithTimeout, ch)

  //Use a select statement to exit out if context expires
  select {
  case <-ctx.Done():
    //This case is selected when the passed in context notifies to stop work
    //In this example, it will be notified when main calls cancelFunction
    fmt.Println("doWorkContext: Time to return")
  case <-ch:
    //This case is selected when processing finishes before the context is cancelled
    fmt.Println("sleepRandomContext returned")
  }
}

func main() {
  //Make a background context
  ctx := context.Background()
  //Derive a context with cancel
  ctxWithCancel, cancelFunction := context.WithCancel(ctx)

  //defer canceling so that all the resources are freed up 
  //For this and the derived contexts
  defer func() {
    fmt.Println("Main Defer: canceling context")
    cancelFunction()
  }()

  //Cancel context after a random time
  //This cancels the request after a random timeout
  //If this happens, all the contexts derived from this should return
  go func() {
    sleepRandom("Main", nil)
    cancelFunction()
    fmt.Println("Main Sleep complete. canceling context")
  }()
  //Do work
  doWorkContext(ctxWithCancel)
}

缺陷

  • 如果函數接收 context 參數,確保檢查它是如何處理取消通知的。例如,exec.CommandContext 不會關閉讀取管道,直到命令執行了進程創建的所有分支(Github 問題:https://github.com/golang/go/issues/23019 )之前,不關閉讀取器管道,這意味着context取消不會立即返回,直到等待cmd.Wait()外部命令的所有分支都已完成處理。如果您使用超時或最後執行時間的最後期限,您可能會發現這不能按預期工作。如果遇到任何此類問題,可以使用執行超時time.After

  • 在Google,我們要求Go程序員將Context參數作爲傳入和傳出請求之間的調用路徑上的每個函數的第一個參數傳遞。

    這就意味着如果您正在編寫一個具有可能需要大量時間的函數的庫,並且您的庫可能會被服務器應用程序使用,那麼您必須接受這些函數中的context。當然,我可以context.TODO()隨處通過,但這造成程序可讀性差,程序看起來不夠優雅。

小結

  1. context.Background只應在最高級別使用,作爲所有派生context的根。
  2. context.TODO應該用在不確定要使用什麼的地方,或者是否將更新當前函數以便將來使用context。
  3. context 取消是建議性的,功能可能需要時間來清理和退出。
  4. context.Value應該很少使用,它永遠不應該用於傳遞可選參數。這使得API隱含並且可能引入錯誤。相反,這些值應作爲參數傳遞。
  5. 不要把context放在結構中,在函數中顯式傳遞它們,最好是作爲第一個參數。
  6. 如果您不確定要使用什麼,請不要傳遞nil,而是使用TODO。
  7. Contextstruct沒有cancel方法,因爲只有派生context的函數才能取消它。
  8. Context是線程安全的,可以放心的在多個goroutine中傳遞。

參考:

http://p.agnihotry.com/post/understanding_the_context_package_in_golang/

https://www.flysnow.org/2017/05/12/go-in-action-go-context.html

https://faiface.github.io/post/context-should-go-away-go2/

links

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章