用 10 分鐘了解 Go 語言 context package 使用場景及介紹

golang logo

context 是在 Go 語言 1.7 版才正式被納入官方標準庫內,為什麼今天要介紹 context 使用方式呢?原因很簡單,在初學 Go 時,寫 API 時,常常不時就會看到在 http handler 的第一個參數就會是 ctx context.Context,而這個 context 在這邊使用的目的及含義到底是什麼呢,本篇就是帶大家了解什麼是 context,以及使用的場景及方式,內容不會提到 context 的原始碼,而是用幾個實際例子來了解。

教學影片

如果對於課程內容有興趣,可以參考底下課程。

如果需要搭配購買請直接透過 FB 聯絡我,直接匯款(價格再減 100

使用 WaitGroup

學 Go 時肯定要學習如何使用併發 (goroutine),而開發者該如何控制併發呢?其實有兩種方式,一種是 WaitGroup,另一種就是 context,而什麼時候需要用到 WaitGroup 呢?很簡單,就是當您需要將同一件事情拆成不同的 Job 下去執行,最後需要等到全部的 Job 都執行完畢才繼續執行主程式,這時候就需要用到 WaitGroup,看個實際例子

package main

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

func main() {
    var wg sync.WaitGroup

    wg.Add(2)
    go func() {
        time.Sleep(2 * time.Second)
        fmt.Println("job 1 done.")
        wg.Done()
    }()
    go func() {
        time.Sleep(1 * time.Second)
        fmt.Println("job 2 done.")
        wg.Done()
    }()
    wg.Wait()
    fmt.Println("All Done.")
}

上面範例可以看到主程式透過 wg.Wait() 來等待全部 job 都執行完畢,才印出最後的訊息。這邊會遇到一個情境就是,雖然把 job 拆成多個,並且丟到背景去跑,可是使用者該如何透過其他方式來終止相關 goroutine 工作呢 (像是開發者都會寫背景程式監控,需要長時間執行)?例如 UI 上面有停止的按鈕,點下去後,如何主動通知並且停止正在跑的 Job,這邊很簡單,可以使用 channel + select 方式。

使用 channel + select

package main

import (
    "fmt"
    "time"
)

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

    go func() {
        for {
            select {
            case <-stop:
                fmt.Println("got the stop channel")
                return
            default:
                fmt.Println("still working")
                time.Sleep(1 * time.Second)
            }
        }
    }()

    time.Sleep(5 * time.Second)
    fmt.Println("stop the gorutine")
    stop <- true
    time.Sleep(5 * time.Second)
}

上面可以看到,透過 select + channel 可以快速解決這問題,只要在任何地方將 bool 值丟入 stop channel 就可以停止背景正在處理的 Job。上述用 channel 來解決此問題,但是現在有個問題,假設背景有跑了無數個 goroutine,或者是 goroutine 內又有跑 goroutine 呢,變得相當複雜,例如底下的狀況

cancel

這邊就沒辦法用 channel 方式來進行處理了,而需要用到今天的重點 context。

認識 context

從上圖可以看到我們建立了三個 worker node 來處理不同的 Job,所以會在主程式最上面宣告一個主 context.Background(),然後在每個 worker node 分別在個別建立子 context,其最主要目的就是當關閉其中一個 context 就可以直接取消該 worker 內正在跑的 Job。拿上面的例子進行改寫

package main

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

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go func() {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("got the stop channel")
                return
            default:
                fmt.Println("still working")
                time.Sleep(1 * time.Second)
            }
        }
    }()

    time.Sleep(5 * time.Second)
    fmt.Println("stop the gorutine")
    cancel()
    time.Sleep(5 * time.Second)
}

其實可以看到只是把原本的 channel 換成使用 context 來處理,其他完全不變,這邊提到使用了 context.WithCancel,使用底下方式可以擴充 context

ctx, cancel := context.WithCancel(context.Background())

這用意在於每個 worknode 都有獨立的 cancel func 開發者可以透過其他地方呼叫 cancel() 來決定哪一個 worker 需要被停止,這時候可以做到使用 context 來停止多個 goroutine 的效果,底下看看實際例子

package main

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

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go worker(ctx, "node01")
    go worker(ctx, "node02")
    go worker(ctx, "node03")

    time.Sleep(5 * time.Second)
    fmt.Println("stop the gorutine")
    cancel()
    time.Sleep(5 * time.Second)
}

func worker(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println(name, "got the stop channel")
            return
        default:
            fmt.Println(name, "still working")
            time.Sleep(1 * time.Second)
        }
    }
}

上面透過一個 context 可以一次停止多個 worker,看邏輯如何宣告 context 以及什麼時機去執行 cancel(),通常我個人都是搭配 graceful shutdown 進行取消正在跑的 Job,或者是停止資料庫連線等等..

心得

初學 Go 時,如果還不常使用 goroutine,其實也不會理解到 context 的使用方式及時機,等到需要有背景處理,以及該如何停止 Job,這時候才漸漸瞭解到使用方式,當然 context 不只有這個使用方式,未來還會介紹其他使用方式。