[インデックス 18301] ファイルの概要
このコミットは、Goランタイムにおけるdeferステートメントのメモリ管理メカニズムを大幅に改善するものです。具体的には、これまでの「ゴルーチンごとのスタックベースのdeferチャンク」というアプローチから、「P(プロセッサ)ごとのdeferプール」へと変更し、特定の引数サイズを持つdefer呼び出しのメモリ割り当てを最適化しています。これにより、特に多数のゴルーチンが生成・終了するようなプログラムにおいて、メモリ使用量と実行時間の両面で顕著な改善が見られます。
コミット
commit 1ba04c171a3c3a1ea0e5157e8340b606ec9d8949
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Tue Jan 21 11:20:23 2014 +0400
runtime: per-P defer pool
Instead of a per-goroutine stack of defers for all sizes,
introduce per-P defer pool for argument sizes 8, 24, 40, 56, 72 bytes.
For a program that starts 1e6 goroutines and then joins then:
old: rss=6.6g virtmem=10.2g time=4.85s
new: rss=4.5g virtmem= 8.2g time=3.48s
R=golang-codereviews, rsc
CC=golang-codereviews
https://golang.org/cl/42750044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/1ba04c171a3c3a1ea0e5157e8340b606ec9d8949
元コミット内容
このコミットの元の内容は、Goランタイムにおけるdeferの管理方法を、ゴルーチンごとに割り当てられるチャンク(DeferChunk)から、P(プロセッサ)ごとに管理されるプール(deferpool)に変更することです。これにより、特に小さなサイズのdeferオブジェクトの割り当てと解放のオーバーヘッドを削減し、メモリ効率と実行速度を向上させます。
具体的な変更点は以下の通りです。
deferの割り当て方法の変更: これまでDeferChunkという大きなメモリブロックからdeferオブジェクトを切り出して使用していましたが、これを廃止し、Pごとに用意されたdeferpoolから再利用可能なdeferオブジェクトを取得するように変更します。- プール対象の
deferサイズ: 引数サイズが8, 24, 40, 56, 72バイトのdeferオブジェクトがプール対象となります。これらのサイズは、Goのメモリ割り当てにおけるサイズクラスと一致するように設計されています。 - メモリ使用量と実行時間の改善: 100万個のゴルーチンを起動し、その後結合するプログラムにおいて、RSS(Resident Set Size)が6.6GBから4.5GBへ、仮想メモリが10.2GBから8.2GBへ削減され、実行時間も4.85秒から3.48秒へと短縮されています。
変更の背景
Go言語のdeferステートメントは、関数の終了時に必ず実行される処理を記述するための強力な機能です。しかし、その実装にはメモリ管理上の課題がありました。
従来のGoランタイムでは、deferステートメントが実行されるたびに、そのdeferオブジェクト(実行すべき関数ポインタ、引数、関連情報などを含む構造体)が、現在のゴルーチンに紐付けられたDeferChunkというメモリブロックから割り当てられていました。このDeferChunkは、複数のdeferオブジェクトをまとめて管理するためのもので、メモリの断片化を減らす目的がありました。
しかし、このアプローチにはいくつかの問題がありました。
- メモリの再利用効率の低さ:
DeferChunkはゴルーチンごとに管理されるため、あるゴルーチンが終了しても、そのDeferChunk内のメモリがすぐに他のゴルーチンで再利用されるわけではありませんでした。特に、短命なゴルーチンが大量に生成・終了するようなシナリオでは、deferオブジェクトの割り当てと解放が頻繁に行われ、メモリのオーバーヘッドが大きくなる傾向がありました。 - GCの負担:
deferオブジェクトはヒープに割り当てられるため、ガベージコレクション(GC)の対象となります。大量のdeferオブジェクトが頻繁に生成・破棄されると、GCの負荷が増大し、プログラムの実行性能に影響を与える可能性がありました。 DeferChunkの管理オーバーヘッド:DeferChunk自体の管理(新しいチャンクの割り当て、古いチャンクの解放、チャンク内のオフセット管理など)にも一定のオーバーヘッドがありました。
これらの課題を解決し、deferステートメントのメモリ効率と性能を向上させることが、このコミットの主要な背景となっています。特に、クラウドネイティブなアプリケーションや高並行処理を扱うGoプログラムでは、大量のゴルーチンが生成されることが一般的であり、deferの効率化はランタイム全体の性能に直結する重要な改善点でした。
前提知識の解説
このコミットを理解するためには、以下のGoランタイムの概念とメモリ管理の基礎知識が必要です。
-
Goランタイム (Go Runtime): Goプログラムは、Goランタイムと呼ばれる実行環境上で動作します。Goランタイムは、スケジューラ(ゴルーチンの管理と実行)、ガベージコレクタ(メモリの自動管理)、プリミティブな同期機構、システムコールインターフェースなど、Goプログラムの実行に必要な低レベルな機能を提供します。C言語で記述された部分が多く、Go言語の高性能と並行処理能力の基盤となっています。
-
ゴルーチン (Goroutine): Go言語における並行処理の基本単位です。OSのスレッドよりもはるかに軽量で、数百万個のゴルーチンを同時に実行することも可能です。ゴルーチンはGoランタイムのスケジューラによって管理され、M(Machine/OSスレッド)上でP(Processor/論理プロセッサ)を介して実行されます。
-
deferステートメント:deferキーワードは、そのdeferステートメントを含む関数がリターンする直前に、指定された関数(遅延関数)を実行することを保証します。これは、リソースの解放(ファイルクローズ、ロック解除など)やエラーハンドリングにおいて非常に便利です。deferされた関数はLIFO(後入れ先出し)の順序で実行されます。 -
Goスケジューラ (Go Scheduler): Goランタイムの重要なコンポーネントの一つで、ゴルーチンをOSスレッド(M)にマッピングし、実行を管理します。スケジューラは、G-M-Pモデルを採用しています。
- G (Goroutine): 実行されるゴルーチン。
- M (Machine): OSスレッド。GoランタイムはM上でコードを実行します。
- P (Processor): 論理プロセッサ。Mがゴルーチンを実行するために必要なコンテキスト(ローカルキュー、キャッシュなど)を提供します。各Pはローカルなゴルーチンキューを持ち、MはPからゴルーチンを取得して実行します。Pの数は通常、CPUのコア数に設定されます。
-
メモリ管理とガベージコレクション (GC): Goは自動メモリ管理(ガベージコレクション)を採用しています。プログラマは明示的にメモリを解放する必要がありません。Goランタイムは、不要になったメモリ領域を自動的に識別し、再利用します。メモリはヒープと呼ばれる領域から割り当てられ、GCはヒープ上のオブジェクトを追跡し、到達不能なオブジェクトを回収します。
-
サイズクラス (Size Classes): Goのメモリ割り当て器(
runtime·malloc)は、メモリの割り当て効率を向上させるために「サイズクラス」という概念を使用します。これは、特定のサイズのメモリブロックを事前に定義し、それらのブロックをプールしておくことで、小さなオブジェクトの頻繁な割り当て・解放のオーバーヘッドを削減する手法です。例えば、8バイト、16バイト、32バイトといったように、よく使われるサイズのメモリブロックが用意されており、要求されたサイズに最も近いサイズのブロックが割り当てられます。これにより、メモリの断片化を抑えつつ、高速な割り当てを実現します。
これらの概念を理解することで、このコミットがGoランタイムのどの部分に影響を与え、どのようなメリットをもたらすのかを深く把握することができます。特に、Pがゴルーチンの実行コンテキストを提供する役割を担っていること、そしてメモリ割り当てにおけるサイズクラスの重要性が、この変更の核心をなしています。
技術的詳細
このコミットの技術的な核心は、deferオブジェクトのメモリ割り当て戦略を、ゴルーチンごとのスタックベースのチャンクから、P(プロセッサ)ごとのプールベースのメカニズムへと移行した点にあります。
従来のdefer管理 (DeferChunkベース)
変更前は、各ゴルーチン(G構造体)がDeferChunkという概念を持っていました。
DeferChunkは、複数のDefer構造体を格納できる大きなメモリブロックでした。deferが呼び出されるたびに、newdefer関数は現在のゴルーチンのDeferChunkから必要なサイズのメモリを切り出してDeferオブジェクトを配置していました。DeferChunkが満杯になったり、要求されるdeferのサイズが大きすぎたりした場合は、新しいDeferChunkが割り当てられるか、個別にヒープからメモリが割り当てられていました。popdefer関数は、deferスタックからDeferオブジェクトを論理的に削除し、DeferChunk内のオフセットを調整していました。freedefer関数は、DeferChunk内のメモリをクリアするか、個別に割り当てられたdeferオブジェクトを解放していました。
この方式の課題は、DeferChunkがゴルーチンに紐付いているため、ゴルーチンが終了するまでそのチャンク内のメモリが再利用されにくい点でした。特に、短命なゴルーチンが大量に生成される場合、DeferChunkの割り当てと解放が頻繁に発生し、メモリのオーバーヘッドとGCの負担が増大していました。
新しいdefer管理 (per-P defer poolベース)
このコミットでは、上記の課題を解決するために、以下の変更が導入されました。
-
DeferChunkの廃止:src/pkg/runtime/runtime.hからDeferChunk構造体と、G構造体内のdchunkおよびdchunknextフィールドが削除されました。これにより、ゴルーチンごとのチャンク管理の複雑さがなくなりました。 -
Pごとの
deferpoolの導入:src/pkg/runtime/runtime.hのP構造体にDefer* deferpool[5];というフィールドが追加されました。これは、Pごとに異なるサイズのDeferオブジェクトをプールするための配列です。配列のインデックスは、deferの引数サイズに対応する「deferサイズクラス」を表します。- プールされる
deferの引数サイズは、8, 24, 40, 56, 72バイトに限定されます。これらのサイズは、Goのメモリ割り当て器のサイズクラスと密接に関連しています。
-
newdefer関数の変更:src/pkg/runtime/panic.cのnewdefer関数が大幅に書き換えられました。- まず、引数サイズ
sizに基づいてDEFERCLASS(siz)マクロを使ってdeferサイズクラスを計算します。 - 計算されたサイズクラスが
p->deferpoolの範囲内であれば、現在のPのdeferpoolからDeferオブジェクトを取得しようとします。 - プールに利用可能な
Deferオブジェクトがあれば、それを再利用します。 - プールが空であるか、要求された
deferのサイズがプール対象外(大きすぎる)である場合、runtime·mallocを使ってヒープから新しいメモリを割り当てます。 - これにより、頻繁に利用される小さな
deferオブジェクトは、Pのローカルプールから高速に割り当て・再利用されるようになります。
-
freedefer関数の変更:src/pkg/runtime/panic.cのfreedefer関数も変更されました。deferオブジェクトが「特殊なdefer」(例:cgocallやmain関数で使われるランタイム内部のdefer)でない場合、その引数サイズに基づいてdeferサイズクラスを計算します。- 計算されたサイズクラスが
p->deferpoolの範囲内であれば、そのDeferオブジェクトをPのdeferpoolに戻し、再利用可能にします。 - プール対象外の
deferオブジェクト(ヒープから個別に割り当てられたもの)は、runtime·freeによって解放されます。 - これにより、
deferオブジェクトの解放時にも、メモリの再利用が効率的に行われるようになります。
-
popdefer関数の廃止とインライン化:src/pkg/runtime/panic.cからpopdefer関数が削除されました。popdeferが行っていたg->defer = d->link;というdeferスタックのポインタ更新処理は、runtime·deferreturn,rundefer,runtime·panicなどの呼び出し元で直接インライン化されました。これにより、関数呼び出しのオーバーヘッドが削減されます。
-
runtime·testdefersizesの追加:src/pkg/runtime/panic.cにruntime·testdefersizesという新しい関数が追加されました。- この関数は、
deferの引数サイズが、DEFERCLASSマクロによって計算されるdeferサイズクラスと、runtime·SizeToClassによって計算されるmallocサイズクラスの間で一貫性があることを検証するためのものです。 - 具体的には、同じdeferサイズクラスにマッピングされるすべての引数サイズが、同じmallocサイズクラスにもマッピングされることを保証します。これは、Pの
deferpoolが、Goのメモリ割り当て器のサイズクラスと整合性を持って動作するために非常に重要です。この関数は、ランタイムの初期化時に一度だけ実行され、整合性チェックを行います。
-
clearpoolsの変更:src/pkg/runtime/mgc0.cのclearpools関数が、Pのdeferpoolもクリアするように変更されました。これは、GCサイクル中にプールをクリアすることで、プール内のオブジェクトがGCの対象となることを防ぎ、メモリリークを防ぐためです。
これらの変更により、Goランタイムは、頻繁に生成・破棄される小さなdeferオブジェクトに対して、より効率的なメモリ割り当てと再利用のメカニズムを提供できるようになりました。Pごとのローカルプールは、ロック競合を減らし、キャッシュの局所性を高める効果も期待できます。
コアとなるコードの変更箇所
このコミットにおける主要なコード変更は、以下のファイルに集中しています。
-
src/pkg/runtime/panic.c:DeferChunk関連の定義(DeferChunkSizeなど)とロジックが完全に削除されました。newdefer関数が、Pごとのdeferpoolを利用するように大幅に書き換えられました。popdefer関数が削除され、その機能が呼び出し元にインライン化されました。freedefer関数が、deferオブジェクトをPのdeferpoolに戻すロジックを含むように変更されました。DEFERCLASSとTOTALSIZEマクロが新しく定義されました。runtime·testdefersizesという新しい検証関数が追加されました。
-
src/pkg/runtime/runtime.h:DeferChunk構造体の定義が削除されました。G構造体からdchunkとdchunknextフィールドが削除されました。P構造体にDefer* deferpool[5];という新しいフィールドが追加されました。Defer構造体からfreeフィールドが削除されました。
-
src/pkg/runtime/mgc0.c:clearpools関数が、Pのdeferpoolをクリアするロジックを含むように変更されました。
-
src/pkg/runtime/msize.c:SizeToClass関数がruntime·SizeToClassとしてエクスポートされ、runtime·testdefersizesから呼び出されるようになりました。runtime·testdefersizes()の呼び出しが追加されました。
-
src/pkg/runtime/cgocall.cおよびsrc/pkg/runtime/proc.c:Defer構造体からfreeフィールドが削除されたことに伴い、d.free = false;という行が削除されました。
これらの変更は、deferのメモリ管理の根幹に関わるものであり、Goランタイムの性能特性に大きな影響を与えます。
コアとなるコードの解説
src/pkg/runtime/panic.c
DEFERCLASSとTOTALSIZEマクロ
// defer size class for arg size sz
#define DEFERCLASS(sz) (((sz)+7)>>4)
// total size of memory block for defer with arg size sz
#define TOTALSIZE(sz) (sizeof(Defer) - sizeof(((Defer*)nil)->args) + ROUND(sz, sizeof(uintptr)))
DEFERCLASS(sz):deferの引数サイズszに基づいて、そのdeferが属するサイズクラスを計算します。このマクロは、deferpoolのインデックスを決定するために使用されます。((sz)+7)>>4は、szを16の倍数に切り上げてから16で割る(右シフト4ビット)ことで、特定のサイズ範囲を同じクラスにマッピングする効率的な方法です。例えば、8バイト、24バイト、40バイト、56バイト、72バイトといった引数サイズが、それぞれ異なるクラスにマッピングされるように設計されています。TOTALSIZE(sz):deferオブジェクト全体が占めるメモリブロックの合計サイズを計算します。これにはDefer構造体自体のサイズと、引数szのサイズが含まれます。ROUND(sz, sizeof(uintptr))は、引数サイズをポインタサイズ(通常は4バイトまたは8バイト)の倍数に切り上げることで、アライメントを保証します。
newdefer関数
static Defer*
newdefer(int32 siz)
{
int32 total, sc;
Defer *d;
P *p;
d = nil;
sc = DEFERCLASS(siz);
if(sc < nelem(p->deferpool)) { // nelemは配列の要素数を返すマクロ
p = m->p; // 現在のMが実行しているPを取得
d = p->deferpool[sc]; // PのdeferpoolからDeferオブジェクトを取得
if(d)
p->deferpool[sc] = d->link; // 取得したオブジェクトの次のオブジェクトをプールに設定
}
if(d == nil) { // プールが空か、プール対象外のサイズの場合
total = TOTALSIZE(siz);
d = runtime·malloc(total); // ヒープから新しく割り当て
}
d->siz = siz;
d->special = 0; // 特殊なdeferではない
d->link = g->defer; // 現在のゴルーチンのdeferスタックの先頭にリンク
g->defer = d; // ゴルーチンのdeferスタックの先頭を更新
return d;
}
この関数は、deferオブジェクトを割り当てる中心的なロジックです。
- まず、引数サイズ
sizからdeferサイズクラスscを計算します。 scがPのdeferpoolの有効なインデックスであれば、現在のP(m->p)の対応するプールからDeferオブジェクトを取得しようとします。- プールにオブジェクトがあればそれを再利用し、プールの先頭を更新します。
- プールが空であるか、
sizがプール対象外のサイズである場合(sc >= nelem(p->deferpool))、TOTALSIZE(siz)で計算されたサイズのメモリをruntime·mallocを使ってヒープから新しく割り当てます。 - 割り当てられた
Deferオブジェクトのフィールド(siz,special,link)を設定し、現在のゴルーチンgのdeferスタックの先頭にリンクします。
freedefer関数
static void
freedefer(Defer *d)
{
int32 sc;
P *p;
if(d->special) // 特殊なdeferはプールしない
return;
sc = DEFERCLASS(d->siz);
if(sc < nelem(p->deferpool)) {
p = m->p;
d->link = p->deferpool[sc]; // Deferオブジェクトをプールに戻す
p->deferpool[sc] = d;
// No need to wipe out pointers in argp/pc/fn/args,
// because we empty the pool before GC.
} else
runtime·free(d); // プール対象外のサイズは解放
}
この関数は、deferオブジェクトを解放するロジックです。
specialフィールドがtrueのdefer(ランタイム内部で特殊な目的で使用されるもの)はプールされず、そのままリターンします。- それ以外の
deferについては、その引数サイズからdeferサイズクラスscを計算します。 scがPのdeferpoolの有効なインデックスであれば、現在のPの対応するプールにDeferオブジェクトを戻します。これにより、同じサイズのdeferが将来再利用される可能性が高まります。- プール対象外のサイズ(
sc >= nelem(p->deferpool))のdeferは、runtime·freeによってヒープから完全に解放されます。 - コメントにあるように、プールに戻されたオブジェクトのポインタをクリアする必要がないのは、GCの前にプールがクリアされるためです。
runtime·testdefersizes関数
void
runtime·testdefersizes(void)
{
P *p;
int32 i, siz, defersc, mallocsc;
int32 map[nelem(p->deferpool)]; // deferpoolのサイズに対応するマップ
for(i=0; i<nelem(p->deferpool); i++)
map[i] = -1; // マップを初期化
for(i=0;; i++) { // 全ての可能な引数サイズについてループ
defersc = DEFERCLASS(i); // deferサイズクラスを計算
if(defersc >= nelem(p->deferpool)) // プール対象外のクラスになったら終了
break;
siz = TOTALSIZE(i); // deferオブジェクト全体のサイズを計算
mallocsc = runtime·SizeToClass(siz); // mallocサイズクラスを計算
siz = runtime·class_to_size[mallocsc]; // 実際の割り当てサイズを取得
// runtime·printf("defer class %d: arg size %d, block size %d(%d)\n", defersc, i, siz, mallocsc);
if(map[defersc] < 0) { // 初めてのdeferサイズクラスの場合
map[defersc] = mallocsc; // マッピングを記録
continue;
}
if(map[defersc] != mallocsc) { // 既存のマッピングと異なる場合
runtime·printf("bad defer size class: i=%d siz=%d mallocsc=%d/%d\n",
i, siz, map[defersc], mallocsc);
runtime·throw("bad defer size class"); // エラー
}
}
}
この関数は、deferの引数サイズとメモリ割り当てのサイズクラスの整合性を検証するためのものです。
deferpoolの各サイズクラスについて、対応するmallocサイズクラスが一つだけであることを保証します。- ループで全ての可能な引数サイズ
iを試行し、それぞれのiに対してDEFERCLASS(i)とruntime·SizeToClass(TOTALSIZE(i))を計算します。 - もし同じ
DEFERCLASSにマッピングされる異なるiが、異なるmallocサイズクラスにマッピングされる場合、それは設計上の問題であり、runtime·throwでパニックを引き起こします。 - この検証は、Pの
deferpoolがGoのメモリ割り当て器のサイズクラスと正しく連携するために不可欠です。
src/pkg/runtime/mgc0.c
clearpools関数
static void
clearpools(void)
{
void **pool, **next;
P *p, **pp;
int32 i;
// clear sync.Pool's
for(pool = pools.head; pool != nil; pool = next) {
next = pool[0];
pool[0] = nil; // next
pool[1] = nil; // slice
pool[2] = nil;
pool[3] = nil;
}
pools.head = nil;
// clear defer pools
for(pp=runtime·allp; p=*pp; pp++) { // 全てのPについてループ
for(i=0; i<nelem(p->deferpool); i++)
p->deferpool[i] = nil; // Pのdeferpoolをクリア
}
}
この関数は、ガベージコレクションのサイクル中に呼び出され、各種プールをクリアします。
- 変更前は
sync.Poolのみをクリアしていましたが、このコミットにより、全てのPのdeferpoolもクリアするようになりました。 p->deferpool[i] = nil;とすることで、プール内のDeferオブジェクトへの参照をなくし、それらがGCによって回収されるようにします。これにより、プール内の古いオブジェクトがメモリリークを引き起こすのを防ぎます。
src/pkg/runtime/runtime.h
P構造体への追加
struct P
{
// ... 既存のフィールド ...
MCache* mcache;
Defer* deferpool[5]; // pool of available Defer structs of different sizes (see panic.c)
// ... 既存のフィールド ...
};
P構造体にdeferpoolが追加されたことが、この変更の基盤となります。各Pが自身のローカルなdeferオブジェクトのプールを持つことで、ロックの競合を避け、キャッシュの局所性を高め、メモリ割り当ての効率を向上させます。
関連リンク
- Go言語の
deferステートメントに関する公式ドキュメント: https://go.dev/tour/flowcontrol/12 - Goランタイムスケジューラに関する解説(G-M-Pモデルなど): https://go.dev/doc/articles/go_scheduler.html
- Goのメモリ管理とガベージコレクションに関する情報: https://go.dev/doc/gc-guide
参考にした情報源リンク
- Goのコミット履歴: https://github.com/golang/go/commits/master
- Gerrit Code Review (Goプロジェクトのコードレビューシステム): https://go-review.googlesource.com/
- このコミットのGerrit Code Reviewページ: https://golang.org/cl/42750044
- Goのソースコード(特に
src/runtimeディレクトリ) - Goのメモリ割り当てに関する一般的な情報源(例: Goのブログ記事、技術解説記事など)
- "Go's work-stealing scheduler" by Daniel Morsing: https://morsing.dk/go-scheduler/
- "The Go scheduler" by Kavya Joshi: https://go.dev/blog/go15gc (Go 1.5 GCに関する記事だが、Goのメモリ管理の背景を理解するのに役立つ)
- "Go Memory Management" by Ardan Labs: https://www.ardanlabs.com/blog/2018/12/go-memory-management.html
これらの情報源は、Goランタイムの内部動作、特にスケジューラ、メモリ管理、そしてdeferステートメントの実装に関する深い理解を得るために参照されました。