Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Go と並行処理

RyotaNakaya
December 03, 2021

Go と並行処理

RyotaNakaya

December 03, 2021
Tweet

More Decks by RyotaNakaya

Other Decks in Technology

Transcript

  1. 2021/12/03 Nakaya Ryota Goと並行処理

  2. ギフティ入社:2019年1月 所属:ContentsCreation Div. ProductUnit2(コーヒー屋さんチーム) 前職:バックオフィス系システムのパッケージベンダー(上流メイン) 社内部活:#coffee、#darts、#poker、#among_us… 分報:#times_nakaya 最近の関心ごと:インタラクテションデザイン 資格:ラオスの象使い 自己紹介

  3. abe 君と yashi さんのこのリクエストをそろそろ回収しようと思って... Motivation

  4. • Go ってなんか並行処理が得意らしいじゃん? • C10K 問題を解決できるレベルの性能らしいじゃん? Motivation

  5. → Go は言語レベルでマルチスレッドをサポートしている • Go ってなんか並行処理が得意らしいじゃん? • C10K 問題を解決できるレベルの性能らしいじゃん? Motivation

  6. • そもそも並行処理とは • Go における並行処理 Ajenda

  7. 並行(concurrent): • 複数の仕事を切り替えることで同時にやってい るように見えること • ある一つの時点では一つの仕事しかしてない 並行と並列 並列(parallel): • ある一つの時点で物理的に複数の仕事をして

    いること time time
  8. シングルコア: • 一つのコアを持つプロセッサ • 元々は全てシングルコアだった • 同時に一つのタスクしか実行できない • クロック数を上げることで高速化を図っていた 並行と並列

    マルチコア: • 複数のコアを持つプロセッサ • デュアルコアとかクワッドコアとか • コアの数だけ同時並列にタスクを実行可能 • 現代の PC は大体マルチコア ※スパコン富岳は52コア https://www.r-ccs.riken.jp/newsletter/202003/interview.html CPU Core CPU Core Core Core Core
  9. • 手元で ps -x コマンドを実行すると大量にプロセスが出てくるはず • コア数分までは同時並列に実行されていて、それ以上は並行的に逐次実行されている • top コマンドの出力を眺めていると、CPU

    を消費するプロセス/OSスレッドが逐次切り替わっているのがわかる 並行と並列
  10. • 並行性はコードの性質を指す • 並列性はプログラムのランタイムの性質であり、コードの性質ではない ◦ 並列に動いて欲しいコードを書いても、必ずしも並列に動くわけではない 並行と並列 同時に物事考えたかったら もっと脳みそ連れてこいや

  11. プロセス: • タスクの実行単位 • プロセスごとに独立したメモリ領域 プロセスとスレッド スレッド: • プロセスよりも軽量な実行単位 •

    プロセスによって管理される • スレッド間のメモリ共有 • 一口にスレッドと言ってもいくつか種類がある
  12. ネイティブスレッド: • 俗に言う OS スレッド • スレッドの切り替えをカーネルが行うため、複数 のスレッドを並行して実行可能 • マルチコアであれば並列に実行可能

    • カーネルスレッドやライトウェイトプロセスなどの 分類がある ネイティブスレッドとユーザースレッド ユーザースレッド: • ユーザー空間で実装されたスレッド • Java の jvm など仮想マシン上で管理されるも のはグリーンスレッドとも呼ばれる • ネイティブスレッドをより抽象化した概念 • あくまでも擬似的なスレッドなので、これ単体で は並列処理は無理
  13. サブルーチン: • 一つのまとまった手続きを指す • いわゆる関数(function) • 処理の start から return

    まで一気に行われる サブルーチンとコルーチン コルーチン: • 処理を中断し、続きから再開できる関数 • C# や Lua など一部の言語で提供されている • 原理的にはネイティブスレッドで同じことができ るが、切り替えのオーバーヘッドが少ない
  14. ポイント: • 待ちが少なくなる(= マシンリソースを効率よく使える) • 早く終わるかもしれない(速くはならない) 並行処理のうれしさ

  15. 直列の場合 並行処理のうれしさ 待ち時間10分 作業時間5分 切り替え1分 エビフライと野菜炒めができるのに 16分かかる

  16. 直列の場合 並行処理のうれしさ 待ち時間10分 作業時間5分 切り替え1分 エビフライと野菜炒めができるのに 16分かかる 無心で油の中のエビを 見守る10分間

  17. 並行の場合 並行処理のうれしさ 経過時間7分 作業時間5分 切り替え1分 エビフライと野菜炒めが 10分で完成!6分短縮できた! 切り替え1分 待ち時間3分 待ち時間に別の

    タスクを行う
  18. 並行の場合 並行処理のうれしさ 経過時間7分 作業時間5分 切り替え1分 エビフライと野菜炒めが 10分で完成!6分短縮できた! 切り替え1分 待ち時間3分 待ち時間に別の

    タスクを行う 個々の作業のスピードそのものが速く なったわけではないことに注意
  19. ポイント: • 競合(デッドロック、ライブロック) • 共有メモリアクセス • コンテキストスイッチのオーバーヘッドによる性能劣化 ◦ 収穫逓減の法則 並行処理の難しさ

  20. デッドロック: 並行処理の難しさ TaskA リソースA リソースB TaskB ロック ロック TaskA はリソースBのロック解放を待ち、

    TaskB はリソースAのロック解放を待つの で一生処理が進まない
  21. お互いが譲り合って 処理が進まなくなる ライブロック: 並行処理の難しさ TaskA リソースA リソースB TaskB リソースAを 使いたい

    リソースAを 使いたい リソースBを 使いたい リソースBを 使いたい TaskB が使うっ ぽいからやめよ TaskB が使うっ ぽいからやめよ リソースAを 使いたい TaskB が使うっ ぽいからやめよ TaskA が使うっ ぽいからやめよ TaskA が使うっ ぽいからやめよ リソースAを 使いたい TaskA が使うっ ぽいからやめよ 以後ループ 以後ループ
  22. 共有メモリアクセス: 並行処理の難しさ TaskA リソースA TaskB 参照と更新がアトミックでないため、 1400にならないといけないところが 1200になってしまう 1000 1000

    1200 1200 1000+200 1000+200
  23. コンテキストスイッチによる性能劣化: • 実行するタスクを切り替えることをコンテキストスイッチという • スイッチングにはコストがかかる • コストを払っても並行処理した方がいいのは CPU のアイドルタイムを減らせるため •

    逆に言うと、CPU の演算を必要とするタスクばかりの場合、スイッチングコストがペイできなくなる 並行処理の難しさ
  24. コンテキストスイッチによる性能劣化: 並行処理の難しさ 作業時間5分 切替時間1分 野菜炒めの調理には CPU の演算のみを必要とするものとする 直列の場合 → 5分+5分+1分(切替1回)=11分

    並行した場合 → 5分+5分+5分(切替5回)=15分 スイッチングコストにより逆に遅くなる
  25. Go における並行処理

  26. 登場人物: • Goroutine • WaitGroup • Channel • Mutex •

    Context • M:Nスケジューラ Go の並行処理
  27. Goroutineとは: • Go が並行処理を扱うための軽量スレッドみたいなもの ◦ グリーンスレッドというより、考え方としてはコルーチン • 一般的なコルーチンとは違い、割り込みや再エントリをプログラマが意識しなくて良い ◦ Go

    のランタイムがよしなにやってくれる • ネイティブスレッドと比べてメモリ消費が少なく、コンテキストスイッチも格段に速い ◦ 一つ当たり数キロバイトという軽さ、ミリオン数実行可能 • Go の net/http サーバーはリクエストごとに Goroutine を起動して、マルチプレキシングする作り Go の並行処理
  28. Goroutineの生成: • go キーワードで Goroutine を生成できる ◦ めちゃ簡単!素敵! • Main

    関数を実行する Goroutine と sayHello を実行 する Goroutine が生成される Go の並行処理 func main(){ go sayHello() // 他の処理をする... } func sayHello(){ fmt.Println("hello") }
  29. Goroutineの生成: • 無名関数でも可能 Go の並行処理 func main() { go func(){

    fmt.Println("hello") }() // 他の処理をする... }
  30. Goroutineの待ち合わせ: • main 関数が終了した時点で Go のプログラムは 終了する • 右のコードではほとんどの場合、sayHello がスケ

    ジューリングだけされて実行されない • 他にもバリア同期するために他の Goroutine を待 ちたいとかって時もある • ので、待ち合わせる必要がある Go の並行処理 func main(){ go sayHello() } func sayHello(){ fmt.Println("hello") }
  31. Goroutineの待ち合わせ: • fork-join モデル ◦ 呼び出し元で合流ポイントを作ることで実 行を待つことが可能 • sync package

    の WaitGroup を使うことが多い Go の並行処理 func main() { var wg sync.WaitGroup wg.Add(1) // 待つ Goroutine の数を指定 go sayHello(&wg) // Goroutine を生成 wg.Wait() // ここでブロック } func sayHello(wg *sync.WaitGroup) { fmt.Println("hello") wg.Done() // WaitGroup をインクリメント }
  32. Channel: • Goroutine 間でデータをやりとりする機構 • CSP(Communicating Sequential Processes) に由来する並行処理のプリミティブの一つ ◦

    (Ruby3 の Ractor の channel は Actor 由来) • FIFO 型のデータ構造のパイプみたいなもの • 共有メモリの参照ではなく所有権の受け渡しを明示的に行うので、スレッドセーフな状態を簡単に作れる Go の並行処理
  33. Channelを使った値の送受信: • make 関数で channel を作成 • <- で値を送受信する •

    通常、Channel は値を受信するまでブロックする ので、待つ必要はない • 同じメモリを共有するのではなく、データの所有権 を渡すことで競合を発生しづらくする • 型安全 Go の並行処理 func main() { ch := make(chan string) // channel を作成 go sayHello(ch) v := <-ch // channel から値を受信 fmt.Println(v) } func sayHello(ch chan string) { ch <- "hello" // channel に値を送信 }
  34. Channelを使った値の送受信: • ブロックしたくない場合は select を使う • 右のコードでは select 実行時に channel

    に値が 入っていれば “hello” が、なければ “no data” が 出力される Go の並行処理 func main() { ch := make(chan string) // channel を作成 go sayHello(ch) select { case v := <-ch: // 受信できたケース fmt.Println(v) default: // どのケースにも当てはまらない場合 fmt.Println("no data") } } func sayHello(ch chan string) { ch <- "hello" // channel に値を送信 }
  35. Channelを使った値の送受信: • select は複数の channel をまとめる糊のようなも の • こんな感じで複数 Goroutine

    作っておいて、先に 返ってきた値を元に処理をしたりといった使い方が できる Go の並行処理 func main() { ch := make(chan string) ch2 := make(chan string) go sayHello(ch) go sayHello(ch2) select { case v := <-ch: fmt.Println(v) case v := <-ch2: fmt.Println(v) } } func sayHello(ch chan string) { ch <- "hello" }
  36. Channelを使った値の送受信: • for-loop で無限ループして、Goroutine の結果を 待ちながら別の処理を行うみたいなこともできる Go の並行処理 func main()

    { ch := make(chan string) go sayHello(ch) for { select { case v := <-ch: fmt.Println(v) default: } // 何か別の処理をする } } func doSomethingHeavy(ch chan string) { // 何か重たい処理をする ch <- "hello" }
  37. Channelを使った値の送受信: • 送信専用、受信専用、バッファ付きなど様々な種類の channel がある • パイプラインやファンイン/ファンアウトみたいな処理も簡単に書ける Go の並行処理

  38. クリティカルセクションの保護: • クリティカルセクションとは、同時にアクセスすることで不具合が生じる危険がある箇所のこと ◦ レースコンディションになるところ全般 • メモリ同期やセマフォなどで明示的に排他制御を行う必要がある Go の並行処理

  39. クリティカルセクションの保護: • channel を使わない場合、クリティカルセクション の排他制御は明示的に行う必要がある • 右のコードでは出力が1000にならずに、実行す るたびに結果が変化する ◦ レースコンディションが発生している

    Go の並行処理 func main() { var count int for i := 0; i < 1000; i++ { go func() { count++ }() } time.Sleep(time.Second * 10) fmt.Println(count) }
  40. クリティカルセクションの保護: • Mutex(Mutual Exclusion) を使う • インクリメント処理をロックの中で行う • ログライブラリなどは書き込みで競合しないよう 内部的にロックを取る仕組みになっているので複

    数 goroutine で呼び出しても安全 • キャッシュや状態管理に使われる Go の並行処理 func main() { var count int var mu sync.Mutex for i := 0; i < 1000; i++ { go func() { mu.Lock() // 排他ロック count++ mu.Unlock() // ロック解除 }() } time.Sleep(time.Second * 3) fmt.Println(count) }
  41. 処理のキャンセル: • 外部 API コールを別 Goroutine で行いたいが、一定時間でタイムアウトさせたいとか • 複数 Goroutine

    で並行処理させてるうちのどれか一つでもエラーが発生したら他をキャンセルしたいとか Go の並行処理
  42. channel によるキャンセル: • キャンセル通知用の channel を作り、そこに通知 する方法 Go の並行処理 func

    main() { cancel := make(chan struct{}) go infiniteSayHello(cancel) // Goroutine 生成 time.Sleep(3 * time.Second) // 3秒待って close(cancel) // channel にクローズを通知 fmt.Println("finish") } func infiniteSayHello(cancel chan struct{}) { // 無限ループしながら hello を出力する for { select { case <-cancel: // 受信したらリターン return default: fmt.Println("hello") } } }
  43. context によるキャンセル: • context はデッドラインやキャンセル、その他のリ クエストスコープな値を管理するもの • 右のコードでは1秒後に context がタイムアウト

    し、ctx.Done() を受信し sayHello が終了するの で、”hello” は出力されない Go の並行処理 func main() { ctx := context.Background() // 1秒でタイムアウトするよう設定 ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() go sayHello(ctx) // context を渡す time.Sleep(3 * time.Second) } func sayHello(ctx context.Context) { select { case <-ctx.Done(): // cancel が呼ばれた時の処理 fmt.Println("cancel") return case <-time.After(2 * time.Second): fmt.Println("hello") } }
  44. context によるキャンセル(Case1): • ctx0 から ctx1 を作って Goroutine に渡す •

    右の状態で ctx1 をキャンセルする Go の並行処理 Goroutine ctx0 => ctx1 Goroutine Goroutine ctx1 ctx1
  45. context によるキャンセル(Case1): • ctx0 から ctx1 を作って Goroutine に渡す •

    右の状態で ctx1 をキャンセルする Go の並行処理 Goroutine ctx0 => ctx1 Goroutine Goroutine ctx1 ctx1 両方キャンセルされる
  46. context によるキャンセル(Case2): • ctx0 から ctx1, ctx2 を作って Goroutine に渡す

    • 右の状態で ctx1 をキャンセルする Go の並行処理 Goroutine ctx0 => ctx1, ctx2 Goroutine Goroutine ctx1 ctx2
  47. context によるキャンセル(Case2): • ctx0 から ctx1, ctx2 を作って Goroutine に渡す

    • 右の状態で ctx1 をキャンセルする Go の並行処理 Goroutine ctx0 => ctx1, ctx2 Goroutine Goroutine ctx1 ctx2 ctx1 を持つ Goroutine だ けがキャンセルされる
  48. context によるキャンセル(Case3): • ctx0 から ctx1を作って Goroutine に渡す • さらに

    ctx1を渡して Goroutine を作る • 右の状態で ctx1 をキャンセルする Go の並行処理 Goroutine ctx0 => ctx1 Goroutine Goroutine ctx1 ctx1
  49. context によるキャンセル(Case3): • ctx0 から ctx1を作って Goroutine に渡す • さらに

    ctx1を渡して Goroutine を作る • 右の状態で ctx1 をキャンセルする Go の並行処理 Goroutine ctx0 => ctx1 Goroutine Goroutine ctx1 ctx1 ctx1 を持つ Goroutine が キャンセルされる
  50. context によるキャンセル(Case4): • ctx0 から ctx1を作って Goroutine に渡す • さらに

    ctx1 から ctx2を作って Goroutine に渡す • 右の状態で ctx1 をキャンセルする Go の並行処理 Goroutine ctx0 => ctx1 Goroutine ctx1 => ctx2 Goroutine ctx1 ctx2
  51. context によるキャンセル(Case4): • ctx0 から ctx1を作って Goroutine に渡す • さらに

    ctx1 から ctx2を作って Goroutine に渡す • 右の状態で ctx1 をキャンセルする Go の並行処理 Goroutine ctx0 => ctx1 Goroutine ctx1 => ctx2 Goroutine ctx1 ctx2 ctx1 とそこから派生した ctx2 を持つ Goroutine がキャンセルされる
  52. M:Nスケジューラ: • N個のOSスレッドに 、M個のユーザースレッド(Goroutine)をマッピングする • マルチコアにスケールし、マシンリソースを効率よく使うことができる Go の並行処理

  53. Go の並行処理 M:Nスケジューラ: • OS にもスケジューリングの機構はあるが... • Go のユーザースレッド側でスケジューリングを行うのは効率がいいから ◦

    コンテキストスイッチコストの削減 ▪ OS スレッドの切り替えは比較的オーバーヘッドが大きい ◦ Go ランタイム都合のスケジューリングが可能 ▪ 例えばガベコレの割り込みなどを任意のタイミングですることができる
  54. スケジューリングの登場人物: • M(Machine) ◦ OS スレッド • G(Goroutine) • P(Processor)

    ◦ GをMに割り当てる ◦ P をいくつ起動できるかは環境変数 `GOMAXPROCS` で定義される ◦ デフォルトはコア数と同一 Go の並行処理
  55. スケジューリング: • イメージはこんな感じ • 通常、PとMが1対1でマッピングされ、それごと に Goroutine を入れるキューがある • 保持する

    Goroutine の数が少なくなると他の キューから盗むことで平準化を図る • Goroutine はプリエンプティブ • なので正確にはタスクではなく継続を盗んでい る Go の並行処理 ◦ 出典: 並行処理を支える Goランタイム| Goでの並行処理を徹底解剖! M(Machie) : G(Goroutine) : P(Processor)
  56. • Go は Goroutine (ユーザースレッド)とそのスケジューリングによって効率的な並行処理を実現している • Goroutine 間の値の受け渡しやイベントの伝播など、並行処理に必要なプリミティブを豊富に提供してくれる • 理解は必要だが、意識はしなくて良いので楽に並行プログラムが書ける

    ◦ 意識するのは Goroutine のみで、OS 側のランタイム事情を考えなくても最適化される まとめ
  57. • 正直、LT 枠に合わせたらめちゃくちゃ薄い内容になりました... • 興味が湧いたらぜひ Go を書こう!! • 参考 終わりに

  58. fin