1. ホーム
  2. スクリプト・コラム
  3. ゴラン

Go言語におけるエスケープ分析とは一体何なのでしょうか?

2022-02-15 18:19:29

1. エスケープ解析の紹介

コンパイル原理で、ポインタのダイナミックレンジを解析する方法をエスケープ解析と呼ぶことは、コンピュータを学ぶ人なら知っている。平たく言えば、あるオブジェクトへのポインタが複数のメソッドやスレッドから参照されることを、そのポインタを "escape" と呼ぶのです。

Goのエスケープ解析は、静的コード解析の後にコンパイラが行うメモリ管理の最適化と簡略化で、変数がヒープに割り当てられるかスタックに割り当てられるかを判断するものです。

C/C++を書いたことがある人ならわかると思いますが、より古典的な malloc new 関数はヒープ上にメモリブロックを確保することができ、このメモリの使用と再利用(破棄)の作業はプログラマの手に委ねられ、適切に処理されないとメモリリークが発生する可能性が高いです。

2. Goのメモリはどこに割り当てられているのか?

しかし、Goでは、メモリのリサイクルがGoで処理されるので(GCリサイクル機構)、基本的にメモリリークの心配はありません。また、新しい関数がありますが、それを使って new 関数は必ずしもヒープ上にあるわけではありません。ヒープとスタックの区別はプログラマにとっては曖昧なものですが、Goコンパイラが私たちのためにこれらすべてを支えてくれているのです。

Goのエスケープ解析の最も基本的な原則は、関数が変数への参照を返す場合は、エスケープするということです。

簡単に言うと、コンパイラがコードの特性やコードのライフサイクルを分析し、関数が戻った後に再び参照されないことを証明できる場合のみ、Goの変数はスタックに割り当てられ、そうでなければヒープに割り当てられるのです。

Goには、コンパイラが直接変数をヒープに割り当てることができるキーワードや関数はありません。その代わり、コンパイラはコードを解析して変数を割り当てる場所を決定します。

変数のアドレスを取ると、ヒープに代入されることがあります。しかし、コンパイラがエスケープ解析を行った後、この変数が関数のリターン後に参照されないという事実を調べると、やはりスタックに割り当てられることになる。

コンパイラは、外部参照されているかどうかで、変数がエスケープされるかどうかを判断しています。

  • 関数外部で参照されていない場合は、スタック領域より優先してスタックに配置されます。
  • は、関数外から参照される可能性がある場合、ヒープ領域に置かれます。

C/C++のコードを書くとき、効率を考えて、しばしば pass-by-value (値渡し)を pass-by-reference で、コンストラクタを回避して直接ポインタを返そうとします。

ここに大きな穴が隠されていることを思い出してほしい。関数内部でローカル変数を定義し、そのローカル変数のアドレスを返す(ポインタ)のである。このローカル変数はスタック上に確保され(静的メモリ割り当て)、関数の実行が終了すると、変数が占有していたメモリは破壊され、この戻り値に対して何らかの操作(デリファレンスなど)を行うと、プログラムが中断、あるいは無残にもクラッシュしてしまうのです。例えば、次のようなコードです。

int *foo ( void )   
{   
    int t = 3;
    return &t;
}



上記の落とし穴を知っていて、もっと賢い方法を使う学生もいるでしょう。関数内でnew関数を使って変数を構築し(動的メモリ割り当て)、その変数のアドレスを返すのです。この変数はヒープ上に作成されるので、関数が終了しても破棄されることはありません。

しかし、それでいいのだろうか? new いつ、どこから、オブジェクトを出すべきか delete は、いつ、どこで、どのように使うのでしょうか?呼び出し元が削除し忘れたり、返り値を取って別の関数に渡したりすると、いつまでたっても delete ということは、メモリリークが発生していることになります。この落とし穴については、Effective C++ Clause 21をチェックしてみてください。

3. GoとC++のメモリ割り当ての違い

上記で述べた C/C++ Goで遭遇する問題は、上記の難題を解決する言語機能として大々的に宣伝されています

C/C++で動的に割り当てられたメモリは、私たちが手動で解放する必要があります。そのため、一部のメモリが誤って処理されたり、時間内に解放されず、メモリリークにつながるという問題が発生します。

しかし、その利点はというと 開発者が自分でメモリを管理できる。

Goのガベージコレクションは、ヒープとスタックをプログラマから透過的に保ちます。これにより、プログラマの手は本当に解放され、ビジネスに集中し、効率的にコードを書くことができるようになります。メモリ管理の複雑な仕組みはコンパイラに任せて、プログラマは人生を楽しむことができるのです。

4. 解析荒らしからの脱出

逃亡分析とは、変数をあるべき場所に割り当てるという "空想の操作"です。newでメモリを申請しても、関数を抜けた後に使わないとわかったら、ヒープよりずっと速いスタックに放り込みます。逆に、一見普通の変数でも、逃走分析の結果、関数を抜けた後に他の場所で参照があるとわかったら、ヒープに割り当てるのです。

変数がすべてヒープに割り当てられると、ヒープはスタックのように自動的にクリーンアップされなくなります。そのため、Goは頻繁にガベージコレクションを行うことになり、ガベージコレクションは比較的大きなシステムオーバーヘッド(CPU能力の25%)を占めます。

ヒープはスタックと比較して、予測不可能なサイズのメモリ割り当てに適しています。しかし、その代償として、割り当てが遅くなり、メモリの断片化が形成される可能性があります。一方、スタックのメモリ割り当ては、非常に高速に行うことができます。スタックメモリの割り当てに必要な CPU 命令は、" PUSH と"です。 RELEASE 一方、ヒープで割り当てられたメモリは、まず適切な大きさのメモリブロックを探しに行く必要があり、後でガベージコレクションによって解放される必要があるのです。

エスケープ解析を使えば、ヒープに割り当てる必要のない変数を直接スタックに割り当てるようにすることができます。ヒープ上の変数が少なければ、ヒープメモリの割り当てにかかるオーバーヘッドを減らすことができ、さらに gc となり、プログラムの速度が向上します。

5. エスケープ分析導出の例示図

導出1. 変数がエスケープされているかどうかを確認するにはどうすればよいですか? 2つの方法があります。 goコマンドでエスケープ解析の結果を確認する。ソースコードをディスアセンブルする。

例えば、こんな例で。

package main
import "fmt"
func foo() *int {
    t := 3
    return &t;
}
func main() {
    x := foo()
    fmt.Println(*x)
}



goコマンドを使用する。

go build -gcflags '-m -l' main.go




を維持するために-lが追加されています。 foo 関数がインライン化されます。 は次のような出力をします。

# Command line variables
src/main.go:7:9: &t escapes to heap
src/main.go:6:7: moved to heap: t
src/main.go:12:14: *x escapes to heap
src/main.go:12:13: main ... argument does not escape

foo 予想通り、関数内の変数tはエスケープされました。私たちが理解していないのは、なぜ main 関数もエスケープされるのでしょうか?というのも、パラメータが interface のような型は fmt.Println(a ...interface{}) の場合、コンパイル時に引数の型を正確に判断することが難しく、またエスケープも起こり得ます。

逆アセンブルのコードは理解しにくいので、ここでは割愛します。

引用2. 下のコードの変数がエスケープされたのでしょうか?

まず、例1を見てみましょう。

package main
type S struct {}
func main() {
  var x S
  _ = identity(x)
}
func identity(x S) S {
  return x
}



分析 : Go言語の関数渡しはすべて値で、関数が呼ばれると、引数のコピーがスタック上に直接取り込まれ、エスケープもされません。

 もう一度、例2を見てください。

package main
type S struct {}
func main() {
  var x S
  y := &x
  _ = *identity(y)
}
func identity(z *S) *S {
  return z
}



分析する。 identity 関数への入力は、zへの参照がないため、そのまま戻り値として受け取られ、zはエスケープされません。xへの参照もエスケープされず main 関数のスコープに含まれるため、x からも逃げられません。

 例3の続きです。

package main
type S struct {}
func main() {
  var x S
  _ = *ref(x)
}
func ref(z S) *S {
  return &z
}



解析する。 zはxのコピーです。ref関数はzへの参照を取るので、zはスタック上にあるわけにもいかず、またref関数の外ではzの見つけ方によって、zはヒープに逃げなければなりません。main関数の中だけ、refの結果が直接捨てられるのですが、Goのコンパイラはまだそこまで賢くないので、この状況を分析することができません。また、xへの参照は取られないので、xのエスケープは発生しません。

そして例4。 構造体メンバに参照を代入する場合はどうでしょうか。

package main
type S struct {
  M *int
}
func main() {
  var i int
  refStruct(i)
}
func refStruct(y int) (z S) {
  z.M = &y
  return z
}



解析する。 refStruct この関数はyへの参照を取るので、yはエスケープされています。

 最後に、例5を見てください。

package main
type S struct {
  M *int
}
func main() {
  var i int
  refStruct(&i)
}
func refStruct(y *int) (z S) {
  z.M = y
  return z
}



解析する。 での main 関数はiへの参照を取り、それを refStruct 関数を呼び出すと、i への参照は main 関数のスコープに含まれるため、i のエスケープは発生しません。前の例と比べると、小さな違いがありますが、結果としてプログラムの効果は異なります。例4では、iは最初に main をスタックフレームに格納し、次に refStruct をスタックフレームに格納し、ヒープにエスケープして、ヒープに1回、合計3回のアロケーションを行いました。この場合、iは一度だけ割り当てられ、その後参照渡しされる。

さて、今回は「Goのエスケープ解析とは一体何なのか」ということについてです。Goのエスケープ解析については、Scripting Houseの過去記事を検索していただくか、引き続き以下の関連記事をご覧ください。