他の言語の特徴を知ることでお道具箱の引き出しを増やし、いいソフトウェアを作っていきたい。そんな思いでこの本を取った。会社にある図書館でたまたま目について目次を見たら、ゴルーチンやら最適化やら中上級レベルな香りがしたので読むことにした。
いいなと思った章を抜粋しながらGoの落とし穴を知っていきたい。(気づいたら年末だ)
正しく作る、わかりやすくする、簡潔にする、速くする、この順番で。
by Wes Dyer
インターフェース汚染
no.5 インターフェース
インターフェースのユースケース3つ
共通の振る舞い
具体的な実装との分離
振る舞いの制限
JavaやC#では、具象型を作成する前にproducer側classにインターフェースを作成するのが自然だったが、Goでは、作成した後に発見してconsumer側classに作るものという違いがある。
consumer側classに作ると、Goの、producerは consumerの型を暗黙的に満たすという仕様を活用できる。
No9 ジェネリクスを使うタイミング
ジェネリクスとは、関数に指定した型を呼び出し時に決めることができる記法の事を指す
1.18でジェネリクスが導入された。
どんな型でも受け付けられる関数を書く時、従来はanyを引数に取っていたが、anyを使うと型付け言語の特性が壊れる。
func foo[T any](t T) {}みたいにすると、呼び出し時に型を指定できるので、foo関数の型付けを失わなくて済むし、受け付けられる型の柔軟性を損なわなくて済む。
ジェネリクスの一般的な用途は2つある
- データ構造(二分木、リンクリスト、ヒープ)
- 任意の型のスライス、マップ、チャネルを処理する関数
ただ、コードが複雑になる場合はジェネリクスを使わない方がいい。見通しがいいことは、型の柔軟性が向上することより数倍いい、らしい。
- キーワード
インスタンス化はコンパイル時に行われる
〜intとintの違いがある
intはint型に制限されるが、〜intはintを基底型とする全ての型に制限する
|
|
No10 型埋め込み
埋め込まれた型のフィールドやメソッドをプロモートするために使われる。
埋め込みとOOPのサブクラス化は似ているから混同する。埋め込み型では、埋め込まれた型がメソッドのレシーバのままである一方で、サブクラス化は、サブクラスがメソッドのレシーバになる。(レシーバって何を受けとる人なの?サーバー並みに広義な言葉!)
レシーバとは、メソッドの型のことを言う。下記でいう、customerである。
|
|
no15 コードのドキュメント
// Deprecated: を使うことで、公開された要素を非推奨扱いできる。
// ComputePath: 2点間の最短経路を返す
// Deprecated: この関数は非推奨です。代わりに、ComputeFastestPathを使ってください
func ComputePath() {}
データ型
no17 8進数リテラル
Goでは、0から始まる整数リテラルを8進数(基数8)とみなしている。基数8の10は10進数の8に相当する。
|
|
uint32で渡さないといけないような関数(os.OpenFile)で8進数の整数が役に立つ。
8進数リテラル:0o644や0644。oは省略できる。
2進数: 0bや0Bを使う。0b100は4を意味する
16進数: 0xや0Xを使う。0xFは10進数での15を意味する。
- オーバーフロー
理解できないから割愛
- 浮動小数点の演算
使わないから割愛
- 比較
構造体に比較不可能な型(slice)が入っていると、コンパイルに失敗する。コンパイル時に失敗してくれる分には問題ない。productionでエラーが出る訳ではないので。
anyはコンパイルを通過する。実行時にエラーが起きる可能性がある。anyには型情報ほぼ無いからだ。
- sliceの長さと容量の違い
制御構造
- rangeの有用性
off by oneエラーを防げる。off by oneエラーとは、境界が曖昧になるため、上限にiを含むか、含まないかがわからなくなることを指す。
|
|
Goでは、代入されるものは全てコピーになるので、rangeループがスライスの内容に影響を与えることはない。
下記はaccountに影響を与えられない例。
|
|
sliceの中身を更新したい場合は、インデックスを使う。
|
|
sliceの構造を変えてもいいんだったら、sliceをaccountへのポインタのスライスに変えると、直接accountsを更新することができるようになる。
ただ、ポインタのスライスを処理すると予測可能性に欠ける。CPUキャッシュをうまく使えず、CPU処理効率を下げる可能性があるので、ポインタのスライスは注意して使わないといけない。
|
|
- range関数のチャネルの評価
|
|
- range関数の配列の評価
rangeは配列のコピーを作成するが、処理中に更新する対象は元配列になる。下記は、元配列の最後の要素を更新しているが、受け取っているvは配列のコピーであるため、出力は10ではなく2になる(おもしろっ)
|
|
配列へのポインタを使えば、配列はコピーされず、元配列を操作することになるので、10が出力されるようになる。もし配列サイズが大きくてコピーを作らないで済むなら、メモリ効率の観点からも&aが推奨される。
|
|
no 34
Goのbreakは一番内側のfor文、switch文, select文の実行を終了させる。(jsと同じだな〜)
一番外側にloopラベルを貼ると、breakはloopラベルが貼られた範囲から脱出するようになる。一番内側ではなく。
- defer文
defer分を取り囲んでいる関数がリターンするまで、defe文に与えられた関数の呼び出しの実行を遅らせることができる。
文字列
No.36 バイトコード列
漢は3バイトにエンコードされるため、len(“漢”)は3を返す。
ちなみにhelloは5。
なんか期待してるのと違う。lenは文字列には使わないようにしたい、あるいは、引数に取る可能性が決まっているを返す場合のみ使いたい
- ルーン
るんるん?
Goでは、ほとんどのI/Oは文字列ではなく[]byteで操作されるため、一旦文字列にしてbyteを返すような操作は余分なメモリ割り当てや計算が走るため非効率になる。まずは[]byteをそのまま操作できないか検討するといい。
- メモリリーク
部分文字列操作によって返される文字列が同じ基底配列を参照すると、メモリリークが発生する可能性がある。部分文字列操作の代わりに深いコピーを行うと、メモリリークを防げる。
関数とメソッド
関数は、何らかの入力と出力を生成できる。メソッドは与えられた型に付属する関数であり、その型はレシーバと呼ばれ、ポインタか値に限定される。
Goには参照渡しが存在しない。そのため、下記はポインタのコピーを渡している。
|
|
レシーバをポインタにしなければならない時
- メソッドがレシーバを変更する処理を書きたい時
- メソッドのレシーバがコピーできないフィールドを含む場合
レシーバがポインタであるべき時
レシーバが大きなオブジェクトな時、呼び出し時にコピーが不要になるため、ポインタにしておくといい。ポインタにしておくと、ポインタのコピーが渡されるため、値を複製せずに済む。
レシーバを値にしなければならない時
- レシーバのimmutablityを担保しないといけない時
- レシーバがマップ、関数、チャネルの場合
レシーバが値であるべき時
- レシーバが変更する必要のないスライスの場合
- レシーバが小さい配列や、可変なフィールドを持たない時
- レシーバがint, float64, stringといったPrimitiveな型の場合
並行処理
並行処理と並列処理の違い
並行処理(coucurrency)とは、一度に多くを扱うことで、並列処理(parallel)とは、1度に多くを行うこと。
並行処理は構造に関することで、別々の並行スレッドがそれぞれ別々に取り組めるステップを導入することで、逐次的な実装を並行なものに変えられるという利点、考えがGoにはある。
ピザ屋屋を例にして考える。
1人のウェイターが注文を受け、1台のかまどでピザを作る。
お客は、次々に注文を出し、ピザできるのを待つ。
顧客の行列が長くなってきた時の対処法の一つとして、提供者を増やすことがあげられる。ウェイターを2人に、かまどを2つに増やす。これは、並列(parallel)実装という。
もう一つが、注文を受ける、ピザ生地をこねて焼いたら完成の状態にする、ピザを焼くかまど、というそれぞれ担当を分離し、注文待ちのキューと商品提供待ちのキューに分ける方法がある。同じことを一度に複数回行うのではなく、全体の構造を変えるという意味で、並行処理(concurrency)という。
あるスレッドが注文を受けるウェイター、別のスレッドがかまどと仮定し、ウェイタースレッドはピザ生地作成者スレッドと協調しないといけないことを想像する。
ウェイタースレッドは、ピザ生地作成者スレッドにどのピザを作りたいか伝えないといけない。一方、ピザ生地作成者スレッドはかまどスレッドと通信しないといけない。
もし1時間あたりの接客数を増やしてスループットを向上させたい場合、どうすればいいか。その場合、ピザ生地を作るのは注文を受けることより時間がかかるため、ピザ生地作成者をもう1人雇うことが考えられる(スケールアウト)
構造は、注文受ける→ピザ生地を作る→かまどにピザ生地を入れる、のまま変わっていない並行処理(concurrency)という点で変更がないことに注目する。
並行処理が必ずしも早いとは限らない理由
OSのスケジューリングとGoのスケジューリングを比較することで、並行処理が必ずしも早いとは限らない理由を考察していきたい。
まず、スレッドはOSが実行できる最小の処理単位である。あるプロセスが複数の処理を同時に実行したい場合、複数のスレッドを生成する。各スレッドは、並行的にも並列的にも処理が可能だとする。
OSは、2つの事柄に関して、スレッドのプロセスを最適にスケジューリングする責任がある。2つの事柄とは、
作業負荷を複数のCPUに均等に分散させること、CPUサイクルを待ち時間を少なく消費させることてある。
OSスレッドがOSによってCPUコアに対しコンテキストスイッチをon/offするのに対し、ゴルーチンはGoランタイムによってOSスレッドに対しコンテキストスイッチをon/offしている。
ゴルーチンのスタックメモリサイズはGo1.4以降だと2KB。OSスレッドはLinux/x86-32だとデフォルトスタックが2MBになる。ゴルーチンはかなり軽量なスレッドと言われてるけど、こんな軽いとは思わなかった!
(ゴルーチンのコンテキストスイッチはスレッドのコンテキストスイッチよりも80%から90%高速。)
- Goのスケジューラの動作について
各OSスレッドはOSスケジューラによってCPUコアに割り当てられ、各ゴルーチンはOSスレッド上で実行される。
G(goroutine): ゴルーチン
M(machine): OSスレッド
P(processor): CPUコア
ゴルーチンは3つのライフサイクルを持っている。
実行中:
実行可能:
待機中:
ゴルーチンを作成済みだがProcessorが全て埋まっていて作成済みのゴルーチンを実行できない状態もある。その場合、ゴルーチンはキューに入れられて、Goのスケジューラルールに従って実行を待つようになる。
キューは複数のPで共有できるグローバルと、指定のPでのみ処理されるローカルがある。
Goスケジューラは、まずグローバルの実行可能キューをみて、なければローカル、ローカルにもなければ他のPから盗むようなアルゴリズムが組まれている。また、他のPでも実行中のGがなければまたグローバルを見て、それでもなければネットワークをポーリングするようになる。
マージソートアルゴリズムを並列処理化させた場合、処理対象が小さすぎるとPによる処理が高速すぎてゴルーチンが待たされる状況が発生する。
no57 チャネルとミューテックスの使い分け
一般的に、状態を共有したい場合や共有資源にアクセスしたい場合、ミューテックス(mutual exclusion)はその資源への排他的なアクセスを保証する。
チャネルは、データの有無にかかわらない、伝達のための仕組みである。そのため、協調や所有権の移転はチャネルを介して実現されるべき。という役割の違いがある。
それぞれの役割についてもっと見ていきたい。
チャネルは複数のゴルーチンをパイプで繋ぎ、値の送受信を行う通信機構である。種類は2つに限定される。
バッファあり: 強力な同期を提供しない。
バッファなし(同期チャネル): 同期を可能にする。2つのゴルーチンが既知の状態にあることが保証される。つまり、もう一つのゴルーチンはメッセージを受信し、もう一つはメッセージを送信している。
G1 ↘️並行
|並列 G3
G2 ↗️並行
一般に、並列(parallel)ゴルーチンは、スライスのような共有資産にアクセスしたり修正するときに同期しないといけない。ミューテックスでは同期は強制されるがチャネル型では強制されないため、並列ゴルーチン間の同期はミューテックスで達成されないといけない。
一方、並行ゴルーチンは協調してオーケストレーションしなければならない。G3がG1とG2の結果を集約する場合、G1とG2は途中計算結果をG3に通知しないといけない。この、通知は通信になるため、並行ゴルーチンはチャネルで達成されないといけなくなる。
また、並行ゴルーチンは構造的に順序に依存関係が生まれるため、あるステップから別のステップに資源の所有権を移動させたい場合がある。例えばG1とG2が共有資源を操作していて、ある時点で処理が完了したとみなす場合、所有権の移転を処理するためにチャネルを使う必要が出てくる。
CPUバウンドとI/Oバウンドの作業負荷の影響
プログラミングにおいて、作業負担の実行時間は、次のいずれかによって制限される。
- CPUの速度: 例えばマージソートアルゴリズムを実行するとき。この作業負荷はCPUバウンドと呼ばれる
- I/Oの速度: 例えばREST呼び出しやデータベースのクエリを行う場合。I\Oバウンドと呼ばれる。
- 利用可能メモリ量: メモリバウンドと呼ばれる。
並行アプリケーションの文脈では、各負担を分類することが重要。なぜ重要なのかを、並行処理パターンの一つであるワーカープールと共に勉強していきたい。下記は、1024バイトを繰り返し読み取ってtask関数に渡す逐次的なコードになっている。
|
|
この逐次的な処理を並列処理に変えたい場合、ワーカープールパターンを採用すればいい。全てのtask関数を並列に実行できるようになる。ワーカープールパターンとは、固定サイズのワーカー(ゴルーチン)のプールを作成することであり、共通のチャネルからタスクをこのプールに受信させることができる。(チャネルは通信する時のパイプ役になる)
ワーカープールパターンに書き直してみる。
|
|
プールと同じ数のチャネルと、nのデルタ(未終了のゴルーチン)を持つWaitGroupを作成することで、チャネルがメッセージを発行する時に親がルーチンが競合を発生させず可能性を防止している。プール数を指定することで、ゴルーチンの数を一定に保てる。資源の増減をワーカープール数でコントロールできるようになるので、資源の影響が小さくなり、外部システムが影響を受けるのを防いでいる。
じゃあ、プールの大きさの値の理想値はなんなのか?
作業負荷がI/Oバウンドな場合、理想値は外部システムによる。(スループットを最大化したい場合とか)
作業負荷がCPUバウンドな場合、理想値はGOMAXPROCSに依存する。
GOMAXPROCSは実行中のゴルーチンに割り当てられるOSスレッド数を設定する環境変数のこと。
CPUバウンドの場合にプール数の理想値がなぜGOMAXPROCSに依存するのか。
例えば4つのCPUコアを持つマシンでアプリケーションでGOMAXPROCSを4にすると、4つのコア(Processor)で均等に処理を実行できる。
そんなわけで、GOMAXPROCSをCPUコア数に指定すると、処理できるゴルーチンの数をコントロールできて、作業負担の実行速度が早くなることにつながるというわけだ。
覚えておきたいのが、並行アプリケーションの実行速度の改善チケットに直面したときは、必ずベンチマークによって仮説を検証すべきということだ。pprofなどのプロファイリングツールを使用してI\OバウンドかCPUバウンドなのか、ボトルネックはどこなのか、をメトリクスを読んで判断しないの検討外れな速度改善になってしまう。あくまでも、検討外れになってしまう、可能性があるだけだが。工数の見積もりをミスったら信頼が減るでしょう?ね?ちゃんと数字を見よう!
No60 Goのコンテキスト
context.Contextは、APIの境界を越えて、デッドライン、キャンセル通知、そして他の値を伝える。
Contextにはいろんな値を込める。Lambda handlerのようなミドルウェアでは、リクエスト元の情報(cognito subや時間、識別IDなど)をcontextに込めてサーバーに投げていたが、Goにも存在していることを知った。
データの損失が無いように後処理をきちんと行う、つまりグレースフルにするためにcontext.WithCancelを使ったりする。
標準ライブラリで利用可能なコンテキストは、すべて複数のゴルーチンによる並行使用に対して安全である、と覚えておけば、熟練のGo開発者になれそう。
ただ、コンテキストを不適切に使うと大変なこともある。
HTTPリクエストに関連付けられたコンテキストは、さまざまな状況でキャンセルが可能。
- クライアントのコネクションが終了したとき
- HTTP/2リクエストの場合、リクエストがキャンセルされたとき
- レスポンスがリクエストに書き戻されたとき
no62 ゴルーチンを停止するタイミング
ゴルーチンは簡単に作れるため、停止タイミングを念頭に置いておかないと、ゴルーチンリークがよく起こる。
ゴルーチンの最小スタックサイズは2KBから始まり、状況に応じて増減する(最大スタックサイズは64ビットアーキテクチャで1GB, 32ビットアーキテクチャで250MB)
ゴルーチンはヒープに割り当てられた変数参照も保持できるため、ゴルーチンリークが起きると、データベースコネクションやオープンされたファイル、ネットワークソケットなど、最終的にグレースフルにクローズすべき資源もリークにつながるため、ゴルーチンリークは起きない設計にしないと知らぬ間に巨額の損失が生まれることにつながりかねない。
|
|
この処理は、チャネルがいつクローズされるかが正確にわからないため、チャネルがいつまでもクローズされないといったゴルーチンリークが起きる可能性がある。避けたい。
これを避ける方法の一つに、処理が終わった時に必ずキャンセル(クローズ)されるコンテキストをゴルーチンに渡すことかある。すべての処理が完了する前にクローズすることを防ぐために、クローズメソッドにはdeferを必ずつける。
no63 ゴルーチンとループ変数の扱い
下記コードは、クロージャでゴルーチンを作成しているため、コードの出力は決定的ではない(223や333を表示する。123を期待してたのに)
|
|
クロージャとは、本体以外の変数を参照する関数値のことで、ここではiがその変数にあたる。
iは時間的に変化するため、ゴルーチンが新たに生成された時のiの値を決定しておきたい場合は、iを新たな変数に入れておく必要がある。
|
|
ここでvarはゴルーチンが作られる前のiを保存しているため、期待通り1,2,3が順不同で表示される。
コードの結果を決定的にするもう一つの方法が、クロージャとしてiへアクセスするのではなく、引数に入れたiへアクセスする方法が挙げられる。
|
|
no64 selectとチャネルを使って決定的な動作を期待する
switch文は最初に一致したケースを選ぶ一方で、select文は複数の選択肢がある場合、ランダムでケースを選択する。スタベーション状態(あるチャネルの通信が速い場合、他のチャネルが通信できない状態)を防ぐため。
下記のように書けば、複数のチャネルから受信する場合に1つのチャネルから残り全てのメッセージを確実に受信してグレースフルに処理を終えることができる。
|
|
no65 通知チャネル
受信側はメッセージの内容から意味を期待せず、メッセージを受信したという事実だけを知ることができる通知のこと。
Goでは、接続断が発生した!という通知だけを送りたい場合は、空構造体のチャネルを送るのが慣習になっている。
空構造体のチャネルは0バイトなので、ただシグナルを送りたいだけならメモリを食わないもので済ませたい。
|
|
データ競合と競合状態の違い
データ競合は、2つ以上のゴルーチンが同時にメモリ位置にアクセスし、少なくとも1つが書き込み中である場合に発生する。
競合状態は、制御できないイベントの順序やタイミングに動作が依存する場合に発生する。
データ競合が発生する例を見ていく。
|
|
このコードを-raceフラグを使って実行するとデータ競合が発生する。
このコードはの問題はどこにあるのか。処理を分解してみると見えてくる。
- iを読み込む
- 値をインクリメントする
- iへ書き戻す
1つ目ゴルーチンがiを読み込むと同時に2つ目のゴルーチンがiを読み込み、それぞれステップ2を実行すると、最終的にiが1になることがある。ゴルーチンの結果が読めない実装になってる時点で怪しいカードになっちゃってるのが分かった。これにはいくつかの対策がある。
- インクリメント操作(更新操作)をアトミックにする。
読み込みと更新を一つの操作にすることで、まだ更新操作が終わっていないiへアクセスされることを防ぐ。アトミックな操作には割り込みができないため、データ競合を防ぐことができる。
|
|
ただ、sync/atomicパッケージは特定の型に対してのみ機能するため、それ以外(スライス、マップ、構造体など)のデータ競合を防ぎたいなら次にあげる方法を試すしかない。
- 2つのゴルーチンをミューテックスのような特別なデータ構造で同期させる
ミューテックスは、いわゆるクリティカルセクションにアクセスされるゴルーチンを1つに限定させることを保証するという特徴がある。
|
|
- 通信とチャネルを使って、1つのゴルーチンのみが変数を更新するようにする
各ゴルーチンはチャネルを介してiを1だけインクリメントするように通知を送り、親ゴルーチンがその通知を集めてiをインクリメントしている。
この方式は、親ゴルーチンのみがiを更新しているためデータ競合が起きない。
|
|
データ競合を防ぐ3つの方法、アトミック操作、ミューテックス、チャネルについてを学んだ。Goは原因と対策が分かりやすくていい。もっとも今回の例は概念を説明するために大部分が省略された極めて簡潔なコードであるからにすぎないが。実際のコードベースでエラー調査をする際に応用できたらシニアエンジニアになれるだろうな。
次は、競合状態が起きる場合と防ぎ方を見ていく。
|
|
この例では、ミューテックスを使用しているためデータ競合は発生しないが、競合状態が発生する。ゴルーチン間の特定の実行順序を制御する役割は、調整とオーケストレーションが担っている。制御できないイベント(ゴルーチンの実行、チャネルへのメッセージ送信速度、データベースへの呼び出しの持続時間など)に依存する動作を含むアプリケーションは競合状態が発生するため、APIを実装するときは注意が必要だ。
- Goメモリモデル
1つのゴルーチンにおいて、ある変数からの読み出しが、別のゴルーチンでの同じ変数への書き込みの後に起こることを保証する条件を定義した仕様のこと
ゴルーチンの作成は、ゴルーチンの実行が始まるよりも前に発生する。したがって、変数を読み込んでから、この変数に書き込む新たなゴルーチンを生成しても、データ競合は発生しない
|
|
ゴルーチンの終了は、どのイベントよりも前に発生することが保証されているわけではないため、下記のコードはデータ競合が発生してしまう。
|
|
チャネルaからチャネルbへの送信は、チャネルbがチャネルaからの受信が完了する前に発生する。
|
|
順序は、変数のインクリメント<チャネルへの送信<チャネルからの受信<変数の読み込み、の順になる。従って、データ競合は発生しない。
チャネルのクローズは、そのクローズを受信する前に発生する。従ってこれもデータ競合が発生しない。
|
|
バッファなしチャネルからの受信は、そのチャネルの送信が完了する前に発生する。
バッファありとなしを比較する。
|
|
この例では、読み込みと書き込みが同時に起こる可能性がある。iは同期されていない。
次に、バッファなしを見てみる。ここでは、書き込みが読み込みの前に発生することが保証されているため、データ競合は発生しない。
|
|
データ競合とデッドロック
並行処理でスライスを扱っているときにスライスに対してappendを使うとデータ競合が発生するかもしれないということを念頭におくべき。
下記は、s2とs1が同じ長さと同じ容量を持ち、同じ基底配列を参照しているために起こる現象。s2を更新したはずが、s1も更新されている点に戸惑うかもしれない。
|
|
複数のゴルーチンを扱う時、同期なしだと実行が決定的にならない、ていうのがGoの特徴。
CPUは順序を保証するためにメモリフェンスを使う必要がある。happens before関係を、順序を保証させたい処理間に持たせる。
最適化
メカニカルシンパシーを磨く。
まずは、処理速度やメモリの最適化のためにCPUアーキテクチャの概要に触れる。
- キャッシュの種類と位置
最近のCPUはメモリアクセスを高速化するためにキャッシングに依存していて、大抵、L1,L2,L3の3つのレベルを経由してメインメモリはアクセスされる。メインメモリに到達するまでに欲しい情報がキャッシュにあれば、そのキャッシュがあるレベルで止まり、リクエスト元へ情報が返る。それぞれ大きさは3に近づくにつれて大きくなっていくが、速度は遅くなっていく。
L1: 64KB, 1ナノ秒
L2: 256KB, L1の4倍遅い
L3: 4MB, L2の10倍遅い
- キャッシュライン
メインメモリから取得した固定の大きさの連続したメモリセグメント。通常は64バイト(8個のint64変数)になる。
参照局所性という原則に、時間的局所性と空間的局所性という概念がある。
時間的局所性とは同じ位置が再び参照されることを指し、空間局所性とは近くのメモリ位置が参照されることを指す。
|
|
この例で時間的局所性は、i, length, totalという複数の変数に適用される。
これらの変数は反復の間アクセスされ続ける、つまり同じ位置が再び参照されているため時間的局所性ってわけ。
空間的局所性は、コード命令とスライスsに適用されている。スライスはメモリ上に連続的に割り当てられた配列に基づくため、s[0]にアクセスされるということは、s[1],s[2]にアクセスされることにつながるため、近くのメモリ位置が参照されている、つまり空間的局所性になる。
同じ変数への反復アクセスを高速化させる時こそCPUキャッシュの出番だが、空間的局所性のため、CPUはメインメモリから1つの変数をコピーするのではなく、キャッシュラインと呼ぶ単位でコピーする。
- 構造体のスライスとスライスの構造体
これは、構造体のスライスを受け取るsumFooという関数
|
|
これはスライスの構造体を受け取る
|
|
この二つの速度面を比較すると、スライスの構造体を受け取るsumBarの方がベンチマークが速くなる。
例えばスライスにはFooの要素が16個、Barのa,b各スライスには16個の要素が存在すると仮定する。
するとFooは4キャッシュライン、Barは2キャッシュラインを利用するため、キャッシュラインの少ないBarの方がメモリアクセス回数(今回だとキャッシュアクセス回数)が少なくなるため、ベンチマークが速くなるというロジックになる。
使用しているメモリ量は同じなためメモリの圧縮最適化には繋がらないが、今回sumBarとsumFooはaフィールドしか利用していないので、空間局所性の高いsumBarの方速い。CPUがメモリからフェッチするキャッシュラインが2少ないため。
より詳細に話してみる。
sumFooはaとbの2つのフィールドを含む構造体のスライスを受け取るため、メモリ上にはaとbの連続が存在することになる。
例)
structのスライスはstruct,struct,structのようになる。
structはa,bフィールドで構成されているため、
a,b,a,b,a,bというメモリ割り当てになる。
一方でsumBarはaとbの2つのスライスを含む構造体を受け取るため、メモリ上にはaの全ての要素が連続的に割り当てられる。
例)
structはa,bのスライスを持っているため、[a,a,a,a,a],[b,b,b,b,b]で渡ってくる。
そのため、a,a,a,a,a,b,b,b,b,bというメモリ割り当てになる。
- 予測可能性
アプリケーションの実行を高速化するために何をすべきかを予測できるようにするCPUの能力
もう一つ、CPUの最適化を助ける、予測可能性という概念がある。
リンクトリストの値と隣接ノードの値を反復処理する関数とスライスの2つの要素のうち1つの要素に対して反復処理を行うsum2を比較して、予測可能性がCPU処理速度にどんな影響を与えるのかを見てみる。
まずはリンクトリストから。
|
|
リンクトリストは値と64ビットポインタ要素の連続になっているため、2つの要素のうち一つの要素を使って合計を求めている。
次に、sum2を見てみる。
|
|
sum2は2つの要素のうち1つの要素だけを読み出す。
この二つは空間的局所性を持っている点で実行時間が似ていると期待するかもしれないけど、実際はスライスを反復する関数の方が圧倒的に速く実行される。
この現象を理解するにはまずストライドという概念を知る必要がある。
ユニットストライド:
定数ストライド:
非ユニットストライド:
- キャッシュ配置ポリシー
フルアソシアティブ、セットアソシアティブなどのキャッシュ配置ポリシーを活用すると、クリティカルストライド問題をクリアしてベンチマークを高速化することができる。細かい仕組みはあんま理解できなかったから活用する。いずれAPIを高速化させないといけなくなった時に思い出せるように、とりあえずトラバース対象が64×8の倍数上ちょうどになってないか?という問いを覚えておきたい。
no92 偽共有につながる並行コード
CPUが追跡する粒度はキャッシュラインであるため、キャッシュラインが複数のコアで共有され、少なくとも1つのゴルーチンが書き込みを行う場合にキャッシュライン全体が無効化されること
解決策は2つある。具体的な実装を置いておく。
a,bそれぞれの総和をResult構造体のそれぞれsumA,sumBに保存する処理の例から、解決策を考えていきたい。
|
|
- 二つのゴルーチンが同じキャッシュラインに含まれないようにすること。
同じキャッシュラインに含まれるとは、64バイト長の中に総和suAとsumBが乗っていることをさす。なので、同じキャッシュラインにならないようにするために、フィールド間(sumAとsumB)に64-8バイトの56バイトのパディングを追加してあげる。
|
|
- アルゴリズムの構造を作り直すこと。
両方のゴルーチンに同じ構造体を共有させる代わりに、チャネルを介してローカルな結果を通信するようにさせる。そうすると、パディングを使用する時と同じ結果になる。不思議!
no94 命令レベルの並列処理
- 命令レベル並列処理の理論
逐次的書かれた命令がレジスタに保存されているが、命令のデコードと実行は並列に処理されるという魔法。
ハザードという難題をもたらす。
データハザードは、レジスタの計算に依存関係が生まれることによって引き起こされる。例えばInstruction2はレジスタCとレジスタDの数値をレジスタEへ加算する時、レジスタCの計算結果を待たなければならない。
コントロールハザードは、条件文による依存関係が生まれて並列処理ができなくなり、クロック回数が増えてしまうことを指す。
no94 データアラインメント
CPUによるメモリアクセスを高速化するために、データの割り当て方を整えること。
割り当てるメモリ量を減らすために、構造体のフィールドを型の大きさの大きい順に構成する。
Goは、構造体ないのフィールドが同じキャッシュラインに含まれないようにするために、コンパイル時にパディングをあてている。そのため、データアラインメントを行うことで無駄なメモリ割り当てを減らし、GCの頻度を減らしたり空間的局所性を向上させることにつながる。
no95 スタックとヒープ
Goでは、変数をスタック上かヒープ上のどちらかに割り当てることができる。
- スタック
スタックはデフォルトのメモリで、特定のゴルーチンのローカル変数を全て格納するLIFOデータ構造のこと。
Goでは関数が呼び出されると、その関数だけがアクセスできるメモリ上の区間としてスタックフレームが作成される。
main関数で呼ばれた関数Foo内で使われてる変数は、Foo関数がリターンされた時、スタックフレーム上に取り残される。どこからも参照されないが、自己クリーニングが行われる。
- ヒープ
全てのゴルーチンが共有するメモリのプール
G1 G2 G3
スタック スタック スタック
ヒーーーーーーーーーーーーープ
3つのゴルーチンはそれぞれ独立したスタックを持つが、ヒープは共有している。なんかタスクとスレッドの違いみたいだな。
ヒープはGCという外部システムによってのみ領域のクリーニングが行われる。つまり、ヒープへの割り当てが増えれば増えるほどGCへの負担が増える。通常GCの実行はCPU容量の25%を使うため、ミリ秒単位のストップザ・ワールド遅延を発生させる可能性がある。
スタックとヒープの速度の違いを調べるためにsumValueとsumPtrをベンチマークしてみる。
このように、スタックは自己クリーニングを行い、単一のゴルーチンに対して局所的に存在するため、割り当てを高速化することができる。
シェアリングアップとシェアリングダウン。
no96 ヒープ割り当てを減らす方法
- 文字列の連結に+演算子の代わりにstrings.Builderを使う
- 可能な限り、[]byteを文字列へ変換させない
- 長さが既にわかっている場合、スライスとマップを事前に割り当てる
- APIを変更する
- コンパイラの最適化に頼る
- sync.poolを使う
no99 GCの仕組み
no100 DockerやKubernetesでGoを実行することによる影響
所感
ゴルーチンは理解した上で使いこなせたらGopherにとって強力な武器になるって、同僚が言ってたけど、ゴルーチンリークとかデータ競合とか、知らないと嵌る落とし穴が多くて、こりゃ大変だと思った。OSやCPU、スタック、ヒープなど、パフォーマンスを最適化するボトルネックがより低レイヤーにある点がいいなと思った。あれ、コンピュータサイエンスの理解を試してる?PCに対するメカニカルシンパシーを持っているか試されてる?という気持ちにさせてくれる言語なのかな、Go言語は。
参考
100 Go Mistakes and How to Avoid Them:
Common Go Mistakes:
Go言語 100Tips ありがちなミスを把握し、実装を最適化する impress top gearシリーズ:
https://www.amazon.co.jp/Go言語-100Tips-ありがちなミスを把握し、実装を最適化する-impress-gearシリーズ-ebook/dp/B0CFL1DK8Q