golang中context的Q&A
阿新 • • 發佈:2022-03-09
標準庫 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等