エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Goのchannelとスケジューリング

この記事は エムスリー Advent Calendar 2020 の13日目の記事です。

エムスリーエンジニアリンググループ/BIRの滝安(@juntaki)です。 BIRはビジネスインテリジェンス&リサーチの略で、そこでは医療従事者の会員向けアンケートをベースに、製薬会社へのマーケティング支援を提供する事業を行っています。

BIRはエムスリーの中で比較的Goの開発・運用歴が長いチームです。 チームSlackでも、たまに他チームからのGoよろず相談が行われているのを目にします。

f:id:juntaki1:20201211180601p:plain

さて、今回の記事では、個人的に作ったCLIツールで発生したGoのスケジューリングの挙動について説明します。 さっそくですが以下のコードはどのような出力になるでしょうか(全然よいコードではないというのはさておき…*1

※以下の検証はGo 1.15.5で行いました。

func main() {
    add := make(chan int)
    sum := 0
    go func() {
        for f := range add {
            sum += f
        }
    }()
    add <- 1
    fmt.Println(sum)
}

結果: https://play.golang.org/p/TTrmP3QndKw => 1

加算用のgoroutineを立ち上げて、addに1を入れているので、sumは1になりました。意図したとおりっぽいですね。

それでは、これはどうでしょうか。1ミリ秒のSleepが増えているところ以外は同じです。

func main() {
    add := make(chan int)
    sum := 0
    go func() {
        for f := range add {
            sum += f
        }
    }()
    time.Sleep(1 * time.Millisecond)
    add <- 1
    fmt.Println(sum)
}

結果: https://play.golang.org/p/a96xzPkJuk9 => 0

0になります。なにもしてないのに壊れました。

Goのchannelとスケジューラの挙動

この結果の原因はadd <- 1するタイミングで、addのreceiverがいるかどうか、です。 その差はgoroutineがスイッチするタイミングの違いから発生します。サンプルコードだとスイッチが発生するタイミングは、おおまか下記の2点です。

  • channelを操作するとき
  • time.Sleepをするとき

最初の例では、add <- 1するタイミングまで、mainがずっと実行されつづけているので、for f := range addが動いていません。 なので、mainは、receiverがいないchannelに書き込むためブロックし、Printするまえに加算用のgoroutineにスイッチされます。

2つめの例では、time.Sleepのタイミングでmainから加算用goroutineにスイッチします。そこでfor f := range add {が動作します。 なのでadd <- 1するタイミングでは、receiverがいることになります。

channelに書き込むときは、runtime.chansend()が呼ばれます。下記はそのコードの抜粋です。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
    if sg := c.recvq.dequeue(); sg != nil {
        // Found a waiting receiver. We pass the value we want to send
        // directly to the receiver, bypassing the channel buffer (if any).
        send(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true
    }

...
  gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
  
...
}

receiverがいる場合は、c.recvqからreceiverのgoroutineが取得できるので、メモリコピーでreceiverに値を渡してしまい、そのまま処理を手放すこと無くreturnし次の処理に進みます。一方でreceiverがいない場合は、gopark()でブロックするコードになっています。

まとめ

コーナーケースをつくようなサンプルコードですが、goroutineの待ち合わせをちゃんと考えて設計しないと、変な挙動に悩まされる、という例でした。

We are hiring!

エムスリー、とくにBIRではGo/Reactエンジニアや、データ基盤開発に興味があるエンジニアを募集しています。社員とカジュアルにお話することもできますので、興味を持たれた方は下記よりお問い合わせください。

open.talentio.com

jobs.m3.com

*1:色々直し方は考えられますが、たとえばgoroutineの処理完了をチャネルで通知して待ち合わせれば、スケジューラのことを考えなくても安定した出力が得られます。また、メモリモデルの観点で、sumはアトミックな更新であるほうが正確ですが、サンプルコードのわかりやすさのために省略しています。