context.Contextの親子とキャンセル処理の順序

非同期処理をキャンセルする機構としてよく使う context.Context

Contextは親子関係が作れて、親をキャンセルするとその子孫も一緒にキャンセルされる。 一緒にといっても、親のキャンセルがトリガーなので、親がわずかに先にキャンセルされると考えるのが自然。

では、select { } をつかって親子両方のキャンセルを待ち受けてみた。

package main

import (
    "context"
    "log"
)

func main() {
    pctx, pcancel := context.WithCancel(context.Background())
    cctx, _ := context.WithCancel(pctx)

    go func() {
        pcancel()
    }()

    select {
    case <-pctx.Done():
        log.Printf("parent done: %+v", pctx.Err())
    case <-cctx.Done():
        log.Printf("child done: %+v", cctx.Err())
    }
}

意外な結果に

何度も実行すると、予想に反して親のキャンセルを先に受け取る場合と、子のキャンセルを先に受け取る場合とバラバラになった。

2020/01/28 13:32:09 child done: context canceled
2020/01/28 13:32:09 child done: context canceled
2020/01/28 13:32:09 parent done: context canceled
2020/01/28 13:32:09 parent done: context canceled
2020/01/28 13:32:09 parent done: context canceled
2020/01/28 13:32:09 child done: context canceled
2020/01/28 13:32:09 child done: context canceled

実際に親の内部の done channel は先にcloseされるのだが、キャンセル処理全体にロックがかけらててて、ロックの中で子Contextのキャンセルもやっている。

結果として、pctx.Done() から値を取り出すのはロック解除の後になるので、そのときは既に子contextもキャンセル済というわけみたい。

// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
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)
    }
}

ロックが解除されたあとに select { } に親子どちらの Done() が先にくるかはタイミングによって変わるということなのかな。