LoginSignup
1
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をキャンセルする場合の流れについても見てみようと思います。

参考文献

1
3
0

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
  3. You can use dark theme
What you can do with signing up
1
3