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

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

このコミットは、Goランタイムにおけるdeferステートメントに関連するメモリリークの修正を目的としています。特に、deferされた関数がクロージャやスタックセグメントへのポインタを保持し続けることによって発生するメモリリークに対処しています。

コミット

commit fd23958f49f0967c9a5999ffc2e33740f246a11a
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Mon Jul 1 17:36:08 2013 -0400

    runtime: fix memory leaks due to defers
    fn can clearly hold a closure in memory.
    argp/pc point into stack and so can hold
    in memory a block that was previously
    a large stack serment.
    
    R=golang-dev, dave, rsc
    CC=golang-dev
    https://golang.org/cl/10784043

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

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

元コミット内容

Goランタイムにおいて、deferステートメントが原因で発生するメモリリークを修正します。具体的には、deferされた関数(fn)がクロージャをメモリ上に保持し続ける可能性があり、また、argp(引数ポインタ)やpc(プログラムカウンタ)がスタックを指し示すことで、以前に大きなスタックセグメントであったメモリブロックを保持し続ける可能性がありました。このコミットは、これらのポインタが不要になったメモリを適切に解放できるようにすることで、メモリリークを防ぎます。

変更の背景

Goのdeferステートメントは、関数の実行が終了する直前に指定された関数を呼び出すメカニズムを提供します。これはリソースの解放(ファイルクローズ、ロック解除など)に非常に便利です。しかし、deferされた関数がクロージャである場合、そのクロージャは外部スコープの変数をキャプチャすることがあります。また、deferの実行コンテキスト(引数やプログラムカウンタなど)は、Defer構造体内に保存されます。

このコミット以前のGoランタイムでは、deferが実行され、そのDefer構造体が不要になった際に、構造体内のポインタフィールドが適切にゼロクリアされていませんでした。これにより、ガベージコレクタ(GC)がこれらのポインタを有効な参照と誤認し、実際には不要になったメモリブロック(特に、クロージャが参照していたオブジェクトや、以前の大きなスタックセグメント)を解放できないという問題が発生していました。これはメモリリークとして現れ、特に長時間稼働するアプリケーションや、多数のdeferが使用されるアプリケーションで顕著になる可能性がありました。

この問題は、ガベージコレクタが参照を追跡する際に、古いポインタが残っていると、そのポインタが指すメモリ領域がまだ使用中であると判断してしまう「保守的なGC」の側面と関連しています。GoのGCは進化していますが、このようなケースでは明示的なポインタの無効化が必要でした。

前提知識の解説

Goのdeferステートメント

deferは、Go言語の制御構造の一つで、現在の関数がリターンする直前(パニックが発生した場合も含む)に、指定された関数呼び出しを遅延実行させるものです。複数のdeferがある場合、それらはLIFO(後入れ先出し)の順序で実行されます。

func example() {
    file := openFile("data.txt")
    defer closeFile(file) // 関数終了時にcloseFileが呼ばれる

    // ファイル操作
}

クロージャ (Closures)

クロージャは、関数が定義された環境(レキシカルスコープ)を記憶し、その環境内の変数にアクセスできる関数です。deferされる関数がクロージャである場合、そのクロージャは外部の変数をキャプチャし、その変数が指すメモリを保持し続ける可能性があります。

func createDeferFunc(value int) func() {
    return func() {
        fmt.Println("Deferred value:", value) // valueをキャプチャ
    }
}

func main() {
    i := 10
    defer createDeferFunc(i)() // iの値をキャプチャしたクロージャがdeferされる
    i = 20 // deferされる関数は10を出力する
}

ガベージコレクション (Garbage Collection, GC)

Goランタイムには自動メモリ管理のためのガベージコレクタが組み込まれています。GCは、プログラムが動的に割り当てたメモリのうち、もはや到達不可能(参照されていない)になったメモリ領域を自動的に解放し、再利用可能にします。GCは、プログラムの実行中に定期的に、または特定の条件(メモリ使用量など)に基づいてトリガーされます。GCが正しく機能するためには、有効なポインタと無効なポインタを正確に区別できる必要があります。

Defer構造体 (内部実装)

Goランタイムの内部では、deferステートメントはruntime.Deferのような構造体として表現されます。この構造体は、deferされる関数の情報(関数ポインタ、引数、レジスタの状態など)を保持します。これらの情報は、関数が実際に実行されるまでメモリ上に保持されます。

メモリリーク

メモリリークは、プログラムが動的に割り当てたメモリを使い終わった後も解放せず、そのメモリがガベージコレクタによって回収されない状態を指します。これにより、利用可能なメモリが徐々に減少し、最終的にはプログラムのパフォーマンス低下やクラッシュにつながる可能性があります。

技術的詳細

このコミットの核心は、runtime/panic.c内のfreedefer関数におけるメモリクリアの範囲の変更です。

freedefer関数は、deferされた関数が実行され、そのDefer構造体が不要になった際に呼び出されます。この関数の目的は、Defer構造体に関連するメモリを解放し、特に構造体内に残っている可能性のあるポインタを無効化することです。これにより、ガベージコレクタがこれらのポインタを追跡して、既に不要なメモリを保持し続けることを防ぎます。

変更前は、Defer構造体のargsフィールドが指す領域のみがクリアされていました。しかし、Defer構造体自体には、fn(関数ポインタ)、argp(引数ポインタ)、pc(プログラムカウンタ)といった、メモリ上の他の場所を指す可能性のあるポインタが含まれています。特に、fnがクロージャを指す場合、そのクロージャはヒープ上のオブジェクトをキャプチャしている可能性があります。また、argppcはスタック上の領域を指すことがあり、以前に大きなスタックセグメントが割り当てられていた場合、その領域への参照が残ってしまうと、GCがそのスタックセグメントを解放できない原因となります。

このコミットでは、Defer構造体全体(ただし、specialフラグが立っている場合は除く)を、その先頭から、argsフィールドの終わりまで、つまりDefer構造体自体とそれに続く引数領域全体をゼロクリアするように変更しました。これにより、fn, argp, pcといったポインタフィールドも確実にゼロクリアされ、ガベージコレクタがこれらのポインタを有効な参照として誤認することがなくなります。

新しいテストケースtest/deferfin.goは、この修正が正しく機能することを検証するために追加されました。このテストは、deferされたクロージャがヒープ上のオブジェクト(この場合はnew(int)で作成されたv)をキャプチャし、そのオブジェクトにruntime.SetFinalizerを設定します。finalizerは、オブジェクトがGCによって回収される直前に実行される関数です。テストは、多数のこのようなdeferとオブジェクトを作成し、GCを複数回実行した後、すべてのfinalizerが呼び出されたことを確認します。もしメモリリークがあれば、finalizerが呼び出されず、テストは失敗します。このテストは、32ビット環境では部分的に保守的なGCの挙動により動作しない可能性があるため、amd64アーキテクチャに限定されています。

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

src/pkg/runtime/panic.c

--- a/src/pkg/runtime/panic.c
+++ b/src/pkg/runtime/panic.c
@@ -104,11 +104,15 @@ popdefer(void)
 static void
 freedefer(Defer *d)
 {
+	int32 total;
+
 	if(d->special) {
 		if(d->free)
 			runtime·free(d);
 	} else {
-		runtime·memclr((byte*)d->args, d->siz);
+		// Wipe out any possible pointers in argp/pc/fn/args.
+		total = sizeof(*d) + ROUND(d->siz, sizeof(uintptr)) - sizeof(d->args);
+		runtime·memclr((byte*)d, total);
 	}
 }

test/deferfin.go (新規追加ファイル)

// run

// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Test that defers do not prevent garbage collection.

package main

import (
	"runtime"
	"sync"
	"sync/atomic"
	"time"
)

var sink func()

func main() {
	// Does not work on 32-bits due to partially conservative GC.
	// Try to enable when we have fully precise GC.
	if runtime.GOARCH != "amd64" {
		return
	}
	N := 10
	count := int32(N)
	var wg sync.WaitGroup
	wg.Add(N)
	for i := 0; i < N; i++ {
		go func() {
			defer wg.Done()
			v := new(int)
			f := func() {
				if *v != 0 {
					panic("oops")
				}
			}
			if *v != 0 {
				// let the compiler think f escapes
				sink = f
			}
			runtime.SetFinalizer(v, func(p *int) {
				atomic.AddInt32(&count, -1)
			})
			defer f()
		}()
	}
	wg.Wait()
	for i := 0; i < 3; i++ {
		time.Sleep(10 * time.Millisecond)
		runtime.GC()
	}
	if count != 0 {
		println(count, "out of", N, "finalizer are not called")
		panic("not all finalizers are called")
	}
}

コアとなるコードの解説

src/pkg/runtime/panic.c の変更点

freedefer関数内の変更は以下の通りです。

  • 変更前: runtime·memclr((byte*)d->args, d->siz);
    • これは、Defer構造体内のargsフィールドが指すメモリ領域(deferされた関数の引数が格納されている場所)のみをゼロクリアしていました。d->argsDefer構造体の末尾に続く可変長データの一部を指します。
  • 変更後:
    total = sizeof(*d) + ROUND(d->siz, sizeof(uintptr)) - sizeof(d->args);
    runtime·memclr((byte*)d, total);
    
    • total変数が導入され、クリアすべきメモリの総バイト数を計算しています。
      • sizeof(*d): Defer構造体自体のサイズ。これにはfn, argp, pcなどのポインタフィールドが含まれます。
      • ROUND(d->siz, sizeof(uintptr)): deferされた関数の引数のサイズをuintptrの倍数に切り上げたもの。これはアライメントを考慮したサイズです。
      • - sizeof(d->args): Defer構造体内のargsフィールドは、実際には可変長引数領域の開始点を示すためのプレースホルダであり、そのサイズはDefer構造体自体のサイズ計算には含めるべきではありません。この減算により、Defer構造体の固定部分と、それに続く引数領域全体を正確にカバーするサイズが計算されます。
    • runtime·memclr((byte*)d, total);
      • Defer構造体の先頭((byte*)d)からtotalバイト分をゼロクリアします。これにより、Defer構造体内のすべてのポインタフィールド(fn, argp, pcなど)と、それに続く引数領域が確実にゼロクリアされます。
      • d->specialtrueの場合は、deferが特別な種類(例えば、goキーワードで開始されたゴルーチンに関連するdeferなど)であり、異なるメモリ管理ロジックが適用されるため、このゼロクリアは行われません。

この変更により、Defer構造体が解放される際に、その中に残っていた古いポインタがガベージコレクタによって追跡されることがなくなり、関連するメモリリークが解消されます。

test/deferfin.go の解説

このテストは、deferがガベージコレクションを妨げないことを検証するためのものです。

  1. アーキテクチャチェック: runtime.GOARCH != "amd64"の場合、テストはスキップされます。これは、32ビット環境ではGoのGCが部分的に保守的であり、このテストの意図する挙動(正確なGCによるメモリ解放)を保証できないためです。
  2. N個のゴルーチン生成: N個(ここでは10個)のゴルーチンを起動します。
  3. vfの作成: 各ゴルーチン内で、v := new(int)によってヒープ上に新しい整数ポインタvを作成します。そして、f := func() { ... }というクロージャを定義します。このクロージャfは、外部スコープの変数vをキャプチャしています。
  4. runtime.SetFinalizerの設定: runtime.SetFinalizer(v, func(p *int) { atomic.AddInt32(&count, -1) })を呼び出し、vがGCによって回収される直前にcountをデクリメントするファイナライザを設定します。countは、GCによって回収されたvの数を追跡します。
  5. defer f(): 定義したクロージャfdeferします。これにより、ゴルーチンが終了する際にfが実行されます。
  6. wg.Wait(): すべてのゴルーチンが終了するのを待ちます。
  7. GCの実行: runtime.GC()を複数回(3回)呼び出し、ガベージコレクションを強制的に実行します。time.Sleepは、GCが完了するのに十分な時間を与えます。
  8. 結果の検証: 最終的にcountが0であることを確認します。もしcountが0でなければ、それは一部のvオブジェクトがGCによって回収されず、そのファイナライザが呼び出されなかったことを意味します。これは、deferされたクロージャがvへの参照を保持し続け、メモリリークが発生していることを示します。

このテストは、deferされたクロージャがキャプチャしたオブジェクトが、deferの実行後も不必要にメモリに保持されないことを保証するための重要な検証手段です。

関連リンク

参考にした情報源リンク

  • Goのソースコード(src/pkg/runtime/panic.c
  • Goのソースコード(test/deferfin.go
  • Goのコミット履歴 (GitHub)
  • Go言語のdefer、クロージャ、ガベージコレクションに関する一般的な知識。
  • GoのDefer構造体に関する内部実装の議論(GoのメーリングリストやIssueトラッカーなど、当時の情報源を想定)