GO言語インタビューの本質~脱出分析はどのように行われるのか?

コンパイル原理では、ポインターのダイナミック レンジを分析する方法はエスケープ分析と呼ばれます。平たく言えば、オブジェクトへのポインターが複数のメソッドまたはスレッドによって参照される場合、そのポインターはエスケープしたと言います。

Go 言語のエスケープ解析は、コンパイラーによる静的コード解析後のメモリ管理の最適化と簡素化であり、変数がヒープに割り当てられるかスタックに割り当てられるかを決定できます。

C/C++ を書いたことのある学生は皆、有名な malloc や new 関数を呼び出すとヒープにメモリを割り当てることができることを知っていますが、このメモリの使用と破壊に対する責任はプログラマにあります。注意しないとメモリ リークが発生します。

Go言語では基本的にメモリリークを心配する必要はありません。新しい関数もありますが、新しい関数を使用して取得されるメモリは必ずしもヒープ上にあるわけではありません。ヒープとスタックの違いはプログラマーにとって「曖昧」ですが、もちろん、これはすべて舞台裏の Go コンパイラーによって行われます。

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

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

Go 言語には、コンパイラーによってヒープ上に変数が直接割り当てられるキーワードや関数はなく、代わりに、コンパイラーはコードを分析して変数を割り当てる場所を決定します。

変数のアドレスを取得すると、ヒープに割り当てられる場合があります。ただし、コンパイラがエスケープ解析を実行した後、関数が戻った後にこの変数が参照されないことが判明した場合、その変数はスタック上に割り当てられます。

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

  1. 関数の外部に参照がない場合は、最初にスタックに配置されます。
  2. 関数の外部に参照がある場合は、それをヒープに配置する必要があります。

C/C++ コードを作成する場合、効率を向上させるために、コンストラクターの実行を回避してポインターを直接返すために、値渡し (値渡し) が参照渡しに「アップグレード」されることがよくあります。

ここには大きな落とし穴が隠されているということを覚えておく必要があります。ローカル変数は関数内で定義され、このローカル変数のアドレス (ポインタ) が返されます。これらのローカル変数はスタック上に割り当てられます (静的メモリ割り当て)。関数が実行されると、変数によって占有されていたメモリは破棄されます。この戻り値に対するアクション (逆参照など) はプログラムの実行を中断します。プログラムを直接クラッシュさせることもできます。たとえば、次のコード:

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

一部の学生は上記の落とし穴に気づいていて、より賢明なアプローチを使用するかもしれません。つまり、関数内で新しい関数を使用して変数 (動的メモリ割り当て) を構築し、この変数のアドレスを返します。変数はヒープ上に作成されるため、関数が終了しても変数は破棄されません。しかし、これで十分でしょうか?new で作成したオブジェクトはいつどこで削除する必要がありますか? 呼び出し元が戻り値を削除するのを忘れたり、戻り値を他の関数に直接渡したりすると、その戻り値は削除できなくなり、メモリ リークが発生します。この落とし穴については、「Effective C++」の第 21 条を読むことができます。これは非常に優れています。

C++ は最も複雑な構文を持つ言語として認識されており、C++ の構文を完全にマスターできる人はいないと言われています。Go 言語ではこれらすべてが大きく異なります。上記の例のような C++ コードを Go に問題なく入力します。

前述した C/C++ で発生する問題は、Go の言語機能として強く推進されています。それはまさに C/C++ のヒ素と Go の蜜です。

C/C++ で動的に割り当てられたメモリでは手動でメモリを解放する必要があるため、プログラムを作成する際は薄氷の上を歩くことになります。これには利点があり、プログラマはメモリを完全に制御できます。しかし、多くの欠点もあります。メモリの解放を忘れることが多く、メモリ リークが発生する可能性があります。したがって、多くの現代言語にはガベージ コレクション メカニズムが追加されています。

Go のガベージ コレクションは、プログラマにとってヒープとスタックを透過的にします。これによりプログラマーの手が真に解放され、ビジネスに集中して「効率的に」コード作成を完了できるようになります。これらの複雑なメモリ管理メカニズムをコンパイラに任せておけば、プログラマは自分の作業を楽しむことができます。

エスケープ分析の「生意気な操作」は、変数を合理的に配置すべき場所に割り当てます。new でメモリを申請したとしても、関数を終了した後でそれが役に立たないと判断したら、スタックに放り込みます。結局のところ、スタック上のメモリ割り当てはヒープよりもはるかに高速です。逆に、一見普通の変数のように見えても、エスケープ解析の結果、関数終了後に別の場所で参照されていることが判明した場合は、ヒープに割り当てます。

変数がヒープ上に割り当てられている場合、スタックのようにヒープを自動的にクリーンアップすることはできません。これにより、Go はガベージ コレクションを頻繁に実行することになり、ガベージ コレクションは比較的大きなシステム オーバーヘッド (CPU 容量の 25% を占める) を占有することになります。

スタックと比較して、ヒープは、予測できないサイズのメモリ割り当てに適しています。しかし、その代償として、割り当ての遅延とメモリの断片化が発生します。スタック メモリの割り当ては非常に高速になります。スタック割り当てメモリでは、割り当てと解放に「PUSH」と「RELEASE」という 2 つの CPU 命令のみが必要ですが、ヒープ割り当てメモリでは、まず適切なサイズのメモリ ブロックを見つけてから、ガベージ コレクションを通じて解放する必要があります。

エスケープ分析を通じて、ヒープに割り当てる必要のない変数をスタックに直接割り当てることができます。ヒープ上の変数が少なくなると、ヒープ メモリを割り当てるコストが削減され、同時に gc への負荷も軽減され、パフォーマンスが向上します。プログラムの実行速度。

拡張 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

-lfoo 関数がインライン展開されるのを防ぐために追加されました。次の出力を取得します。

# command-line-arguments
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

t予想どおり、foo 関数の変数がエスケープされました。私たちを困惑させるのは、なぜ main 関数xもエスケープするのかということです。これは、一部の関数パラメータが fmt.Println(a...interface{}) などのインターフェイス型であるためで、コンパイル中にパラメータの特定の型を判断するのが難しく、エスケープも発生します。

逆アセンブリのソースコード:

go tool compile -S main.go

結果の一部をインターセプトすると、図でマークされた命令は、tメモリがヒープに割り当てられ、エスケープが発生したことを示しています。
ここに画像の説明を挿入します

拡張 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
}

分析: 恒等関数の入力をそのまま戻り値とみなし、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 のコピーです。z への参照は ref 関数で取得されるため、z をスタックに置くことはできません。そうでない場合、z は ref 関数の外部で参照によってどのように見つけることができるので、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
}

分析: i への参照は main 関数で取得され、refStruct 関数に渡されます。i の参照は常に main 関数のスコープ内で使用されるため、i はエスケープされません。前の例と比較すると、小さな違いがありますが、結果として得られるプログラムの効果は異なります: 例 4 では、i は最初にメイン スタック フレームに割り当てられ、次に refStruct スタック フレームに割り当てられ、その後ヒープにエスケープされます。ヒープ上に 1 回割り当てられ、合計 3 回の割り当てになります。この例では、i は 1 回だけ割り当てられ、参照によって渡されます。

例6:

package main

type S struct {
  M *int
}

func main() {
  var x S
  var i int
  ref(&i, &x)
}

func ref(y *int, z *S) {
  z.M = y
}

分析: この例では、i は逃げましたが、前の例 5 の分析によれば、i は逃げません。2 つの例の違いは、例 5 の S が戻り値にあり、入力は出力に「流れる」ことしかできないことです。この例では、S は入力パラメータにあるため、エスケープ解析は失敗し、i が必要です。山に逃げます。

おすすめ

転載: blog.csdn.net/zy_dreamer/article/details/132795412