Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 19497] ファイルの概要

このコミットは、Goコンパイラのcmd/gcにおけるエスケープ解析のバグを修正するものです。具体的には、関数がパラメータのポインタのポインタ(間接参照)を返す場合に、変数が誤ってスタックに割り当てられ、Go 1.3で導入されたスタックコピー機能によってクラッシュを引き起こす可能性があった問題に対処しています。この修正により、このようなケースで変数が正しくヒープに割り当てられるようになります。

コミット

コミットハッシュ: fe3c913443a713097a9a0a427846d5411c4150b0 作者: Russ Cox rsc@golang.org 日付: Tue Jun 3 11:35:59 2014 -0400 コミットメッセージの要約: cmd/gc: パラメータの間接参照を返す関数のエスケープ解析を修正

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/fe3c913443a713097a9a0a427846d5411c4150b0

元コミット内容

commit fe3c913443a713097a9a0a427846d5411c4150b0
Author: Russ Cox <rsc@golang.org>
Date:   Tue Jun 3 11:35:59 2014 -0400

    cmd/gc: fix escape analysis of func returning indirect of parameter
    
    I introduced this bug when I changed the escape
    analysis to run in phases based on call graph
    dependency order, in order to be more precise about
    inputs escaping back to outputs (functions returning
    their arguments).
    
    Given
    
            func f(z **int) *int { return *z }
    
    we were tagging the function as 'z does not escape
    and is not returned', which is all true, but not
    enough information.
    
    If used as:
    
            var x int
            p := &x
            q := &p
            leak(f(q))
    
    then the compiler might try to keep x, p, and q all
    on the stack, since (according to the recorded
    information) nothing interesting ends up being
    passed to leak.
    
    In fact since f returns *q = p, &x is passed to leak
    and x needs to be heap allocated.
    
    To trigger the bug, you need a chain that the
    compiler wants to keep on the stack (like x, p, q
    above), and you need a function that returns an
    indirect of its argument, and you need to pass the
    head of the chain to that function. This doesn't
    come up very often: this bug has been present since
    June 2012 (between Go 1 and Go 1.1) and we haven't
    seen it until now. It helps that most functions that
    return indirects are getters that are simple enough
    to be inlined, avoiding the bug.
    
    Earlier versions of Go also had the benefit that if
    &x really wasn't used beyond x's lifetime, nothing
    broke if you put &x in a heap-allocated structure
    accidentally. With the new stack copying, though,
    heap-allocated structures containing &x are not
    updated when the stack is copied and x moves,
    leading to crashes in Go 1.3 that were not crashes
    in Go 1.2 or Go 1.1.
    
    The fix is in two parts.
    
    First, in the analysis of a function, recognize when
    a value obtained via indirect of a parameter ends up
    being returned. Mark those parameters as having
    content escape back to the return results (but we
    don't bother to write down which result).
    
    Second, when using the analysis to analyze, say,
    f(q), mark parameters with content escaping as
    having any indirections escape to the heap. (We
    don't bother trying to match the content to the
    return value.)
    
    The fix could be less precise (simpler).
    In the first part we might mark all content-escaping
    parameters as plain escaping, and then the second
    part could be dropped. Or we might assume that when
    calling f(q) all the things pointed at by q escape
    always (for any f and q).
    
    The fix could also be more precise (more complex).
    We might record the specific mapping from parameter
    to result along with the number of indirects from the
    parameter to the thing being returned as the result,
    and then at the call sites we could set up exactly the
    right graph for the called function. That would make
    notleaks(f(q)) be able to keep x on the stack, because
    the reuslt of f(q) isn't passed to anything that leaks it.
    
    The less precise the fix, the more stack allocations
    become heap allocations.
    
    This fix is exactly as precise as it needs to be so that
    none of the current stack allocations in the standard
    library turn into heap allocations.
    
    Fixes #8120.
    
    LGTM=iant
    R=golang-codereviews, iant
    CC=golang-codereviews, khr, r
    https://golang.org/cl/102040046

変更の背景

このコミットは、Goコンパイラのエスケープ解析における長年のバグを修正するものです。このバグは、Go 1とGo 1.1の間の2012年6月に、Russ Cox氏自身がコールグラフの依存関係順にエスケープ解析を段階的に実行するように変更した際に導入されました。この変更は、入力が(関数が引数を返すなどして)出力にエスケープするケースをより正確に扱うことを目的としていました。

問題は、func f(z **int) *int { return *z } のように、パラメータの多重間接参照を返す関数において発生しました。エスケープ解析は、z自体はエスケープせず、返されないと判断していましたが、これは不十分な情報でした。

具体的なバグのトリガーは以下のシナリオです。

var x int
p := &x
q := &p
leak(f(q)) // fは *q を返す。つまり &x が leak に渡される

このコードでは、f(q)*q(つまりp、さらにその指す&x)を返します。しかし、エスケープ解析が不正確だったため、コンパイラはxpqをすべてスタックに保持しようとする可能性がありました。これは、記録された情報によれば、leakに興味深いものが何も渡されていないと判断されたためです。しかし実際には、f*q(つまりp)を返すため、&xleakに渡され、xはヒープに割り当てられる必要がありました。

このバグは、コンパイラがスタックに保持したいチェーン(上記のx, p, qのようなもの)があり、引数の間接参照を返す関数があり、そのチェーンの先頭がその関数に渡される場合に発生します。このようなケースは稀であるため、このバグは2012年6月から存在していたにもかかわらず、Go 1.3がリリースされるまで発見されませんでした。多くの間接参照を返す関数がインライン化されるような単純なゲッターであったことも、バグが顕在化しなかった一因です。

Goの以前のバージョンでは、&xxのライフタイムを超えて使用されなかった場合、誤ってヒープ割り当てされた構造体に&xを配置しても問題は発生しませんでした。しかし、Go 1.3で導入された新しいスタックコピー機能により、スタックがコピーされてxが移動しても、&xを含むヒープ割り当てされた構造体が更新されなくなり、Go 1.2やGo 1.1では発生しなかったクラッシュを引き起こすようになりました。このため、このバグの修正が不可欠となりました。

前提知識の解説

このコミットを理解するためには、Goのエスケープ解析とメモリ管理に関する以下の概念を理解しておく必要があります。

エスケープ解析 (Escape Analysis)

Goのエスケープ解析は、コンパイラが行う最適化の一つで、変数がスタックに割り当てられるべきか、それともヒープに割り当てられるべきかを決定します。

  • スタック (Stack): 関数呼び出しごとに確保されるメモリ領域で、関数のローカル変数や引数が格納されます。スタックに割り当てられた変数は、関数が終了すると自動的に解放されます。高速なアクセスが可能ですが、サイズに制限があり、関数のスコープを超えて生存することはできません。
  • ヒープ (Heap): プログラム全体で共有されるメモリ領域で、動的にメモリを確保・解放します。ヒープに割り当てられた変数は、ガベージコレクションによって管理され、関数のスコープを超えて生存できます。スタックに比べてアクセスは遅く、ガベージコレクションのオーバーヘッドが発生します。

エスケープ解析の目的は、変数の生存期間を分析し、その変数が関数のスコープ外でも参照される可能性がある(「エスケープする」)かどうかを判断することです。

  • エスケープしない場合: 変数はスタックに割り当てられます。
  • エスケープする場合: 変数はヒープに割り当てられます。

変数がエスケープする典型的なケースには以下のようなものがあります。

  1. ポインタが関数から返される場合: 関数内で作成された変数のアドレスが、その関数の戻り値として返される場合。
    func createPointer() *int {
        x := 10 // xはスタックに割り当てられるべきだが、そのアドレスが返されるためヒープにエスケープする
        return &x
    }
    
  2. ポインタがグローバル変数や、関数の引数として渡された構造体のフィールドに格納される場合:
    var globalVar *int
    
    func storePointer(p *int) {
        globalVar = p // pが指す値はグローバルにエスケープする
    }
    
  3. クロージャが外部変数を参照する場合: クロージャが定義されたスコープ外で実行される可能性があるため、参照される外部変数はヒープにエスケープします。

エスケープ解析は、メモリ効率とパフォーマンスを向上させるために非常に重要です。不正確なエスケープ解析は、不要なヒープ割り当て(パフォーマンス低下)や、スタックに割り当てられた変数が関数の終了後に参照され、不正なメモリアクセス(クラッシュ)を引き起こす可能性があります。

Go 1.3のスタックコピー (Stack Copying)

Go 1.3より前のバージョンでは、Goのランタイムは「セグメントスタック」を使用していました。これは、関数呼び出しの深さに応じてスタックを動的に拡張する仕組みです。しかし、セグメントスタックにはいくつかの問題がありました。例えば、スタックの拡張・縮小に伴うオーバーヘッドや、Cgoとの連携の複雑さなどです。

Go 1.3では、これらの問題を解決するために「連続スタック」と「スタックコピー」が導入されました。連続スタックでは、スタックは連続したメモリ領域として確保され、必要に応じてより大きな領域にコピーされます。

このスタックコピーの導入により、エスケープ解析の正確性がより重要になりました。なぜなら、スタックに割り当てられた変数のアドレスがヒープ上の構造体などに格納されている場合、スタックがコピーされて変数のアドレスが変わると、ヒープ上のポインタが古いアドレスを指したままになり、不正なメモリ参照が発生してクラッシュにつながる可能性があるからです。

今回のバグは、まさにこのスタックコピーの導入によって顕在化したものであり、エスケープ解析の不正確さが直接クラッシュを引き起こすようになった典型的な例です。

技術的詳細

このコミットの修正は、エスケープ解析のロジックを改善し、特にパラメータの間接参照が関数の戻り値としてエスケープするケースを正確に検出することに焦点を当てています。修正は大きく2つの部分に分かれます。

1. 関数解析時におけるパラメータ内容のエスケープ検出

関数のエスケープ解析を行う際、パラメータのポインタを介してアクセスされる値(「内容」)が、関数の戻り値として返されるかどうかを認識します。

  • EscContentEscapes フラグの導入: src/cmd/gc/go.h に新しいエスケープフラグ EscContentEscapes が導入されました。これは、パラメータの内容が何らかの戻り値にエスケープすることを示すビットフラグです。
  • escwalk 関数の変更: src/cmd/gc/esc.cescwalk 関数は、エスケープ解析の主要なウォーカーです。この関数内で、パラメータ(src->class == PPARAM)が戻り値(dst->class == PPARAMOUT)に流れる場合、かつその流れが間接参照(level > 0)を伴う場合に、src->escEscContentEscapes フラグが設定されるようになりました。これにより、パラメータ自体ではなく、そのパラメータが指す内容がエスケープするという情報が記録されます。

2. 呼び出しサイトにおける間接参照のエスケープ処理

関数が呼び出される際、その関数のパラメータに EscContentEscapes フラグが設定されている場合、そのパラメータが指す任意の間接参照がヒープにエスケープするとマークされます。

  • EscState 構造体への funcParam ノードの追加: src/cmd/gc/esc.cEscState 構造体に、特殊なノード funcParam が追加されました。このノードは、パラメータの内容がエスケープする際に、そのエスケープ先として使用されます。funcParamescloopdepth が非常に大きな値に設定されており、常にヒープにエスケープするような振る舞いをします。
  • escassignfromtag 関数の変更: escassignfromtag 関数は、エスケープ解析の結果に基づいて割り当てを行う関数です。この関数内で、em & EscContentEscapes が真の場合(つまり、パラメータの内容がエスケープするとマークされている場合)、escassign(e, &e->funcParam, src) が呼び出されます。これにより、src(パラメータ)が指す内容が funcParam にエスケープするとマークされ、結果的にヒープに割り当てられるようになります。
  • escassign 関数の変更: escassign 関数は、エスケープ解析のフローを伝播させる関数です。この関数内で、dst == &e->funcParam の場合に、srcが指すポインタがヒープにエスケープすると判断されるロジックが追加されました。

精度に関する考慮事項

コミットメッセージでは、この修正の精度について議論されています。

  • より低い精度(より単純な修正):

    • 最初の部分で、内容がエスケープするすべてのパラメータを単純なエスケープとしてマークし、2番目の部分を省略する。
    • f(q)を呼び出す際に、qが指すすべてのものが常にエスケープすると仮定する。 これらのアプローチは実装が単純になりますが、より多くのスタック割り当てがヒープ割り当てに変わり、パフォーマンスが低下する可能性があります。
  • より高い精度(より複雑な修正):

    • パラメータから結果への具体的なマッピングと、パラメータから返されるものへの間接参照の数を記録する。
    • 呼び出しサイトで、呼び出された関数に対して正確なグラフを設定する。 これにより、notleaks(f(q))のようなケースでxをスタックに保持できるようになります。しかし、実装は非常に複雑になります。

このコミットで採用された修正は、「現在の標準ライブラリにおけるスタック割り当てがヒープ割り当てに変わらないように、必要なだけの精度」を持つように設計されています。これは、パフォーマンスへの影響を最小限に抑えつつ、バグを効果的に修正するためのバランスの取れたアプローチです。

コアとなるコードの変更箇所

このコミットでは、主にGoコンパイラのバックエンドであるcmd/gc内のエスケープ解析関連のファイルが変更されています。

  1. src/cmd/gc/esc.c:

    • エスケープ解析の主要なロジックが記述されているファイルです。
    • EscState 構造体に funcParam という新しい Node が追加され、パラメータの内容がエスケープする際の特別なターゲットとして機能します。
    • analyze 関数内で funcParam ノードの初期化が行われます。
    • escassignfromtag 関数が変更され、EscContentEscapes フラグが設定されたパラメータの内容を funcParam に割り当てるロジックが追加されました。
    • escwalk 関数が変更され、パラメータの間接参照が戻り値に流れる場合に EscContentEscapes フラグを設定するロジックが追加されました。また、leaks の判定ロジックも更新され、funcParam へのエスケープを考慮するようになりました。
  2. src/cmd/gc/go.h:

    • Goコンパイラで使用される共通の定義や構造体が記述されているヘッダーファイルです。
    • enum に新しいエスケープフラグ EscContentEscapes が追加されました。これは、パラメータの内容が戻り値にエスケープすることを示すビットフラグです。
    • EscBits の値が 4 から 3 に変更され、EscReturnBitsEscBits+1 として定義されました。これにより、エスケープフラグのビット割り当てが調整されています。
  3. test/escape2.go:

    • エスケープ解析のテストケースが記述されているファイルです。
    • issue 8120 というコメントとともに、このバグを再現し、修正が正しく機能することを確認するための新しいテストケースが追加されました。
    • U および V という構造体と、それらを使用する String() メソッドや NewV 関数が定義され、パラメータの間接参照がエスケープするシナリオが検証されています。
    • 既存のテストケースの一部で、期待されるエスケープ解析の結果(エラーメッセージ)が更新されています。例えば、AlsoNoLeak()LeaksABit() のコメントが // ERROR "b does not escape" から // ERROR "leaking param b content to result ~r0" に変更されています。

コアとなるコードの解説

src/cmd/gc/go.h の変更

enum
    EscNone,
    EscReturn,
    EscNever,
    EscBits = 3, // 変更前は 4
    EscMask = (1<<EscBits) - 1,
    EscContentEscapes = 1<<EscBits, // 新規追加
    EscReturnBits = EscBits+1,      // 新規追加
};
  • EscBits4 から 3 に変更されました。これは、エスケープフラグのビットフィールドのサイズを調整するためです。
  • EscContentEscapes が新しく追加されました。これは、パラメータの内容が(間接参照を介して)戻り値にエスケープすることを示すフラグです。
  • EscReturnBits が新しく追加されました。これは、戻り値に関連するエスケープフラグの開始ビット位置を定義します。

src/cmd/gc/esc.c の変更

EscState 構造体への funcParam の追加

struct EscState {
    // ... 既存のフィールド ...
    // If an analyzed function is recorded to return
    // pieces obtained via indirection from a parameter,
    // and later there is a call f(x) to that function,
    // we create a link funcParam <- x to record that fact.
    // The funcParam node is handled specially in escflood.
    Node    funcParam;
    // ... 既存のフィールド ...
};
  • funcParam は、パラメータの内容がエスケープする際に、そのエスケープ先として使用される特別なノードです。これにより、エスケープ解析のフローグラフ内で、パラメータの内容がヒープにエスケープするという情報を表現できます。

analyze 関数での funcParam の初期化

analyze(NodeList *all, int recursive) {
    // ... 既存の初期化 ...
    e->funcParam.op = ONAME;
    e->funcParam.orig = &e->funcParam;
    e->funcParam.class = PAUTO;
    e->funcParam.sym = lookup(".param");
    e->funcParam.escloopdepth = 10000000; // 非常に大きな値で、常にヒープにエスケープするように振る舞う
    // ... 既存の処理 ...
}
  • funcParam ノードが初期化され、その escloopdepth が非常に大きな値に設定されます。これにより、このノードに到達する値は、事実上ヒープにエスケープすると見なされます。

escassignfromtag 関数の変更

escassignfromtag(EscState *e, Strlit *note, NodeList *dsts, Node *src) {
    // ... 既存の処理 ...
    // If content inside parameter (reached via indirection)
    // escapes back to results, mark as such.
    if(em & EscContentEscapes)
        escassign(e, &e->funcParam, src); // ここで funcParam に割り当てられる
    // ... 既存の処理 ...
    for(em >>= EscReturnBits; em && dsts; em >>= 1, dsts=dsts->next) // EscBits から EscReturnBits に変更
    // ... 既存の処理 ...
}
  • em & EscContentEscapes の条件が追加されました。これは、エスケープ解析の結果タグ emEscContentEscapes フラグが含まれている場合(つまり、パラメータの内容がエスケープするとマークされている場合)に真となります。
  • この条件が真の場合、escassign(e, &e->funcParam, src) が呼び出されます。これは、src(パラメータ)が指す内容が funcParam にエスケープするという情報をエスケープ解析のフローグラフに追加します。
  • ループの開始条件が em >>= EscBits から em >>= EscReturnBits に変更されました。これは、エスケープフラグのビット割り当ての変更に対応するためです。

escwalk 関数の変更

escwalk(EscState *e, int level, Node *dst, Node *src) {
    // ... 既存の処理 ...
    // Input parameter flowing to output parameter?
    if(dst->op == ONAME && dst->class == PPARAMOUT && dst->vargen <= 20) {
        if(src->op == ONAME && src->class == PPARAM && src->curfn == dst->curfn && src->esc != EscScope && src->esc != EscHeap) {
            if(level == 0) { // 直接のパラメータ
                // ... 既存の処理 ...
                src->esc |= 1<<((dst->vargen-1) + EscReturnBits); // EscBits から EscReturnBits に変更
                goto recurse;
            } else if(level > 0) { // 間接参照を介したパラメータ
                // ... 警告メッセージ ...
                if((src->esc&EscMask) != EscReturn)
                    src->esc = EscReturn;
                src->esc |= EscContentEscapes; // ここで EscContentEscapes を設定
                goto recurse;
            }
        }
    }

    // ... 既存の処理 ...
    // The second clause is for values pointed at by an object passed to a call
    // that returns something reached via indirect from the object.
    // We don't know which result it is or how many indirects, so we treat it as leaking.
    leaks = level <= 0 && dst->escloopdepth < src->escloopdepth ||
            level < 0 && dst == &e->funcParam && haspointers(src->type); // 新規追加
    // ... 既存の処理 ...
}
  • escwalk は、エスケープ解析のフローをたどる関数です。
  • level > 0 の条件が追加されました。これは、パラメータが間接参照を介して戻り値に流れるケースを検出します。この場合、src->escEscContentEscapes フラグが設定されます。
  • leaks の判定ロジックに新しい条件が追加されました。level < 0 && dst == &e->funcParam && haspointers(src->type) は、funcParam にエスケープするポインタを持つ値がヒープにエスケープすると判断するためのものです。level < 0 は、呼び出しサイトでの解析を示す特別なレベルです。

これらの変更により、Goコンパイラは、パラメータの間接参照が関数の戻り値としてエスケープする複雑なシナリオを正確に識別し、関連する変数を適切にヒープに割り当てることで、Go 1.3のスタックコピー機能との互換性を確保し、クラッシュを防ぐことができます。

関連リンク

参考にした情報源リンク