天天看點

Context:多線程并發控制

一、協程如何退出

一個協程啟動後,大部分情況需要等待裡面的代碼執行完畢,然後協程會自行退出。但是如果有一種情景,需要讓協程提前退出怎麼辦呢?

package main

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

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        watchDog("【監控狗1】")
    }()
    wg.Wait()
}

func watchDog(name string) {
    // 開啟for select循環,一直背景監控
    for {
        select {
        default:
            fmt.Println(name, "正在監控... ...")
        }
        time.Sleep(1 * time.Second)
    }
}      

通過 watchDog 函數實作了一個監控狗,它會一直在背景運作,每隔一秒就會列印"監控狗正在監控……"的文字。

​如果需要讓監控狗停止監控、退出程式,一個辦法是定義一個全局變量,其他地方可以通過修改這個變量發出停止監控狗的通知。然後在協程中先檢查這個變量,如果發現被通知關閉就停止監控,退出目前協程。

​但是這種方法需要通過加鎖來保證多協程下并發的安全,基于這個思路,有個更新版的方案:用 select+channel 做檢測

package main

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

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    // 用來停止監控狗
    stopCh := make(chan bool)
    go func() {
        defer wg.Done()
        watchDog(stopCh, "【監控狗1】")
    }()
    // 想讓監控狗監控5秒
    time.Sleep(5 * time.Second)
    // 發出停止指令
    stopCh <- true
    wg.Wait()
}

func watchDog(stopCh chan bool,name string) {
    // 開啟for select循環,一直背景監控
    for {
        select {
        case <- stopCh:
            fmt.Println(name, "停止指令已收到,馬上停止")
            return
        default:
            fmt.Println(name, "正在監控... ...")
        }
        time.Sleep(1 * time.Second)
    }
}      

​這個示例是使用 select+channel 的方式改造的 watchDog 函數,實作了通過 channel 發送指令讓監控狗停止,進而達到協程退出的目的。

​1. 為 watchDog 函數增加 stopCh 參數,用于接收停止指令

2. 在 main 函數中,聲明用于停止的 stopCh,傳遞給 watchDog 函數,然後通過 stopCh<-true 發送停止指令讓協程退出​

二、Context

通過 select+channel 讓協程退出的方式比較優雅,但是如果我們希望做到同時取消很多個協程呢?如果是定時取消協程又該怎麼辦?這時候 select+channel 的局限性就凸現出來了,即使定義了多個 channel 解決問題,代碼邏輯也會非常複雜、難以維護。

要解決這種複雜的協程問題,必須有一種可以跟蹤協程的方案,隻有跟蹤到每個協程,才能更好地控制它們,這種方案就是 Go 語言标準庫為我們提供的 Context。

package main

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

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    ctx, stop := context.WithCancel(context.Background())
    go func() {
        defer wg.Done()
        watchDog(ctx, "【監控狗1】")
    }()
    // 先讓監控狗監控5秒
    time.Sleep(5 * time.Second)
    // 發停止指令
    stop()
    wg.Wait()
}

func watchDog(ctx context.Context, name string) {
    // 開啟for select循環,一直背景監控
    for {
        select {
        case <-ctx.Done():
            fmt.Println(name, "停止指令已收到,馬上停止")
            return
        default:
            fmt.Println(name, "正在監控... ...")
        }
        time.Sleep(1 * time.Second)
    }
}      

相比于 select + channel的方案,Context方案有4點改動:

  1. watchDog 的 stopCh 參數換成了 ctx,類型為 context.Context
  2. 原來的 case <-stopCh 改為 case <-ctx.Done(),用于判斷是否停止
  3. 使用 context.WithCancel(context.Background()) 函數生成一個可以取消的 Context,用于發送停止指令。這裡的 context.Background() 用于生成一個空 Context,一般作為整個 Context 樹的根節點
  4. 原來的 stopCh <- true 停止指令,改為 context.WithCancel 函數傳回的取消函數 stop()。

可以看到,這和修改前的整體代碼結構一樣,隻不過從 channel 換成了 Context。

什麼是Context

一個任務會有很多個協程協作完成,一次 HTTP 請求也會觸發很多個協程的啟動,而這些協程有可能會啟動更多的子協程,并且無法預知有多少層協程、每一層有多少個協程。

如果因為某些原因導緻任務終止了,HTTP 請求取消了,那麼它們啟動的協程怎麼辦?該如何取消呢?因為取消這些協程可以節約記憶體,提升性能,同時避免不可預料的 Bug。

Context 就是用來簡化解決這些問題的,并且是并發安全的。Context 是一個接口,它具備手動、定時、逾時發出取消信号、傳值等功能,主要用于控制多個協程之間的協作,尤其是取消操作。一旦取消指令下達,那麼被 Context 跟蹤的這些協程都會收到取消信号,就可以做清理和退出操作。

Context 接口隻有四個方法:

type Context interface {
   Deadline() (deadline time.Time, ok bool)
   Done() <-chan struct{}
   Err() error
   Value(key interface{}) interface{}
}      
  1. Deadline 方法可以擷取設定的截止時間,第一個傳回值 deadline 是截止時間,到了這個時間點,Context 會自動發起取消請求,第二個傳回值 ok 代表是否設定了截止時間。
  2. Done 方法傳回一個隻讀的 channel,類型為 struct{}。在協程中,如果該方法傳回的 chan 可以讀取,則意味着 Context 已經發起了取消信号。通過 Done 方法收到這個信号後,就可以做清理操作,然後退出協程,釋放資源。
  3. Err 方法傳回取消的錯誤原因,即因為什麼原因 Context 被取消。
  4. Value 方法擷取該 Context 上綁定的值,是一個鍵值對,是以要通過一個 key 才可以擷取對應的值。
Context 接口的四個方法中最常用的就是 Done 方法,它傳回一個隻讀的 channel,用于接收取消信号。當 Context 取消的時候,會關閉這個隻讀 channel,也就等于發出了取消信号。

三、Context樹

我們不需要自己實作 Context 接口,Go 語言提供了函數可以幫助我們生成不同的 Context,通過這些函數可以生成一顆 Context 樹,這樣 Context 才可以關聯起來,父 Context 發出取消信号的時候,子 Context 也會發出,這樣就可以控制不同層級的協程退出。

從使用功能上分,有四種實作好的 Context

  1. 空Context:不可取消,沒有截止時間,主要用于 Context 樹的根節點
  2. 可取消的Context:用于發出取消信号,當取消的時候,它的子 Context 也會取消。
  3. 可定時取消的Context:多了一個定時的功能
  4. 值Context:用于存儲一個 key-value 鍵值對

從下圖 Context 的衍生樹可以看到,最頂部的是空 Context,它作為整棵 Context 樹的根節點,在 Go 語言中,可以通過 context.Background() 擷取一個根節點 Context。

Context:多線程并發控制

有了根節點 Context 後,這顆 Context 樹要怎麼生成呢?需要使用 Go 語言提供的四個函數。

  1. WithCancel(parent Context):生成一個可取消的Context。
  2. WithDeadline(parent Context, d time.Time):生成一個可定時取消的 Context,參數 d 為定時取消的具體時間。
  3. WithTimeout(parent Context, timeout time.Duration):生成一個可逾時取消的 Context,參數 timeout 用于設定多久後取消。
  4. WithValue(parent Context, key, val interface{}):生成一個可攜帶 key-value 鍵值對的 Context。

以上四個生成 Context 的函數中,前三個都屬于可取消的 Context,它們是一類函數,最後一個是值 Context,用于存儲一個 key-value 鍵值對。

四、使用Context取消多個協程

package main

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

func main() {
    var wg sync.WaitGroup
    wg.Add(3)
    ctx, stop := context.WithCancel(context.Background())
    go func() {
        defer wg.Done()
        watchDog(ctx, "【監控狗1】")
    }()
    go func() {
        defer wg.Done()
        watchDog(ctx, "【監控狗2】")
    }()
    go func() {
        defer wg.Done()
        watchDog(ctx, "【監控狗3】")
    }()
    time.Sleep(5 * time.Second)
    stop()
    wg.Wait()
}

func watchDog(ctx context.Context, name string) {
    // 開啟for select循環,一直背景監控
    for {
        select {
        case <-ctx.Done():
            fmt.Println(name, "停止指令已收到,馬上停止")
            return
        default:
            fmt.Println(name, "正在監控... ...")
        }
        time.Sleep(1 * time.Second)
    }
}      

示例中增加了兩個監控狗,也就是增加了兩個協程,這樣一個 Context 就同時控制了三個協程,一旦 Context 發出取消信号,這三個協程都會取消退出。

​以上示例中的 Context 沒有子 Context,如果一個 Context 有子 Context,在該 Context 取消時會發生什麼呢?下面通過一幅圖說明:

Context:多線程并發控制

可以看到,當節點 Ctx2 取消時,它的子節點 Ctx4、Ctx5 都會被取消,如果還有子節點的子節點,也會被取消。也就是說根節點為 Ctx2 的所有節點都會被取消,其他節點如 Ctx1、Ctx3 和 Ctx6 則不會。

五、Context 傳值

Context 不僅可以取消,還可以傳值,通過這個能力,可以把 Context 存儲的值供其他協程使用。

package main

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

func main() {
    var wg sync.WaitGroup
    wg.Add(4)
    ctx, stop := context.WithCancel(context.Background())
    valCtx := context.WithValue(ctx, "userId", 2)
    go func() {
        defer wg.Done()
        getUser(valCtx)

    }()
    go func() {
        defer wg.Done()
        watchDog(ctx, "【監控狗1】")
    }()
    go func() {
        defer wg.Done()
        watchDog(ctx, "【監控狗2】")
    }()
    go func() {
        defer wg.Done()
        watchDog(ctx, "【監控狗3】")
    }()
    time.Sleep(5 * time.Second)
    stop()
    wg.Wait()
}

func getUser(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("【擷取使用者】", "協程退出")
            return
        default:
            userId := ctx.Value("userId")
            fmt.Println("【擷取使用者】", "使用者ID為", userId)
            time.Sleep(1 * time.Second)
        }
    }
}

func watchDog(ctx context.Context, name string) {
    // 開啟for select循環,一直背景監控
    for {
        select {
        case <-ctx.Done():
            fmt.Println(name, "停止指令已收到,馬上停止")
            return
        default:
            fmt.Println(name, "正在監控... ...")
        }
        time.Sleep(1 * time.Second)
    }
}      

通過 context.WithValue 函數存儲一個 userId 為 2 的鍵值對,就可以在 getUser 函數中通過 ctx.Value("userId") 方法把對應的值取出來,達到傳值的目的。

六、Context 使用原則

Context 是一種非常好的工具,使用它可以很友善地控制取消多個協程。在 Go 語言标準庫中也使用了它們,比如 net/http 中使用 Context 取消網絡的請求。

​要更好地使用 Context,有一些使用原則需要盡可能地遵守。

  1. ​Context 不要放在結構體中,要以參數的方式傳遞。
  2. Context 作為函數的參數時,要放在第一位,也就是第一個參數。
  3. 要使用 context.Background 函數生成根節點的 Context,也就是最頂層的 Context。
  4. Context 傳值要傳遞必須的值,而且要盡可能地少,不要什麼都傳。
  5. Context 多協程安全,可以在多個協程中放心使用。
  1. 《22 講通關 Go 語言》

繼續閱讀