1. 前言
在 Go 語言中,上下文 context.Context 用來設置截止日期,同步信號,傳遞值的功能,它與 goroutine 關系密切,被用來解決 goroutine 之間 退出通知
,元數據傳遞
等的任務。本文通過示例代碼來學習梳理 context.Context 包,希望做到從入門到深入了解。
2. context.Context 包類型
首先看類圖如下:
從類圖可以看出:
- context.Context 是個接口類型,它實現了 Deadline(),Done(),Err() 和 Value(key interface{}) 方法。各方法的功能如下:
- Deadline: 返回上下文 context.Context 的截止時間,截止時間到將取消該上下文。
- Done: 返回只讀空結構體通道。源碼中沒有向該通道寫結構體,調用該方法會使通道阻塞在接收數據,直到關閉該通道(關閉通道會讀到結構體的零值)。
- Err: 返回上下文 context.Context 結束的錯誤類型。有兩種錯誤類型:
- 如果 context.Context 被取消,則返回 canceled 錯誤;
- 如果 context.Context 超時,則返回 DeadlineExceeded 錯誤。
- Value: 返回 context.Context 存儲的鍵 key 對應的值。
- canceler 也是一個接口,該接口實現了 cancel(removeFromParent bool, err error) 和 Done() 方法。實現了該接口的上下文 context.Context 均能被取消(通過調用 cancel 方法取消)。
- cancelCtx 和 timerCtx(timerCtx 內嵌了 cancelCtx 結構體) 均實現了 canceler 接口,因此這兩類上下文是可取消的。
- emptyCtx 是空的上下文。它被 Backgroud 函數調用作為父上下文或被 ToDo 函數調用,用於不明確傳遞什么上下文 context.Context 時使用。
- valueCtx 是存儲鍵值對的上下文。
3. 代碼示例
前面解釋如果看不懂也沒關系,這里通過代碼來分析 context.Context 包的內部原理,畢竟 talk is cheap...
3.1 代碼示例一: 單子 goroutine
func worker1(ctx context.Context, name string) {
time.Sleep(2 * time.Second)
for {
select {
case <-ctx.Done():
fmt.Println("worker1 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request")
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker1(ctx, "worker1")
time.Sleep(1 * time.Second)
cancel()
time.Sleep(1 * time.Second)
}
代碼的輸出為:worker1 stop worker1 context canceled
。
逐層分析看代碼為何輸出信息如上。首先,查看 context.WithCancel 函數:
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) }
}
函數接受的是父上下文,也就是 main 中傳入的函數 context.Background() 返回的 emptyCtx 上下文。在 newCancelCtx 函數新建 context.cancelCtx 上下文:
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
然后,propagateCancel 函數將父上下文的 cancel 信號傳遞給新建的 context.cancelCtx:
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
return // parent is never canceled
}
...
截取部分函數內容分析,后續會接着介紹。由於 emptyCtx 不會被取消,它的 Done 方法返回值為 nil,實際上執行到第一個判斷 if done == nil
函數就會返回。
最后,返回新建上下文 context.cancelCtx 的地址及 CancelFunc 函數 func() { c.cancel(true, Canceled) }
。后續取消上下文即是通過調用該函數取消的。
花開兩朵,各表一枝。在把視線移到 worker1 函數,這個函數需要介紹的即是 ctx.Done() 方法,前面說過它返回只讀通道,如果通道不關閉,將一直是阻塞狀態。從時間上看,當子 goroutine 還在 sleep,即還未調用 ctx.Done 方法,main 中的 cancel() 函數已經執行完了。那么,cancel 函數做了什么動作呢?接着看:
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
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
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()
if removeFromParent {
removeChild(c.Context, c)
}
}
在 cancel 函數中的關鍵代碼是 c.done = closedchan,由於 goroutine 中還未調用 ctx.Done 方法,所以這里 context.cancelCtx 的 done 屬性還是 nil。closedchan 是個已關閉通道,它在 context.Context 包的 init 函數就已經關閉了:
var closedchan = make(chan struct{})
func init() {
close(closedchan)
}
那么等 goroutine 睡醒了就知道通道已經關閉了從而讀取到通道類型的零值,然后退出 goroutine。即打印輸出 worker1 stop worker1 context canceled
。
到這里這一段代碼的解釋基本上結束了,還有一段是 cancel() 的執行要介紹,在 c.children for 循環這里,由於 c context.cancelCtx 沒有 children 也即 c.children 是 nil,從而跳出 for 循環。
在 removeChild 函數中,父上下文 parent 並未取消,所以函數 parentCancelCtx 返回 ok 為 false,從而退出函數:
func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}
3.2 代碼示例二:單子 goroutine
討論完上一段代碼,在看另一種變形就不難理解了,即子 goroutine 在取消前執行的情況。代碼就不貼了,只是 sleep 時間換了下。區別在於 cancel 函數的判斷:
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
由於子 goroutine 中已經調用了 ctx.Done() 方法:
func (c *cancelCtx) Done() <-chan struct{} {
c.mu.Lock()
if c.done == nil {
c.done = make(chan struct{})
}
d := c.done
c.mu.Unlock()
return d
}
所以這里 c.done 的判斷將不等於 nil 而走向 close(c.done) 直接關閉通道。
3.3 代碼示例三:多子 goroutine
多子 goroutine 即一個 parent context.Context 有多個子 context.cancelCtx 的情況。如代碼所示:
func worker1(ctx context.Context, name string, cancel context.CancelFunc) {
time.Sleep(2 * time.Second)
cancel()
for {
select {
case <-ctx.Done():
fmt.Println("worker1 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request")
}
}
}
func worker2(ctx context.Context, name string) {
time.Sleep(2 * time.Second)
for {
select {
case <-ctx.Done():
fmt.Println("worker2 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request")
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker1(ctx, "worker1", cancel)
go worker2(ctx, "worker2")
time.Sleep(1 * time.Second)
cancel()
time.Sleep(2 * time.Second)
}
類似於代碼示例一中單子 goroutine 的情況。區別在於同步鎖這里:
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // already canceled
}
這里如果有一個 goroutine 調用了 cancel() 方法,c.err 就不等於 nil,其它 goroutine 再去調用 cancel() 就會判斷 if c.err != nil
從而直接退出。這也引申出一點,上下文 context.Context 的方法是冪等性的,對於不同 goroutine 調用同樣的上下文 context.Context 會得到相同的結果。
3.4 代碼示例四:單父單子和單孫上下文
3.4.1 場景一
構造這樣一種場景:父上下文 parent 有一個子上下文,該子上下文還有一個子上下文,也就是父上下文 parent 的孫上下文:
func worker3(ctx context.Context, name string, cancel context.CancelFunc) {
for {
select {
case <-ctx.Done():
fmt.Println("worker3 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request")
}
}
}
func worker2(ctx context.Context, name string) {
time.Sleep(2 * time.Second)
cctx, cancel := context.WithCancel(ctx)
go worker3(cctx, "worker3", cancel)
time.Sleep(2 * time.Second)
cancel()
for {
select {
case <-ctx.Done():
fmt.Println("worker2 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request")
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker2(ctx, "worker2")
time.Sleep(1 * time.Second)
cancel()
time.Sleep(2 * time.Second)
}
輸出結果:
worker3 stop worker3 context canceled
worker2 stop worker2 context canceled
在這樣一個場景下,子上下文會先於孫上下文取消,同樣的層層查看為何會打印以上輸出。首先,對於main 中的 cancel() 函數,當它運行時孫上下文還未創建,所以它的運行和代碼示例一樣。那么,我們看當孫上下文 cctx 創建時發生了什么:
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
return // parent is never canceled
}
select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err())
return
default:
}
...
還是看 propagateCancel 函數,由於傳入的 parent context.Context 已經取消了,所以 case <- done 會讀到結構體的零值,進而調用 child.cancel 方法:
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
...
c.err = err
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
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()
if removeFromParent {
removeChild(c.Context, c)
}
}
為了篇幅起見這里省略了部分 cancel 代碼,類似前文調用 c.done = closedchan 關閉上下文 cctx 的通道,接着執行 cancel 方法,由於 cctx 並沒有 children 同樣的 for child := range c.children
將跳出循環,並且 removeFromParent 為 false 跳出 if 判斷。
此時孫上下文 cctx 通道已經被關閉了,再次調用 cancel() context.cancelFunc 會判斷 if c.err != nil
進而退出。
3.4.2 場景二
更改 sleep 時間,使得 main 中 cancel 函數在孫上下文 cancel() 執行后執行。由於子上下文並未 cancel,在 propagateCancel 里會走到 parentCancelCtx 判斷這里,這里通過 p.children[child] = struct{}{}
將孫上下文綁定:
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// parent has already been canceled
child.cancel(false, p.err)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
}
綁定的目的是:對下,當子上下文取消時會直接調用孫上下文取消,實現了取消信號的同步。對上,當孫上下文取消時會切斷和子上下文的關系,保持子上下文的運行狀態。這部分是在 cancel 函數里實現的:
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()
if removeFromParent {
removeChild(c.Context, c)
}
對於 removeFromParent 函數,重點是其中的 delete 函數 delete(p.children, child)
將子上下文從父上下文的 p.children map 中移除掉:
func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}
3.5 代碼示例四:多子上下文
直接看圖:
圖片來自 深度解密 Go 語言之context,這里不作過多分析,有興趣的讀者可自行研究。相信通過前文幾個代碼示例的梳理已基本到深入了解的程度了。
4. 附言
本文希望通過代碼的梳理達到從入門上下文 context.Context 到深入了解的程度,然而本文並未高屋建瓴的對其中的設計進行抽象,也並未分析 context.Context 的由來及其它上下文 context.Context 如 valueCtx 和 timerCtx 等的分析,這些內容是本文缺乏的。幸好網上有較好的文章記錄,想更深入了解,推薦博文: