🦜

Goroutineの使い方

2023/12/06に公開

この記事はGo 言語 Advent Calendar 2023のシリーズ2の4日目の記事です(穴があったので入りました!)。

goroutineの特徴

コルーチンをベースにコルーチンの以下の点を改良

  • 言語組み込みワード「go」にて起動できる
  • M:Nスレッドシステム採用によりマルチコア分散処理が可能
  • ブロッキングを検出したらネイティブスレッドが独立
  • プリエンプティブ性を追加(Go1.14以降)

以上により、goroutineスレッドシステムはコードを書く人にとって「ネイティブスレッド」の感覚で実装を書くことができ、「コルーチン」のようにメモリやタスクスイッチ負荷が小さく、「コルーチン」のような面倒な制約(期待するレイテンシ以上にCPUビジーにしてはいけないなど)も無いといういいとこどりのスレッドシステムになりました。

ネイティブスレッドライクによる特性

  • ネイティブスレッドを使ったプログラミングと同様にメモリアクセス競合をケアする必要あり
  • ある実装はgoroutineに載せ替えでもメインスレッドでもどちらでも動く
  • そもそもメインスレッドもgoroutineなので区別がない
  • つまり手続き的に書いたコードを並行に動かすか、シーケンシャルに動かすかは自由に選択できる
  • goroutineは軽量でありながらネイティブスレッドっぽい特性を持つ
  • わざわざOSのネイティブスレッドやサードパーティ製スレッドシステムを利用する用事がない
  • 結果としてGoのコードが依存するスレッドシステムはほぼほぼgoroutineのみ

すると何が起こるのかというと、世にあるライブラリのほとんどが同期的に利用するのはもちろん、必要であればgoroutineに載せて非同期的に利用することも自由です。
また、そうして作られたコードの多くはWindowsやmacOS、LinuxなどOSに依存することなく動きます(標準ライブラリもクロスプラットフォーム向けに作られていることもあります)。

この自由さと圧倒的なポータビリティはこれまでの既存言語にはなかなか達成できていないGoならではの特徴です。

goroutineの方針

  • 言語仕様に組み込み、デフォルトランタイムで管理される
  • IDや名前などの識別情報へのアクセスを禁止
  • ネイティブスレッド縛りが発生するスレッドローカルメモリを禁止
  • OSごとにサポートの異なるプロセスフォークを禁止
  • 生成したgoroutineを変数へ束縛することを禁止
  • 初期スタックサイズが2KB(Go1.4にて8Kから2Kに変更)
  • goroutineをちゃんと終了するにはreturnするのみ

以上によりgoroutineの特殊な使われ方を防ぎ、ユーザーに妨げられることなくランタイムはgoroutineに対する制御を自由にできます(それによりランタイムの改善も進めやすい)。
さらにOS依存の強い機能やネイティブスレッド特有の機能をユーザーに触らせないことで、Goのコードは多くの環境で同じように動きます。

goroutineの間違った使い方

終了しないgoroutine

基本はgoroutineの破棄をプロセス終了処理に任せないようにしましょう。特にライブラリでは御法度です。

go func() {
	for {
		...
	}
} ()

context.Contextを第一引数に受け取って、以下のようにすることが推奨されます。

go func(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		...
		}
	}
} (ctx)

また、チャネルを受け取ってチャネルが閉じるのを待つパターンもあります。
この場合はチャネルの提供元がちゃんとチャネルを閉じることに責任を持ちましょう。

go func(ch <-chan int) {
	for v := range ch {
		...
	}
} (ch)

メモリの消費量を考慮しない使い方

https://pkolaczk.github.io/memory-consumption-of-async/

Goで10秒スリープを行う100万個のgoroutineを起動した場合、メモリ使用量が2.6GBを超えてしまいますが、こういう使い方をGoでは想定していて可能ではあるんですが、他の処理系に比べてメモリ使用量が大きくてびっくりするかもしれません。これを問題とみるかどうかは用途次第だと思いますが、1つのgoroutineが担当するにはタスクが単純すぎてもったいない使い方と言えます。

実際に100万個のgoroutineを扱うネットワークゲートウェイのような案件に取り組んだこともありますが、その場合「1セッション=1goroutine」で最大100万セッションを想定していました。その場合のメモリ試算は上記の通りでそれを前提にサーバーマシンのメモリ構成を決めることで特に問題にはなりませんでした。

「初期スタックサイズ」というものがひとつのgoroutineに期待される実装の規模を想定していると考えられます。あまり細かすぎるシンプルすぎるタスクをgoroutineに載せるのは推奨されないという事です。

他のメモリ占有量の少ないスレッドシステムではさらに小さなタスクを想定しているという事でもありますが、じゃぁgoroutineも初期スタックサイズをもっと小さくすればいいんじゃと一瞬雑なアイディアが思い浮かびますが、そういった小さなタスクを対象にするのは「ある不利益」につながります。

ベクトル型コンピューティングっぽいこと

goroutineの入力と出力に大量のデータを流し、それらの計算をたくさんのgoroutineに分散処理させる手法でベクトル型コンピューティング(SIMDやGP-GPUなど)の真似事が可能ではあるんだけど、実はこういう用途にgoroutineを利用する場合には注意すべきことがあります。

タスクスイッチにかかるCPU負荷よりも実際の処理内容が十分大きいかどうかは要確認です。
基本は「goroutineには最低ひとつのI/O待ちが必須」と考えてよいと思います。goroutineに渡す、結果を受け渡す以外の待ちが無いgoroutineを大量に起こすような使い方は避けましょう。

例えばPythonのジェネレーターのように1イテレートごとにタスクスイッチを行い大量のデータ処理を実装しちゃうやり方。

// 送信側
ch := make(chan int)
go func() {
	defer close(ch)
	for i:=0; i<10000; i++ {
		ch <- i
	}
} ()
// 受信側
for v := range ch {
	...
}

こういう送受信がベストエフォートで回るような処理にチャネルを使うと、実際の処理内容が軽い場合goroutineスイッチが微小な時間間隔(数十~数百ナノ秒オーダー)で発生します。チャネルバッファを増やせばスイッチ頻度を多少下げることはできますが・・・。

先述の「ある不利益」とはどういうことかというと、goroutineスイッチがあまりにも高頻度になってしまうと如何にgoroutineのタスクスイッチが軽量とはいえCPU負荷の多くをgoroutineスイッチに消費されてしまうという問題があります。

もちろん、goroutineタスクそれぞれが十分に重い処理でタスクスイッチ負荷が十分小さく気にならないレベルであれば問題にはなりません。

結局のところこういった処理をgoroutineをまたいで行う必要が本当にあるのかどうかは要検討です。goroutineをまたがずにクロージャーなどで十分高速に処理することができるのでgoroutineを使わない手法を検討してみるべきです。

もしくは計算量の粒度を大きくしてから複数goroutineに分散すべきです。

アクセス競合の多いデータ

排他処理(ロック解除待ち)の頻度の多さは並列度の敵
以下の順番に排他処理による待ちが減らせないか検討しよう

  1. クリティカルセクションでリードオンリー
  2. atomicにできるか
  3. 更新頻度の少ないデータはRWMutexで
  4. メモリ共有よりはchanで通信

クリティカルセクションでリードオンリー

  • 準備段階ではミュータブルなんだけど
  • 速度が重要な処理に入る時点でリードオンリー扱いに変更
  • こういうデータは排他処理不要
  • 複数のgoroutineから参照されても問題ない
  • Goではリードオンリーを強制は難しいが方法はある
  • それがわかる命名&運用でカバーすることもできる

atomicにできるか

  • 対象データをatomicパッケージを通してアクセス
  • 整数ならatomicパッケージで操作する機能がそろっている
  • atomicによる操作自体は複数goroutineからの操作に対し原子性が保たれる
  • つまり、不整合な操作にはならない
  • ただし、ポインタをLoad&Storeする場合そのフィールドアクセスには原始性が担保されない
  • 前述のリードオンリーなデータへのポインタであればOK

更新頻度の少ないデータはRWMutexで

  • RWMutexは参照のみならロック解除待ちがほぼ発生しない
  • 更新頻度が稀ならRWMutexにしておくと並列度の低下が抑えられる
  • ただし微小なれどロック確認コストはかかる

メモリ共有よりはchanで通信

  • chan通信がCSPの基本
  • chan通信はgoroutineセーフ
  • ただ使いどころを間違えるとロック解除待ちが頻発するので注意
  • 適切なところで使うのと適切なバッファサイズを選択すること

並列度を高く保つのは重要

https://zenn.dev/nobonobo/articles/e43cdca80650e4#アムダールの法則

ここのアムダールの法則に従い、クリティカルセクション(処理性能が重要な処理ブロック)の並列度を95%~99%あたりをキープできるように設計することは大事。
コア数が100個を超えるコンピューターを使うなら99.x%を確保しないとコア数の豊富さがあんまり活かされないです。

どうしても並列度が高くない処理をどうにかして事前処理に移設できないかは設計するうえでも考慮すべきポイントになります。

まとめ

  • goroutine内で動かす処理はI/O待ちを含む処理かCPU負荷の大きい処理であることが望ましい
  • goroutineに載せるタスクの粒度が細かくなりすぎないように
  • goroutine/select/chan/contextを活用してCSPの定石を学ぼう
  • 情報交換を共有メモリで行わず通信で情報交換することに心がけよう
  • 更新頻度の高いデータを排他処理すると並列度が落ちてしまうので注意
  • 並列度を高く保つのは重要

Discussion