2022/07/03

[golang] よく出てくる context.Context

通信するライブラリなどを使おうとするとしばしば登場するのが context.Context だ。
gRPC だとこんな感じ。

Basics tutorial | Go | gRPC
https://grpc.io/docs/languages/go/basics/#simple-rpc-1

context.Background() でコンテキストを作って与えておけば取りあえず使えるので深く考えずに使っていたのだが、真面目に実装していくと理解していないとまずいと感じた。というより、あまり理解していなくてコードレビューで指摘を受けてしまった。

よく出てくるのでほどほどに理解したい。
ネットで「golang context」で検索するとたくさん出てくるので、きれいな解釈はそちらを見た方がよいだろう。なら書くなよと思われそうだが、私は書かないと理解ができないタイプなので仕方ないのだ。


まずは godoc

context package - context - Go Packages
https://pkg.go.dev/context#Context

出てくるのは context.Context だが、パッケージの存在目的が Overview に書いてあるので目を通す。

  • プロセス間で伝播
    • デッドライン
    • キャンセル
    • シグナル
    • その他 request-scoped な値

「between proceses」とあるが、Linux でいうところのプロセスではなく goroutine を指してるのだろうか。
「request-scoped values」は、処理の要求を行ったコンテキストが持っている値だろうか。 goroutine だったらそういう値はがんばらなくても参照できそうなものだが、うーん?

「プログラミング言語Go」には context が載っていない。本は v1.5 のときに書かれているのだが context が生まれたのは v1.7 のようだ。ちなみに本では goroutine のキャンセルについて書かれており、それは channel を使って行うようになっている。

 

こちらが、よく紹介されている go.dev のブログ。

Go Concurrency Patterns: Context - The Go Programming Language
https://go.dev/blog/context

まあ、もう 8年も経っているので日本語で紹介しているサイトの文面を読むだけでいいや。。。


いろいろ使い道はあるのだが、主な使い方はキャンセル処理の通知だと思う。
チャネルを使えば処理の終了を待ち合わせることもできるし、キャンセル処理をさせることもできる。

hiro99ma blog: [golang] chan で待つ
https://blog.hirokuma.work/2022/06/golang-chan.html

ただ、それをおのおのの実装でやっていくと違いが出てくるだろうし、似たような処理があちこちに出てきて格好が悪くなるから context にそういうのをまとめておいてみんな使いましょう、ということなのだろう。
A が Bライブラリを使って、 B が Cライブラリを使って、とやっていって、Aがキャンセルする処理を Bに伝えて、それを B が Cに伝えて、ということになるのをルール化したというところか。

なので、goroutine を走らせておいて「あとは好きにやっとけ」というタイプの処理だと使うことが無いかもしれない。多少なりとも goroutine を走らせた方が結果を待ったりするからこそ自分がキャンセルすることを下々に通知する必要が出てくると思われるからだ。

ただ、goroutine を呼び出した方が先に終了してしまうと goroutine の処理が終わる前に中断させられてしまうことになるから、何かしら終了を待つようなことにはなるのではなかろうか。
そう考えると、あまり難しく考えず、goroutine の中で context.Context を引数にとる API を使うなら その外側で context.Context を渡すようにした方がよい、くらいでいいのかな。

 

そういう見方をすると context のメンバーの用途がわかりやすい。

Done() は呼び出し元がキャンセルしたのを子が知るための channel 。

package main

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

func main() {
    fmt.Printf("start\n")
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    go func(ctx context.Context) {
        fmt.Printf("goroutine start\n")
        time.Sleep(10 * time.Second)
        fmt.Printf("goroutine done\n")
        <-ctx.Done()
        fmt.Printf("detect Done!")
    }(ctx)

    time.Sleep(5 * time.Second)
    cancel()
    fmt.Printf("main done.\n")
}

$ go run .
start
goroutine start
main done.

まあ、待ち始めるのが 10秒後なので、5秒後に cancel() するとそうなるよね。。。
だからといって goroutine の方を先に終わらせて Done() を待つというのは意味が無い。それなら goroutine を終われば良いだけだ。
となると、select で Done() を待つのと同じように、別の channel も待つようなシーンでないと意味が無いのか。

やるなら、cancel() を通知した後、自分が実行した goroutine が終わるのは待つようにするというところか。

package main

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

func main() {
    fmt.Printf("start\n")
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    done := make(chan struct{})
    go func(ctx context.Context) {
        fmt.Printf("goroutine start\n")
        time.Sleep(10 * time.Second)
        fmt.Printf("goroutine done\n")
        <-ctx.Done()
        fmt.Printf("detect Done!")
        done <- struct{}{}
    }(ctx)

    time.Sleep(5 * time.Second)
    cancel()
    <-done
    fmt.Printf("main done.\n")
}

5秒経過したら cancel() を呼び、待ち合わせている goroutine からの channel done を待ってから終了させる。
done を通知するのは自作の goroutine だから、done しなかったら自分のバグということでよいだろう。

では、もしその goroutine の中で別の goroutine を呼び出しているのであればどうするか?
同じだ。 Done() の channel を受け取ったら配下の goroutine をキャンセルさせるように通信して終わるまで待つのだと思う。

いやー、難しいね。