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

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

このコミットは、GoランタイムのWindows固有のメモリ管理に関するファイルである src/pkg/runtime/mem_windows.c に関連する変更です。このファイルは、GoプログラムがWindows上でメモリを確保、解放、および管理する方法を定義しており、特にWindows APIの VirtualAllocVirtualFree といった関数をGoランタイムがどのように利用しているかを実装しています。

コミット

このコミットは、Windows環境においてメモリのデコミット(MEM_DECOMMIT)が失敗した場合のランタイムの挙動を改善することを目的としています。具体的には、VirtualFree 関数によるデコミットが失敗した際に、即座にパニックを起こすのではなく、より堅牢な方法でメモリを解放しようと試みるロジックが導入されました。これにより、特定のメモリ割り当てパターンで発生していたクラッシュが修正されます。

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

https://github.com/golang/go/commit/30b8af98c0d9ab172842feebf38d1a7ef00a6afa

元コミット内容

commit 30b8af98c0d9ab172842feebf38d1a7ef00a6afa
Author: Russ Cox <rsc@golang.org>
Date:   Tue May 13 01:09:38 2014 -0400

    runtime: handle decommit failure gracefully on Windows
    
    I have no test case for this at tip.
    The original report included a program crashing at revision 88ac7297d2fa.
    I tested this code at that revision and it does fix the crash.
    However, at tip the reported code no longer crashes, presumably
    because some allocation patterns have changed. I believe the
    bug is still present at tip and that this code still fixes it.
    
    Fixes #7143.
    
    LGTM=alex.brainman
    R=golang-codereviews, alex.brainman
    CC=dvyukov, golang-codereviews
    https://golang.org/cl/96300046

変更の背景

この変更の背景には、Windowsのメモリ管理APIである VirtualFree の特定の挙動と、Goランタイムのメモリ割り当て戦略との間のミスマッチがありました。

Windowsの VirtualAlloc 関数は、メモリを予約(reserve)し、その後コミット(commit)することで、実際に物理メモリやページファイルにマッピングされるようにします。VirtualFree は、この予約されたメモリを解放したり、コミットされたメモリをデコミットしたりするために使用されます。

問題は、Goランタイムがメモリを管理する際に、複数の VirtualAlloc 呼び出しによって取得された隣接するメモリ領域を、Goランタイム内部で一つの大きなブロックとして扱う場合がある点にありました。Windowsの VirtualFree は、MEM_DECOMMIT フラグを指定して呼び出された場合、単一の VirtualAlloc 呼び出しによって割り当てられたページ群に対してのみ、その操作が成功するという制約があります。もし VirtualFree が、異なる VirtualAlloc 呼び出しに由来するページを含む範囲に対して MEM_DECOMMIT を試みると、その操作は失敗し、nil を返します。

元のGoランタイムの実装では、runtime·SysUnused 関数(GoランタイムがOSにメモリを返却する際に使用される)内で VirtualFreeMEM_DECOMMIT に失敗した場合、即座に runtime·throw を呼び出してパニックを起こしていました。これは、メモリのデコミットが失敗するという状況が、Goプログラムのクラッシュに直結することを意味していました。

コミットメッセージによると、この問題は特定の古いリビジョン(88ac7297d2fa)でクラッシュとして報告されており、Fixes #7143 と関連付けられています。最新のGoのバージョンでは、メモリ割り当てパターンが変更されたため、同じプログラムがクラッシュしなくなったものの、根本的なバグ(VirtualFree の制約)は依然として存在すると判断され、この修正が導入されました。

この変更の目的は、VirtualFreeMEM_DECOMMIT が失敗した場合でも、Goランタイムがより堅牢にメモリを解放しようと試み、プログラムの安定性を向上させることにあります。

前提知識の解説

1. 仮想メモリと物理メモリ

  • 物理メモリ (Physical Memory): コンピュータに実際に搭載されているRAM(Random Access Memory)のことです。
  • 仮想メモリ (Virtual Memory): オペレーティングシステムが提供する抽象化されたメモリ空間です。各プロセスは、物理メモリのサイズに関わらず、広大な仮想アドレス空間を持っているかのように見えます。OSは、この仮想アドレスを物理アドレスにマッピングし、必要に応じてディスク上のページファイル(スワップファイル)を利用して、物理メモリが不足した場合でもプログラムが動作できるようにします。

2. Windowsのメモリ管理API (VirtualAlloc, VirtualFree)

Windowsでは、プロセスが直接物理メモリを操作することはなく、仮想メモリを介してメモリを管理します。そのための主要なAPIが VirtualAllocVirtualFree です。

  • VirtualAlloc:

    • VirtualAlloc(LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect)
    • 指定されたサイズとタイプで、プロセスの仮想アドレス空間内にメモリ領域を予約(reserve)またはコミット(commit)します。
    • 予約 (Reserve): 仮想アドレス空間内に、将来使用する可能性のある領域を確保します。この時点では物理メモリは割り当てられません。
    • コミット (Commit): 予約された領域の一部または全体に、物理メモリ(またはページファイル上の領域)を割り当て、実際に読み書き可能な状態にします。
    • flAllocationType フラグ:
      • MEM_RESERVE: 仮想アドレス空間を予約します。
      • MEM_COMMIT: 予約された仮想アドレス空間をコミットします。
      • MEM_RESERVE | MEM_COMMIT: 予約とコミットを同時に行います。
  • VirtualFree:

    • VirtualFree(LPVOID lpAddress, SIZE_T dwSize, DWORD dwFreeType)
    • VirtualAlloc で割り当てられたメモリ領域を解放したり、デコミットしたりします。
    • dwFreeType フラグ:
      • MEM_RELEASE: 予約された仮想アドレス空間全体を解放します。この操作は、VirtualAlloc で予約されたアドレスの開始点から、その予約されたサイズ全体に対してのみ実行できます。
      • MEM_DECOMMIT: コミットされたページをデコミットします。これにより、物理メモリとのマッピングが解除され、ページファイルに書き込まれていたデータも破棄されます。予約は維持されます。この操作は、単一の VirtualAlloc 呼び出しによって割り当てられたページ群に対してのみ成功します

3. Goランタイムのメモリ管理

Goランタイムは、OSから直接メモリを要求し、それを独自のヒープとして管理します。これは、C/C++の malloc/free のような標準ライブラリを介さずに、Goのガベージコレクタ(GC)が効率的に動作できるようにするためです。

  • アリーナ (Arena): Goランタイムは、OSから大きなメモリブロック(アリーナ)を VirtualAlloc などで取得します。
  • スパン (Span): アリーナは、さらに小さな「スパン」と呼ばれる単位に分割されます。スパンは、特定のサイズのオブジェクトを格納するために使用されます。
  • デコミット (Decommit): GoのGCは、不要になったメモリをOSに返却する際に、まずそのメモリを「デコミット」します。これにより、物理メモリのフットプリントを減らすことができます。完全にOSに返却する場合は「リリース」します。

このコミットで問題となっているのは、Goランタイムが内部的に管理しているメモリ領域が、実際には複数の VirtualAlloc 呼び出しによって取得された断片的な領域が隣接している場合があるという点です。Goランタイムは、これらの断片をあたかも連続した一つの大きな領域であるかのように扱ってデコミットしようとしますが、VirtualFreeMEM_DECOMMIT はそのように設計されていないため、問題が発生します。

技術的詳細

このコミットの技術的詳細の核心は、Windowsの VirtualFree 関数が MEM_DECOMMIT フラグと共に使用された際の挙動の制約と、Goランタイムがその制約をどのように回避して堅牢性を高めたかという点にあります。

VirtualFreeMEM_DECOMMIT 制約

前述の通り、VirtualFreeMEM_DECOMMIT フラグで呼び出す場合、その操作は単一の VirtualAlloc 呼び出しによって割り当てられたページ群に対してのみ成功します。これは、VirtualAlloc がメモリを予約・コミットする際に、内部的にその領域のメタデータを管理しているためです。VirtualFree は、このメタデータと照合して、指定された範囲が単一の割り当てに属しているかを確認します。

Goランタイムは、メモリの断片化を減らし、効率的なメモリ管理を行うために、OSから取得した複数の小さなメモリブロックを内部的に結合し、あたかも一つの大きな連続した領域であるかのように扱うことがあります。この結合された領域の一部をデコミットしようとすると、その領域が実際には複数の VirtualAlloc 呼び出しに由来する異なるセグメントにまたがっている場合、VirtualFree は失敗します。

従来の挙動と問題点

変更前の runtime·SysUnused 関数では、VirtualFreenil を返した場合(つまりデコミット失敗)、即座に runtime·throw("runtime: failed to decommit pages") を呼び出し、Goプログラムをクラッシュさせていました。これは、メモリ管理の失敗が致命的なエラーとして扱われていたためです。

新しい堅牢なデコミット戦略

このコミットで導入された新しい戦略は、VirtualFree が失敗した場合に、指定されたメモリ領域をより小さなチャンクに分割し、それぞれに対してデコミットを再試行するというものです。これは、失敗した大きなデコミット操作が、実際には複数の成功する小さなデコミット操作の組み合わせで達成できる可能性があるという仮定に基づいています。

具体的には、以下のロジックが導入されました。

  1. 最初に、指定された v から n バイトの領域全体に対して VirtualFree(v, n, MEM_DECOMMIT) を試みます。
  2. もしこれが成功すれば、処理は終了します。
  3. もし失敗した場合、while(n > 0) ループに入ります。このループは、残りのデコミットすべきメモリがなくなるまで続きます。
  4. ループ内で、現在の残りのメモリサイズ nsmall にコピーします。
  5. 内部の while(small >= 4096 && runtime·stdcall(runtime·VirtualFree, 3, v, small, (uintptr)MEM_DECOMMIT) == nil) ループに入ります。
    • このループは、small サイズのデコミットが成功するか、small がページの最小サイズ(4096バイト)を下回るまで続きます。
    • もし small サイズのデコミットが失敗した場合、small を半分に減らし、ページサイズ(4096)の倍数に切り捨てます (small = (small / 2) & ~(4096-1);)。これは、デコミットを試みる領域を徐々に小さくしていくことで、単一の VirtualAlloc 呼び出しに由来する領域を見つけ出すことを目的としています。
  6. 内部ループを抜けた後、もし small が4096バイト未満であれば、これ以上デコミットできる最小単位が見つからなかったことを意味するため、runtime·throw("runtime: failed to decommit pages") を呼び出してパニックを起こします。これは、最終的にデコミットできない領域が存在する場合のフォールバックです。
  7. もし small サイズのデコミットが成功した場合、vsmall バイト分進め、n から small を減算します。これにより、デコミットが成功した部分をスキップし、残りの部分の処理を続行します。

このアプローチは、最悪の場合で O(n log n) の計算量を持つとコメントされています。これは、メモリ領域を半分ずつ分割して再試行するバイナリサーチのような性質を持つためです。しかし、メモリのデコミットは頻繁に行われる操作ではなく、通常は数分単位のスケールで発生するため、この計算量でも十分高速であると判断されています。

この変更により、GoランタイムはWindows上でのメモリデコミット失敗に対してより回復力を持つようになり、特定の条件下でのクラッシュが回避されるようになりました。

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

変更は src/pkg/runtime/mem_windows.c ファイルの runtime·SysUnused 関数内で行われました。

--- a/src/pkg/runtime/mem_windows.c
+++ b/src/pkg/runtime/mem_windows.c
@@ -36,10 +36,30 @@ void
 runtime·SysUnused(void *v, uintptr n)
 {
 	void *r;
+	uintptr small;
 
 	r = runtime·stdcall(runtime·VirtualFree, 3, v, n, (uintptr)MEM_DECOMMIT);
-	if(r == nil)
-		runtime·throw("runtime: failed to decommit pages");
+	if(r != nil)
+		return;
+
+	// Decommit failed. Usual reason is that we've merged memory from two different
+	// VirtualAlloc calls, and Windows will only let each VirtualFree handle pages from
+	// a single VirtualAlloc. It is okay to specify a subset of the pages from a single alloc,
+	// just not pages from multiple allocs. This is a rare case, arising only when we're
+	// trying to give memory back to the operating system, which happens on a time
+	// scale of minutes. It doesn't have to be terribly fast. Instead of extra bookkeeping
+	// on all our VirtualAlloc calls, try freeing successively smaller pieces until
+	// we manage to free something, and then repeat. This ends up being O(n log n)
+	// in the worst case, but that's fast enough.
+	while(n > 0) {
+		small = n;
+		while(small >= 4096 && runtime·stdcall(runtime·VirtualFree, 3, v, small, (uintptr)MEM_DECOMMIT) == nil)
+			small = (small / 2) & ~(4096-1);
+		if(small < 4096)
+			runtime·throw("runtime: failed to decommit pages");
+		v = (byte*)v + small;
+		n -= small;
+	}
 }
 
 void

コアとなるコードの解説

変更された runtime·SysUnused 関数は、GoランタイムがOSに未使用のメモリページを返却する際に呼び出されます。

  1. 初期デコミット試行:

    r = runtime·stdcall(runtime·VirtualFree, 3, v, n, (uintptr)MEM_DECOMMIT);
    if(r != nil)
        return;
    

    まず、指定されたアドレス v から n バイトのメモリ領域全体に対して VirtualFreeMEM_DECOMMIT フラグで呼び出します。runtime·stdcall は、GoランタイムがC言語の標準呼び出し規約でWindows APIを呼び出すためのヘルパー関数です。 もしこの呼び出しが成功し、rnil でなければ、関数は正常に終了します。

  2. デコミット失敗時のフォールバックロジック: 最初の試行が失敗した場合(r == nil)、以下の新しいロジックが実行されます。

    // Decommit failed. Usual reason is that we've merged memory from two different
    // VirtualAlloc calls, and Windows will only let each VirtualFree handle pages from
    // a single VirtualAlloc. ...
    while(n > 0) {
        small = n;
        while(small >= 4096 && runtime·stdcall(runtime·VirtualFree, 3, v, small, (uintptr)MEM_DECOMMIT) == nil)
            small = (small / 2) & ~(4096-1);
        if(small < 4096)
            runtime·throw("runtime: failed to decommit pages");
        v = (byte*)v + small;
        n -= small;
    }
    
    • 外側の while(n > 0) ループ: これは、まだデコミットされていないメモリ領域が残っている限り、処理を続行するためのループです。n はデコミットすべき残りのバイト数です。
    • small = n;: 現在の残りのメモリサイズ nsmall 変数に一時的に保存します。
    • 内側の while ループ:
      while(small >= 4096 && runtime·stdcall(runtime·VirtualFree, 3, v, small, (uintptr)MEM_DECOMMIT) == nil)
          small = (small / 2) & ~(4096-1);
      
      このループは、現在の v から small バイトの領域に対して VirtualFree を試みます。
      • small >= 4096: デコミットする最小単位はページのサイズ(4096バイト)以上である必要があります。
      • runtime·stdcall(...) == nil: VirtualFree の呼び出しが失敗した場合、ループが続行されます。
      • small = (small / 2) & ~(4096-1);: デコミットが失敗した場合、small の値を半分に減らします。& ~(4096-1) は、結果を4096の倍数に切り捨てるためのビット演算です。これにより、デコミットを試みる領域のサイズを徐々に小さくしていき、最終的に単一の VirtualAlloc 呼び出しに由来する、デコミット可能な最小単位を見つけ出そうとします。
    • デコミット不能な場合のパニック:
      if(small < 4096)
          runtime·throw("runtime: failed to decommit pages");
      
      内側のループを抜けた後、もし small が4096バイト未満になっていれば、それは現在の v から始まる領域で、最小単位のページサイズでもデコミットに成功しなかったことを意味します。この場合、これ以上デコミットを試みても無駄であるため、最終的にパニックを発生させます。
    • 成功した領域のスキップ:
      v = (byte*)v + small;
      n -= small;
      
      内側のループで small サイズのデコミットが成功した場合、v ポインタを small バイト分進め、残りのデコミットすべきバイト数 n から small を減算します。これにより、次の外側のループのイテレーションでは、まだデコミットされていない残りの領域から処理が再開されます。

このロジックにより、GoランタイムはWindowsの VirtualFree の制約を考慮し、より堅牢なメモリデコミット処理を実現しています。

関連リンク

参考にした情報源リンク