Carpe Diem

備忘録

context.WithCancel, WithTimeout で知っておいた方が良いこと

概要

christina04.hatenablog.com

でも話したcontext.Contextタイムアウト、キャンセルなどのハンドリングができて便利ですが、使う際に知っておいた方が良いことをいくつかまとめました。

環境

Tips

Q. 親・子の両方でWithTimeoutが設定されたらどうなるか?

例えば

  • 親:4秒
  • 子:1秒

の時、もしくはその逆の時どうなるか、です。

func parentFunc(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
    defer cancel()
    err := childFunc(ctx)
    if err != nil {
        return err
    }
    return nil
}

func childFunc(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
    defer cancel()
    done := make(chan error, 1)
    go func() {
        err := heavyFunc()
        if err != nil {
            done <- err
        }
        done <- nil
    }()

    select {
    case e := <-done:
        return e
    case <-ctx.Done():
        return ctx.Err()
    }
}

The Go Playground

A. timeoutが短い方が優先される

ドキュメントには以下のように書いてあります。

WithDeadline returns a copy of the parent context with the deadline adjusted to be no later than d. If the parent’s deadline is already earlier than d, WithDeadline(parent, d) is semantically equivalent to parent.

ref: context - The Go Programming Language

つまり短い方のtimeoutになるということですね。なので例の場合は1秒になるが正解です。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    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,
    }

ref: src/context/context.go - The Go Programming Language

実際のソースを確認してもドキュメント通りのロジックであることが分かります。

Q. ctx.Done()はどこでハンドリングすればいいか

cancel()を呼んだりtimeoutした場合、ctx.Done()がcloseされるのでzero値がreadできるようになります。
なので

func childFunc(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
    defer cancel()
    done := make(chan error, 1)
    go func() {
        err := heavyFunc()
        if err != nil {
            done <- err
        }
        done <- nil
    }()

    select {
    case e := <-done:
        return e
    case <-ctx.Done():
        return ctx.Err()
    }
}

のようにselectで待ち受ければtimeoutした時に関数を終了できます。
ではこれを親でもやる必要があるのでしょうか?

また親→子→孫→ひ孫の関係の場合、どこまでctx.Doneハンドリングすれば良いのでしょうか?

A. 最低限、一番下の子がハンドリングすれば良い。

親→子→孫→ひ孫の関係でcancel()を呼んだりtimeoutした場合、ひ孫ctx.Done()をハンドリングすれば良いです。
そうすれば

ひ孫が終了

孫が終了

子が終了

親が終了

のように順に関数が終了してくれます。

Q. 子のキャンセルは親に伝播するのか?

contextの説明では親のキャンセルが子に伝播するとなっていますが、逆に子でキャンセルしたら親の方でctx.Done()は発火するのでしょうか?

func childFunc(ctx context.Context) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()
    done := make(chan error, 1)
    go func() {
        err := childFunc2(ctx)
        if err != nil {
            done <- err
        }
        done <- nil
    }()

    select {
    case e := <-done:
        fmt.Println("childFunc done", e)
        return e
    case <-ctx.Done():
        fmt.Println("childFunc ctx.Done", ctx.Err())
        return ctx.Err()
    }
}

func childFunc2(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
    defer cancel()
    done := make(chan error, 1)
    go func() {
        err := heavyFunc()
        if err != nil {
            done <- err
        }
        done <- nil
    }()

    select {
    case e := <-done:
        fmt.Println("childFunc done", e)
        return e
    case <-ctx.Done():
        fmt.Println("childFunc2 ctx.Done", ctx.Err())
        return ctx.Err()
    }
}

The Go Playground

parentFunc
└ childFunc
 └ childFunc2

という階層になっています。これでchildFunc2がtimeoutした場合、上のchildFuncのctx.Done()は発火するのかどうか。

A. しない

実行してみると分かりますが、

childFunc2 ctx.Done context deadline exceeded
childFunc done context deadline exceeded

このように上のchildFuncはctx.Done()の方でなく、doneのchannelから通知が来て終了しています。

Q. timeoutを設定していたらcancelを実行しなくても良いのか?

ドキュメントには

The returned context’s Done channel is closed when the deadline expires, when the returned cancel function is called, or when the parent context’s Done channel is closed, whichever happens first.

ref: context - The Go Programming Language

とあります。つまりtimeoutでもcancelの実行でもどちらでもctx.Done()はクローズされるのです。
ではなぜWithTimetou()はcancel functionを返し、

Canceling this context releases resources associated with it, so code should call cancel as soon as the operations running in this Context complete.

のように必ず実行するように推奨しているのでしょうか?

A. 他の子に終了を通知するために必要

例えば

parentFunc
├ childFuncA
└ childFuncB

で、childFuncAがtimeoutで終了してもchildFuncBにはそれによるctx.Doneは伝わりません。

childFuncAでtimeout

parentFuncでcancel()実行

childFuncBにctx.Done()が伝わる

という流れをにする必要があるため、cancel()の実行も必要になります。
またtimeoutだけでなくエラーで即時終了するケースがあります。その際もdefer cancel()しておけばすぐに他の子に終了通知を送れます。

まとめ

context.Contextで知っておいた方が良いことをまとめました。