[インデックス 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
ステートメントの実装に関する深い理解を得るために参照されました。