LoginSignup
3

More than 5 years have passed since last update.

contextにおけるキャンセル伝搬の流れ

Last updated at Posted at 2018-05-25

contextのキャンセル伝搬の流れを調べてみました。
キャンセル処理を行う簡単な例として以下のサンプルコードを用意しました。こちらを元にソースコードから流れを追っていきます。

サンプルコード

main関数でgoroutinを起動してsleepFuncを呼び出し、そのなかでさらにgoroutinを起動してtime.Sleepを実行しています。

package main

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

func sleepFunc(ctx context.Context, duration time.Duration) string {
    doneCh := make(chan struct{})
    go func() {
        time.Sleep(duration * time.Second)
        doneCh <- struct{}{}
    }()

    select {
    case <-doneCh:
        return "done"
    case <-ctx.Done():
        //ここでキャンセル処理を行う
        return "cancel"
    }
}

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

    resultCh := make(chan string)
    go func() {
        resultCh <- sleepFunc(ctx, 10)
    }()
    cancel()

    fmt.Println(<-resultCh)
}

sleepFunc()内で起動したgoroutinでtime.Sleep()を実行しますが、time.Sleep()が完了する前にmain関数でcancel()が呼ばれるため、標準出力にcancelと表示されます。
動作はGo Playgroundで確認できます。

結論

<- ctx.Done()で待機しているところに対して、cancelCtx構造体がもつchan struct{}型のdoneclose()することでキャンセルシグナルを伝搬しています。

contextの取得

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

main関数では最初にcontext.Backgroud()を引数にしてcontext.WithCancel()を呼び出してcontextを取得しています。

context.Background()

context.Background()は空のcontextであるbackgroundを返す関数です。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L202-L208

backgroudemptyCtxnew()で初期化したものです。
emptyCtxContextインターフェースを満たすint型の変数ですが、実装しているメソッドのうちString()以外のメソッドには実処理がなく、returnするだけになっています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L167-L195

context.WithCancel()

context.WithCancel()は引数で受け取ったcontextを親としてもつ新たなcontextを返します。
サンプルコードではcontext.Background()を引数にしていますので、emptyCtxを初期化したオブジェクトを渡しているということになります。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L224-L234

中身をもう少し見ていきます。

newCancelCtx()cancelCtx構造体を初期化して返す関数です。
cancelCtxにはContextインターフェースが埋め込まれており、その他にいくつかのフィールドの定義がされています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L236-L239
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L314-L323

次の行ではparent&cを引数に渡してpropagateCancel()を実行しています。
親contextとそこから派生したcontext(のアドレス)を使って何らかの処理をしていると考えられます。

propagateCancel()では、まずはじめにparent.Done()nilであるかをチェックして、nilである場合はそこでreturnします。
サンプルコードでは、parentemptyCtxを初期化したオブジェクトなのでemptyCtxに実装されているDone()メソッドを見ます。これはnilを返すのでここでreturnされます。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L243-L245

最後にnewCancenCtx()で取得した新しいcontextのアドレスと、キャンセル用の関数をreturnしています。
これらはmain関数でctx, cancelという変数に格納しています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L233

キャンセルの実行

サンプルコードではcontextを取得した後、goroutinを起動してsleepFunc()を実行します。このとき引数でcontextを渡しています。

sleepFunc()は内部で更にgoroutinを起動してtime.Sleep()を実行します。
goroutinの外ではselectでcannelの受信を待機しています。doneChtime.Sleep()が正常に完了した場合に受信し、ctx.Done()はキャンセルが実行された場合に受信します。

main関数でgoroutinを起動した直後にcancel()を実行しているので、実際にはctx.Done()からシグナルを受信して標準出力に"cancel"という文字列が表示されます。

ctx.Done()

キャンセル伝搬の流れを見る前にまずはctx.Done()の中身を見たいと思います。

cancelCtxのフィールドに含まれるsynx.Mutexを利用してロックを取得します。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L326
これは定義している箇所のコメントから、他のフィールドを保護する用途で使われていることが読み取れます。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L319

c.donenilである場合は初期化したchannelをc.doneに代入します。その後c.donedに代入してreturnしています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L327-L332

なお、channelは参照型なので一旦dに代入していますがこれはc.doneと同じデータ構造を参照しています。
https://golang.org/doc/effective_go.html#channels

Like maps, channels are allocated with make, and the resulting value acts as a reference to an underlying data structure.

cancel()

キャンセル伝搬の流れです。

cancelcontext.WithCancel()の2つめの返りとして受け取ったものでした。
context.WithCancel()の2つめの返り値はfunc() { c.cancel(true, Canceled) }でしたので、キャンセル処理の流れを追うにはcancelCtxcancel()メソッドの中身を追っていけばよさそうです。

err == nilである場合にpanicを発生させています。errは第二引数で渡されているCanceledです。errors.New()で定義したerrorが代入されていてnilではないので、次の処理に進みます。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L348-L350
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L154-L155

cancelCtxのフィールドに含まれるsynx.Mutexを利用してロックを取得します。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L351

c.err != nilの場合はすでにキャンセル済みとみなされ、ロックを開放したあとにreturnされます。
次の行でc.errerrを代入していますので、このcancel()が実行されるとc.errnilでなくなり、次の呼び出しのときはc.err != nilになるということのようです。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L352-L356

c.done == nilの場合はclosedchanが代入され、そうでない場合はc.doneがクローズされます。
あらかじめctx.Done()を実行していた場合、c.doneには初期化したchannelがセットされているため、そのchannelがクローズされます。
つまりcase <-ctx.Done():のようにしていた場合、c.doneがクローズされたタイミングでシグナルを受信しますので、これによりキャンセルを伝搬しているということになります。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L357-L361

closedchanはここで定義されています。empty structのchannelです。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L307-L308

c.childrenの各要素のcancel()メソッドを実行しています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L362-L365

第一引数のremoveFromParentがtrueである場合にremoveChild()が実行されます。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L369-L371

親contextがある場合は、親contextが持つ子contextのmapから自らを削除します。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L287-L298

まとめ

context.WithTimeout()context.WithValue()を使った場合や複数のgoroutinをキャンセルする場合の流れについても見てみようと思います。

参考文献

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
3