天天看點

Go 語言中的 Context

Go 語言中的 Context
Go 語言中的 Context

什麼是 Context

Go 1.7 标準庫引入 Context(上下文) ,準确說它是 goroutine 的上下文,包含 goroutine 的運作狀态、環境、現場等資訊。Context 主要用來在 goroutine 之間傳遞上下文資訊。Context 幾乎成為了并發控制和逾時控制的标準做法。

Context 接口定義如下:

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

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

  • ​Deadline()​

    ​​ :傳回的第一個值是 context 的截止時間,到了這個時間點,Context 會自動觸發 Cancel 動作。傳回的第二個值是布爾值, ​

    ​true​

    ​ 表示設定了截止時間, ​

    ​false​

    ​ 表示沒有設定截止時間,如果沒有設定截止時間,就要手動調用 cancel 函數取消 Context 。
  • ​Done()​

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

    ​struct{}​

    ​。在子協程裡讀這個 channel ,除非被關閉,否則讀不出任何東西,根據這一點,就可以做一些清理動作,退出 goroutine 。
  • ​Err()​

    ​ :傳回 context 被 cancel 的原因。例如是被取消,還是逾時。
  • ​Value()​

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

上面這些方法都是用于讀取的,不能進行設定。

Go 語言中的 Context
Go 語言中的 Context

使用 Context 控制協程

一個協程開啟後,我們無法強制關閉,一般關閉協程的原因有以下幾種:

  • 協程執行完正常退出
  • 主協程退出,子協程被迫退出
  • 通過信道發送信号,引導協程關閉

下面是一個使用信道控制協程的例子:

package main

import (
 "fmt"
 "time"
)

func main() {
    // 定義 chan
 c := make(chan bool)

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

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

上面的程式中,我們定義了一個 chan ,通過該 chan 引導子協程關閉。開啟子協程後,我們讓主協程休眠 10s ,子協程不斷循環,使用 ​

​select​

​​ 判斷 chan 是否可以接收到值,如果可以接收到,則退出子協程;否則執行 ​

​default​

​ 繼續監控,直到接收到值為止。該程式運作後輸出如下:

監控子協程中...
監控子協程中...
監控子協程中...
監控子協程中...
監控子協程中...
通知監控停止
監控退出,停止了...      

這證明了我們可以通過信道引導協程的關閉。

當然,使用信道可以控制多個協程,下面是使用一個信道控制多個子協程的例子:

package main

import (
 "fmt"
 "time"
)

func monitor(c chan bool, num int) {
 for {
  select {
  case value := <- c:
   fmt.Printf("監控器%v 接收值%v 監控結束\n", num, value)
   return
  default:
   fmt.Printf("監控器%v 監控中...\n", num)
   time.Sleep(2 * time.Second)
  }
 }
}

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

 for i := 0; i < 3; i++ {
  go monitor(c, i)
 }

 time.Sleep(time.Second)
 // 關閉所有的子協程
 close(c)
 // 為了檢測監控過是否停止,如果沒有監控輸出,就表示停止了
 time.Sleep(5 * time.Second)
 fmt.Println("主程式退出")
}      

上面的代碼中,我們使用 ​

​close​

​ 關閉通道後,如果該通道是無緩沖的,則它會從原來的阻塞變成非阻塞,也就是可讀的,不過讀到的會一直是零值,是以根據這個特性就可以判斷擁有該通道的協程是否要關閉。運作該程式輸出如下:

監控器0 監控中...
監控器1 監控中...
監控器2 監控中...
監控器2 接收值false 監控結束
監控器0 接收值false 監控結束
監控器1 接收值false 監控結束
主程式退出      

到這裡,我們一直講的是使用信道控制協程,還沒提到 Context 呢。那麼既然能用信道控制協程,為什麼還要用 Context 呢?因為使用 Context 更好用而且更優雅,下面是基于上面例子使用 Context 控制協程的代碼:

package main

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

func monitor(con context.Context, num int) {
 for {
  select {
  // 判斷 con.Done() 是否可讀
  // 可讀就說明該 context 已經取消
  case value := <- con.Done():
   fmt.Printf("監控器%v 接收值%v 監控結束\n", num, value)
   return
  default:
   fmt.Printf("監控器%v 監控中...\n", num)
   time.Sleep(2 * time.Second)
  }
 }
}

func main() {
 // 為 parent context 定義一個可取消的 context
 con, cancel := context.WithCancel(context.Background())

 for i := 0; i < 3; i++ {
  go monitor(con, i)
 }

 time.Sleep(time.Second)
 // 關閉所有的子協程
 // 取消 context 的時候,隻要調用一下 cancel 方法即可
 // 這個 cancel 就是在建立 con 的時候傳回的第二個值
 cancel()
 // 為了檢測監控過是否停止,如果沒有監控輸出,就表示停止了
 time.Sleep(5 * time.Second)
 fmt.Println("主程式退出")
}      

運作該程式輸出如下:

監控器2 監控中...
監控器0 監控中...
監控器1 監控中...
監控器0 接收值{} 監控結束
監控器2 接收值{} 監控結束
監控器1 接收值{} 監控結束
主程式退出      

可以看到和上面使用信道控制協程的效果相同。

Go 語言中的 Context
Go 語言中的 Context

根 Context

建立 Context 必須要指定一個父 Context,建立第一個 Context 時,指定的父 Context 是 Go 中已經實作的 Context ,Go 中内置的 Context 有以下兩個:

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

func Background() Context {
 return background
}

func TODO() Context {
 return todo
}      

可以看到,其中一個内置的 Context 是 ​

​Background​

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

而另一個内置的 Context 是 ​

​TODO​

​ ,當你不知道要使用什麼 Context 的時候就可以使用這個,但其實看源碼和上面那個内置的 Context 隻是名稱不一樣罷了,一般使用上面那個。

再者,我們看到内置的 Context 都是 ​

​emptyCtx​

​ 結構體類型,檢視源碼不難發現,它是一個不可取消,沒有設定截止時間,沒有攜帶任何值的 Context 。

// An emptyCtx is never canceled, has no values, and has no deadline. It is not
// struct{}, since vars of this type must have distinct addresses.
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
}      
Go 語言中的 Context
Go 語言中的 Context

Context API

context 包有幾個 With 系列的函數,它們可以可以傳回新 Context :

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

以上函數都有一個共同點,第一個參數都接收一個父 context 。下面簡要介紹上面這幾個函數。

WithCancel()

它傳回一個 context ,并傳回一個 ​

​CancelFunc​

​​ 無參函數,如果調用該函數,其會往 ​

​context.Done()​

​ 這個方法的 chan 發送一個取消信号。讓所有監聽這個 chan 的都知道該 context 被取消了。上面使用 Context 控制協程的例子已經示範了這一點。

WithDeadline()

它同樣會傳回一個 ​

​CancelFunc​

​​ 無參函數,但它參數帶有一個時間戳(time.Time),即截止時間,當到達截止時間, ​

​context.Done()​

​ 這個方法的 chan 就會自動接收到一個完成的信号。

WithTimeout()

這個函數和上面的 ​

​WithDeadline()​

​ 差不多,但它帶有一個具體的時間段(time.Duration)。

​WithDeadline()​

​​ 傳入的第二個參數是 ​

​time.Time​

​ 類型,它是一個絕對的時間,意思是在什麼時間點逾時取消。

而 ​

​WithTimeout()​

​​ 傳入的第二個參數是 ​

​time.Duration​

​ 類型,它是一個相對的時間,意思是多長時間後逾時取消。

WithValue()

使用該函數可以在原有的 context 中可以添加一些值,然後傳回一個新的 context 。這些資料以 Key-Value 的方式傳入,Key 必須有可比性,Value 必須是線程安全的。

Go 語言中的 Context
Go 語言中的 Context

Request Context

下面簡單講一講請求上下文, Request 有一個方法:

func (r *Request) Context() context.Context {
 if r.ctx != nil {
  return r.ctx
 }
 return context.Background()
}      

該方法傳回目前請求的上下文。還有一個方法:

func(*Request) WithContext(ctx context.Context) context.Context      

該方法基于目前的 Context 進行“修改”,實際上是建立一個新的 Context ,因為 Context 是不允許修改的。下面是一個使用 Context 處理請求逾時的例子,這個例子基于上一期的中間件的例子,先在 ​

​middleware​

​​ 目錄下補充中間件 ​

​TimeoutMiddleware​

​ :

package middleware

import (
 "context"
 "net/http"
 "time"
)

type TimeoutMiddleware struct {
 Next http.Handler
}

func (tm *TimeoutMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request)  {
 if tm.Next == nil {
  tm.Next = http.DefaultServeMux
 }

 // 擷取目前請求的上下文
 ctx := r.Context()
 // “修改” Context 設定 2s 逾時
 ctx, _ = context.WithTimeout(ctx, 2 * time.Second)
 // 建立一個新的 Context 代替目前請求的 Context
 r.WithContext(ctx)
 // 接收信号的信道
 ch := make(chan struct{})

 go func() {
  tm.Next.ServeHTTP(w, r)
  // 執行完給信道發送執行完信号
  ch <- struct{}{}
 }()

 select {
 // 正常處理能得到執行完信号 傳回
 case <-ch:
  return
 // 從 ctx.Done() 得到信号證明逾時
 // 傳回逾時響應
 case <-ctx.Done():
  w.WriteHeader(http.StatusRequestTimeout)
 }
 ctx.Done()
}      

修改 main.go 把 ​

​TimeoutMiddleware​

​​ 中間件套在 ​

​AuthMiddleware​

​ 外面:

package main

import (
 "encoding/json"
 "goweb/middleware"
 "net/http"
 "time"
)

type Person struct {
 Name string
 Age int64
}

func main() {
 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  p := Person{
   Name: "Caizi",
   Age: 18,
  }

  enc := json.NewEncoder(w)
  enc.Encode(p)
 })
 http.ListenAndServe("localhost:8080", &middleware.TimeoutMiddleware{
  Next: new(middleware.AuthMiddleware),
 })
}      

同樣,我們利用上次的 test.http 進行測試:

GET http://localhost:8080/ HTTP/1.1
Authorization: a      

測試的結果是正常的,接下來再修改 main.go 增加休眠 3s 代碼,使其響應逾時:

package main

import (
 "encoding/json"
 "goweb/middleware"
 "net/http"
 "time"
)

type Person struct {
 Name string
 Age int64
}

func main() {
 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  p := Person{
   Name: "Caizi",
   Age: 18,
  }

  // 休眠 3s
  time.Sleep(3 * time.Second)

  enc := json.NewEncoder(w)
  enc.Encode(p)
 })
 http.ListenAndServe("localhost:8080", &middleware.TimeoutMiddleware{
  Next: new(middleware.AuthMiddleware),
 })
}      

測試後結果傳回 ​

​408 Request Timeout​

​ 。