1. 程式人生 > 其它 >golang中context的Q&A

golang中context的Q&A

標準庫 context Q&A

參考文件

context如何被取消

1. context.Context講解
type Context interface {
	// 返回context是否會被取消,以及自動取消時間
	Deadline() (deadline time.Time, ok bool)

	// 當context被取消了,或者到了最後的deadline, 返回一個被關閉的channel
	Done() <-chan struct{}

	// 在channel Done()關閉後,返回context取消原因
	Err() error

	// 獲取key對應的value
	Value(key interface{}) interface{}
}
2. Context是一個介面,定義了4個方法,它們都是冪等的,也就是說連續多次呼叫同一個方法,得到的結果是相同的
3. Done()返回一個channel, 可以表示context被取消的訊號,當這個channel被關閉了,說明context被取消了
    注意這裡是一個只讀的channel, 我們知道讀取一個已關閉的channel,會讀出響應型別的零值,並且原始碼裡沒有地方
    會向這個channel塞入值,換句話說,這個一個read-only的channel,因此在子協程裡讀取這個channel,
    除非被關閉,否則讀取不出來任何東西, 也正是利用了這一點,子協程從channel中讀出值(零值)後,就可以收尾退出了
4. Err() 返回一個錯誤,表示channel被關閉的原因,例如是被取消,還是超時
5. Deadline() 返回context的截止時間
6. Value() 獲取之前設定的key對應的value
7. context.Background()通常用在main函式中,作為所有context的根節點
    context.TODO()通常用在字並不知道傳遞什麼context的情形,例如呼叫一個需要傳遞context的函式,
    你手頭並沒有其它context可以傳遞,這時就可以傳遞TODO(),這常常發生在重構中,給一些函式添加了一個Context引數
    但不知道要傳遞什麼,就用TODO佔個位子,最終要換成其他context

context是什麼

1. go1.7標準庫引入context,中文名就是"上下文", 準確說它是goroutine的上下文,包含goroutine的執行狀態、環境、現場等資訊
main goroutine 通知 child goroutine退出任務的案例
func main() {
	messages := make(chan int, 10)
	done := make(chan bool)  // 建立一個無緩衝只讀channel

	go func() {
		var ticker = time.NewTicker(time.Second)
		for _ = range ticker.C{  // 遍歷只讀channel
			select {
			case <-done:
				fmt.Println("main goroutine 通知 child goroutine結束了...")
				return
			default:
				fmt.Println("messages: ", <-messages)
			}
		}
	}()

	for i := 0; i < 10; i++{
		messages <- i
	}

	time.Sleep(time.Second * 5)  // 主協程對上面的子協程有5秒的超時控制
	close(done)  // 關閉 channel
	time.Sleep(time.Second)
	fmt.Println("main goroutine 結束了...")

}
2. 上述例子中定義了一個buffer為0的channel done, 子協程執行這定時任務,如果主協程需要在某個時刻傳送訊息通知
    子協程中斷任務並退出,那麼就可以讓子協程監聽這個done channel, 一旦主協程關閉done channel,那麼子協程就可以
    退出了,這樣實現了主協程通知子協程的需求,但是還是有限的
3. 假如我們可以在簡單的通知上附加額外的資訊來控制取消,為什麼取消,或者有一個它必須要完成的最終期限
    更或者有多個取消選項,我們需要額外的資訊來判斷選擇執行哪個取消選項
4. 考慮下面這種情況,假如主協程有多個任務,1,2,m,主協程對這些任務有超時控制,
    而其中任務1又有多個子任務1,2,n, 任務1對這些子任務也有超時控制,
    那麼這些子任務即要感知主協程的取消訊號,也要感知任務1的取消訊號,
5. 如果還是使用done channel的方法,我們需要定義兩個done channel, 子任務需要監聽這兩個done channel
    這樣好像也還行,但是如果層級更深的話,這些子任務還有子任務的話,那麼使用done channel的方法將變得非常繁瑣且混亂
6. 我們需要優雅的方案來實現這一種機制:
    * 上層任務取消後,所有的下層任務都會被取消
    * 中間某一層任務取消後,只會將當前任務的下層任務全部取消,而不會影響上層任務及同級任務
    這個時候,context就派上用場了  
7. Context介面包含四個方法:
    Deadline返回綁定當前context的任務被取消的截止時間;如果沒有設定期限,將返回ok == false。
    Done 當綁定當前context的任務被取消時,將返回一個關閉的channel;如果當前context不會被取消,將返回nil。
    Err 如果Done返回的channel沒有關閉,將返回nil;如果Done返回的channel已經關閉,將返回非空的值表示任務結束的原因。如果是context被取消,Err將返回Canceled;如果是context超時,Err將返回DeadlineExceeded。
    Value 返回context儲存的鍵值對中當前key對應的值,如果沒有對應的key,則返回nil。
8. 可以看到Done()方法返回的channel用來傳遞結束訊號以中斷當前任務,
    Deadline()方法指示一段時間後當前goroutine是否會被取消,
    以及一個Err()方法用來解釋goroutine被取消的原因,而Value用於獲取當前任務樹的額外資訊,
9. Background()方法和TODO()方法生成的context其實是一致的,那麼我們何時呼叫哪個呢
    background通常用於主函式、初始化、以及測試中使用,作為一個頂層的context,
    也就是說一般我們建立的context都是基於bakground
    TODO()是在不確定使用什麼context的時候使用

兩種不同功能的基礎context型別,valueCtx、cancelCtx

type valueCtx struct {
	Context
	key, val interface{}
}
func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}
1. valueCtx嵌入了Context變數來表示父節點context,表示當前context繼承了父context的所有資訊
    valueCtx還攜帶了一組鍵值對,也就是說這種context可以攜帶額外的資訊,valueCtx實現了Value方法
    用以在context鏈路上獲取key對應的值,如果當前context不存在需要的key,會沿著context鏈向上
    尋找key對應的值,直到根節點
WithValue
1. WithValue用以向context新增鍵值對,
func WithValue(parent Context, key, val interface{}) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}
2. 這裡新增鍵值對不是在原結構體上直接新增,而是以此context作為父節點,重新黃建一個valueCtx子節點
    將鍵值對新增到子節點上,由此形成一條context鏈,獲取value的過程就是在此context鏈上由尾部向前搜尋
cancelCtx
type cancelCtx struct {
	Context

	mu       sync.Mutex            // protects following fields
	done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
	children map[canceler]struct{} // set to nil by the first cancel call
	err      error                 // set to non-nil by the first cancel call
}
1. cancelCtx跟valueCtx類似,cancelCtx結構體中也有一個變數context作為父節點,
    變數done表示一個channel, 用來表示傳遞關閉訊號,children表示一個map,用來儲存當前context節點下的子節點
    err儲存錯誤資訊表示被取消的原因
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	if err == nil {
		panic("context: internal error: missing cancel error")
	}
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return // already canceled
	}
	// 設定取消原因
	c.err = err
	d, _ := c.done.Load().(chan struct{})
	// 設定一個關閉的channel或者將done channel關閉,用以傳送關閉的訊號
	if d == nil {
		c.done.Store(closedchan)
	} else {
		close(d)
	}
	// 將子節點context依次取消
	for child := range c.children {
		// NOTE: acquiring the child's lock while holding parent's lock.
		child.cancel(false, err)
	}
	c.children = nil
	c.mu.Unlock()

	// 將當前context節點從父節點上移除
	if removeFromParent {
		removeChild(c.Context, c)
	}
}
2. 可以發現cancelCtx型別的變數其實也是canceler型別,因為cancelCtx實現了canceler介面,
    Done()方法返回的是通知goroutine取消的訊號通道,Err()方法返回的是被取消的原因
    cancelCtx型別的context在呼叫cancel方法時,會設定取消原因,將done channel設定為一個關閉的channel
    或者關閉channel, 然後將子節點context依次取消,如果有需要還會將當前節點從父節點直接移除
WithCancel
1. WithCancel函式用來建立一個可取消的context, 即cancelCtx型別的context, 
    WithCancel()方法返回一個cancelCtx和CancelFunc, 呼叫CancelFunc即可觸發cancel操作,看原始碼
type CancelFunc func()

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}
TimerCtx
1. timerCtx是一種基於cancelCtx的context型別,從字面上就可以看出,這是一種可以定時取消的context型別
    timerCtx增加了兩個欄位,timer和deadline, timer:計時器,deadline:截止日期
2. timerCtx內部使用cancelCtx實現取消,另外使用定時器timer和過期時間deadline實現定時取消的功能,
    timerCtx在呼叫cancel方法時,會先將內部的cancelCtx取消,如果需要則將自己從cancelCtx祖先節點上移除
    最後取消計時器
WithDeadline
1. 如果父節點parent有過期時間,並且過期時間<設定的時間d,那麼新建的子節點context無須設定過期時間
    使用WithCancel建立一個可取消的context即可
2. 否則就利用parent和過期時間d建立一個定時取消的timerCtx, 並建立context與可取消context祖先節點的
    取消關聯關係,接下來判斷當前時間具體過期時間d的時長dur
3. 如果dur<0,表明當前已經過了過期,則直接取消新的timerCtx
4. 為新建的timerCtx設定定時器,一旦達到過期時間就取消當前的timerCtx
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	// 如果父節點parent有過期時間,並且過期時間<設定的時間d,那麼新建的子節點context無須設定過期時間
	// 使用WithCancel建立一個可取消的context即可
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		return WithCancel(parent)
	}
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
	propagateCancel(parent, c)
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded) // deadline has already passed
		return c, func() { c.cancel(false, Canceled) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	// 為新建的timerCtx設定定時器,一旦達到過期時間就取消當前的timerCtx
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}
WithTimeout
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}
1. 與WithDeadline類似,WithTimeout也是建立一個定時取消的context,只不過WithDeadline是接收一個過期時間點
    而WithTimeout是接收一個過期時長,看原始碼也知道,WithTimeout也是呼叫的WithDeadline

Context的使用

1. 使用context實現文章開頭done channel的例子示範一下如何更優雅的實現協程間取消訊號的同步
func main() {
	messages := make(chan int, 10)

	for i := 0; i < 10; i++{
		messages <- i
	}

	// 建立帶過期時間的context
	ctx, cancel := context.WithTimeout(context.Background(), time.Second * 5)

	go func(ctx context.Context) {
		ticker := time.NewTicker(time.Second)
		for _ = range ticker.C{  // 遍歷通道,一秒執行一次
			select{
			case <-ctx.Done():
				fmt.Println("child goroutine 結束了...")
				return
			default:
				fmt.Println("receive message: ", <-messages)
			}
		}
	}(ctx)

	defer close(messages)
	defer cancel()  // 此語句可以省略,因為context是帶過期時間的,不需要手動取消

	select{
	case <-ctx.Done():
		time.Sleep(time.Second)
		fmt.Println("main goroutine 結束了")
	}

}
2. 這個例子中,只要讓子協程監聽主協程傳入的ctx,一旦ctx.Done()方法返回空channel, 子執行緒即可取消執行任務
    但是這個例子還無法展現context的傳遞取消資訊的強大優勢
3. net/http包的原始碼裡在實現http server就用到了context, 簡單分析下
4. 重點:
這樣處理的目的主要有以下幾點:
1. 一旦請求超時,即可中斷當前請求;
2. 在處理構建response過程中如果發生錯誤,可直接呼叫response物件的cancelCtx方法結束當前請求;
3. 在處理構建response完成之後,呼叫response物件的cancelCtx方法結束當前請求。
* 在整個server處理流程中,使用了一條context鏈貫穿Server、Connection、Request,不僅將上游的資訊共享給下游任務,
* 同時實現了上游可傳送取消訊號取消所有下游任務,而下游任務自行取消不會影響上游任務。

context是什麼?

1. context主要用來在goroutine之間傳遞上下文資訊,包括取消訊號,超時時間,截止時間,k-v等
2. 隨著context包的引入,標準庫中很多介面也增加了context引數,例如database/sql,
    context幾乎成為了併發控制和超時控制的標準做法

context有什麼用?

1. go通常用來寫後臺服務,只需要幾行程式碼就可以寫一個http server,在go的server裡
    通常每來一個請求都會啟動若干個goroutine同時工作,有些去資料庫拿資料,有些呼叫下游介面獲取相關資料
    這些goroutine需要共享這個請求的基本資料,例如登入的token,處理請求的最大超時時間
    當請求被取消或者超時,所有為這個請求工作的goroutine需要快速退出,系統就可以回收相關資源
2. go語言中的server實際上是一個"協程模型", 也就是說一個協程處理一個請求,例如業務高峰期,某個下游伺服器響應變慢
    而當前系統的請求又沒有超時控制,或者說超時時間設定的過大,那麼等待下游伺服器返回資料的協程會越來越多,
    協程也是需要消耗資源的,如果攜程數量激增,記憶體暴漲,甚至導致服務不可用,更嚴重會導致雪崩效應,整個服務對外不可用,P0級別的事故
3. 上面說的P0級別事故,可以通過設定下游伺服器最大處理時間就可以避免,給下游伺服器設定timeout=5ms,
    如果這個時間沒有接收到資料,就直接返回給客戶端一個預設值或錯誤,
4. context包就是為了解決上面這些問題而開發的,在一組goroutine之間傳遞共享的值,取消訊號,超時控制,截止日期
5. 用簡練的話來說,在go裡面,我們不能直接殺死協程,需要通過channel+select的方法來關閉協程,
    但是在某些場景下,例如一個請求衍生了很多個協程,這些協程間是相互關聯的,需要共享一些全域性變數,有共同的deadline
    而且可以同時被關閉,再用channel+select就會比較麻煩,這是就可以通過context來實現
* 一句話解決:context用來解決goroutine之間 退出通知、元資料傳遞 的功能
6. 【引申1】舉例說明context在實際專案中如何使用
    context會在函式傳遞間傳遞,只需要在適當的時間呼叫cancel函式就可以向goroutine發出取消訊號
    或者呼叫Value函式取出context中的值
7. context使用注意4個事項
    1. 不要將context塞入結構體裡,直接將context作為函式的第一引數,而且一般命名為ctx
    2. 不要向函式傳入一個nil context,如果你是在不知道傳遞什麼,標準庫給提供好了一個context:todo
    3. 不要把本應該作為函式引數的型別放入到context中,context應該儲存一些共同的資料,例如登入的session、cookie等
    4. 同一個context有可能會被傳入到多個goroutine,別擔心,context是併發安全的
context可以傳遞共享的資料
1. 對於web服務端開發,往往希望將一個請求處理的整個過程串起來,這非常依賴於Thread Local(對於go可以理解為單個協程所獨有的)
    變數,go語言中沒有Thread Local這個概念,所以在呼叫函式是需要傳遞context
func main() {
	bgCtx := context.Background()
	process(bgCtx)

	vCtx := context.WithValue(bgCtx, "traceId", "123456")
	process(vCtx)
}

func process(ctx context.Context){
	if traceId, ok := ctx.Value("traceId").(string); ok{
		fmt.Printf("process over, traceId = %s\n", traceId)
	} else {
		fmt.Println("process over, no traceId")
	}
}
/*
	process over, no traceId
	process over, traceId = 123456
*/
context可以訊號通知取消goroutine
1. 設想一個場景,開啟外賣訂單,上面顯示外賣小哥的位置,而且每秒更新一次,app向後端發起websocket連結後,
    後臺啟動一個協程,每隔一秒計算一次小哥的位置併發送給前端,如果使用者退出訂單頁面,後臺需要取消此過程,
    退出goroutine,系統回收資源
func Perform(ctx context.Context) {
    for {
        calculatePos()
        sendResult()
        select {
        case <-ctx.Done():
            // 被取消,直接返回
            return
        case <-time.After(time.Second):
            // block 1 秒鐘 
        }
    }
}

ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
go Perform(ctx)
// ……
// app 端返回頁面,呼叫cancel 函式
cancel()
2. 注意一個細節,WithTimeout函式返回的context和cancel是分開的,context本身並沒有取消函式,
    這樣做的原因是取消函式只能由外層函式呼叫,防止子節點contxt呼叫取消函式,從而嚴格控制資訊的流向
    由父節點context流向子節點context
防止goroutine洩漏
1. 舉一個例子,如果不用context取消,goroutine就會洩漏的例子
func gen() <-chan int {
	ch := make(chan int)
	var n int
	go func() {
		for {
			ch <- n
			n++
			time.Sleep(time.Second)
		}
	}()
	return ch
}

func main() {
	for v := range gen(){
		fmt.Println(v)
		if v == 5{
			break
		}
	}
	time.Sleep(time.Second * 5)
}
2. 這是一個可以無限生成整數的協程,但如果我們只要產生的前5個數,那麼就會發生goroutine洩漏
    當n == 5, 直接break掉,那麼gen函式的協程就會執行無限迴圈,發生了goroutine洩漏
3. 用context改進這個例子
func gen(ctx context.Context) <-chan int {
	ch := make(chan int)
	go func() {
		var n int
		for {
			select{
			case <-ctx.Done():  // 監聽主協程發出的退出訊號
				return
			case ch <- n:
				n++
				time.Sleep(time.Millisecond * 200)
			}
		}
	}()
	return ch
}

func main() {
	cancelCtx, cancelFunc := context.WithCancel(context.Background())
	defer cancelFunc()  // 避免其它地方忘記cancel,並且重複呼叫不影響

	for v := range gen(cancelCtx){
		fmt.Println(v)
		if v == 5{
			cancelFunc()  // 取消所有子協程, 然後退出迴圈
			break
		}
	}
}
4. 增加一個context, 在break前呼叫cancel函式,取消子goroutine, gen()函式在接收到取消訊號後,
    直接退出,系統回收資源

context.Value的查詢過程是怎樣的

type valueCtx struct {
	Context
	key, val interface{}
}

func (c *valueCtx) String() string {
	return contextName(c.Context) + ".WithValue(type " +
		reflectlite.TypeOf(c.key).String() +
		", val " + stringify(c.val) + ")"
}

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}
1. 由於它直接將Context作為匿名欄位,因此儘管它只實現了兩個方法String()和Value(),其它方法繼承自Context
    但它仍然是一個context,這是go語言的一個特點
2. 建立valueCtx函式:
func WithValue(parent Context, key, val interface{}) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}
3. 對key的要求是可比較,因為之後要通過key取出context中的值,可比較是必須的,通過層層傳遞context,
    最終形成一棵樹
4. 和連結串列有點像,只是它的方向相反,Context指向它的父節點,而連結串列指向下一個節點,
    通過WithValue函式可以建立層層的valueCtx,儲存goroutine間可以共享的變數,
    取值的過程,實際上是一個遞迴查詢的過程
func (c *valueCtx) Value(key interface{}) interface{} {
	// 取值的過程,實際上是一個遞迴查詢的過程,先從當前valueCtx中的key查詢
	// 如果不存在就從它的父節點的Context中去查詢,層層遞迴,直到找到或nil為止
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}
5. 它會順著鏈路一直往上找,比較當前節點的key是否是要查詢的key,如果是,則直接返回value,
    否則一直順著context往前,最終找到根節點(一般是emtpyCtx),直接返回一個nil, 
    所以用Value方法的時候要判斷,結果是否為nil
6. 因為查詢方向是往上走的,所以父節點沒法獲取子節點的值,子節點卻可以獲取父節點的值,
7. WithValue: 建立context節點的過程實際上就是建立連結串列節點的過程,兩個節點的key值是可以相等的,
    但他們是兩個不同的context節點,查詢的時候會先從當前節點查詢,如果找不到一直找到最後根節點,
    整體而言,用WithValue構造的其實是一個低效率的連結串列
8. 注意:如果能用函式引數傳遞的儘量不要使用context傳參,context儘量傳遞一些共享的變數如session,cookie等