はじめに
append() は Go 言語で最も頻繁に使用される組み込み関数の一つで、ほとんどの業務ロジックで使用されます。
しかし、パフォーマンスや安定性を重視する場面では、潜在的なボトルネックになることもあります。
本記事では、ソースコードとベンチマーク結果を組み合わせて、slice の拡張(容量増加)の仕組みを体系的に解析し、CPU やメモリに影響する重要なポイントを探ります。そして、最適化のための実践的な方法を紹介します。
append 内部での slice の原理
Go において、slice は配列の軽量ラッパーであり、内部的には以下の構造体で表されます:
type slice struct {
array unsafe.Pointer // 基盤となる配列へのポインタ
len int // 現在の要素数
cap int // 配列の容量
}
append(s, elems...) を実行すると、Go は追加後の長さを確認し、拡張が必要かどうかを判断します:
-
len(s) + len(elems) <= cap(s)の場合、配列に空きがあるため、新しい要素は直接書き込まれます -
len(s) + len(elems) > cap(s)の場合、拡張がトリガーされ、runtimeのgrowsliceが呼び出されます
拡張処理の流れ
拡張が発生すると、growslice は以下のステップを実行します:
- 追加後の総長さ
needed = len + len(elems)を計算 - 古い容量と必要量から新しい容量
newcapを決定 - より大きなメモリ領域を割り当て
-
memmoveで既存データを新しい配列にコピー - slice の内部ポインタ、長さ、容量を更新して新しい slice を返す
このプロセスは開発者からは透過的ですが、性能上の消費ポイントとなります。
拡張は完全なデータコピーと新しいメモリ割り当てを伴うためです。
拡張戦略
Go の拡張戦略は runtime/slice.go 内の nextslicecap() によって決まります(Go 1.21 の場合):
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
return newLen
}
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
newcap += (newcap + 3*threshold) >> 2
if uint(newcap) >= uint(newLen) {
break
}
}
if newcap <= 0 {
return newLen
}
return newcap
}
この拡張ロジックは大きく三段階に分かれます:
-
倍増段階:
newLen <= 2 × oldCapかつoldCap < 256の場合は 2 倍 - 緩やか成長段階:閾値を超えると増加率を徐々に緩やかにし、最終的に約 1.25 倍
-
安全段階:オーバーフローや容量不足の場合は
newLenを直接返す
この設計により、小さな slice は高速に拡張され、大きな slice はメモリ効率を考慮して緩やかに拡張されます。
まとめ
容量不足時の拡張は、データコピーと新メモリ割り当てを伴います。
増加ロジックは nextslicecap により性能とメモリのバランスを制御しています。
- 小さな slice (<256) は 2 倍拡張
- 大きな slice (>=256) は 約 1.25 倍拡張
- 各拡張は必ず
memmoveによるコピーを伴う
このプロセスの理解は、頻繁な追加や大規模データ処理の最適化に不可欠です。
なお、
appendは slice の内部配列割り当てを操作するものであり、他のコレクション構造の拡張戦略とは異なります。
拡張が性能に与える影響
append() のコストを直感的に理解するために、容量未指定と事前指定の 2 つのケースでベンチマークを行いました:
func BenchmarkSliceAppend(b *testing.B) {
for i := 0; i < b.N; i++ {
s := []int{}
for j := 0; j < 10_000_000; j++ {
s = append(s, j)
}
}
}
func BenchmarkSliceAppendWithCap(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]int, 0, 10_000_000)
for j := 0; j < 10_000_000; j++ {
s = append(s, j)
}
}
}
ベンチマーク結果:
BenchmarkSliceAppend-8 19 59692110 ns/op
BenchmarkSliceAppendWithCap-8 121 9187971 ns/op
性能差は 6 倍以上!
容量未指定の場合、追加ごとに拡張が発生し、以下の操作が繰り返されます:
- 新しいメモリ領域を割り当て
-
memmoveで既存データをコピー - 古い配列を GC に回収させる
これらの操作は数千万要素では数百回実行され、ヒープ割り当てと GC の頻度が急増します。事前割り当てを行った場合、GC はほとんど発生せず、メモリ使用量も安定します。
拡張による性能問題の回避方法
容量の事前推定と make の利用
要素数をおおよそ予測できる場合は、slice 作成時に容量を指定 (cap) します:
s := make([]int, 0, estimatedSize)
これにより、数十〜数百回のコピーと GC を回避できます。
sync.Pool による slice の再利用
高並列環境で頻繁に生成される一時 slice は sync.Pool で再利用可能です:
pool := sync.Pool{New: func() any {
return make([]int, 0, 1024)
}}
s := pool.Get().([]int)[:0]
defer pool.Put(s)
これによりヒープ割り当ての頻度と GC 負荷が大幅に軽減されます。
データの分割処理(チャンク化)
データ量が不確定な場合は、チャンク単位で処理すると良いでしょう:
const chunkSize = 10000
for i := 0; i < totalItems; i += chunkSize {
end := i + chunkSize
if end > totalItems {
end = totalItems
}
chunk := make([]int, 0, end-i)
for j := i; j < end; j++ {
chunk = append(chunk, j)
}
processChunk(chunk)
}
ピークメモリを制御でき、瞬間的な大容量割り当てを避けられます。
一時的な大容量 slice の使用に注意
寿命の短い大容量 slice は、ヒープメモリを圧迫し GC を頻繁に誘発します。
var pool = sync.Pool{
New: func() any {
return make([]byte, 0, 1_000_000)
},
}
func handleLargeData() {
buf := pool.Get().([]byte)
defer func() {
buf = buf[:0]
pool.Put(buf)
}()
buf = append(buf, loadData()...)
processLargeBuffer(buf)
}
どうしても大きなバッファが必要な場合は、再利用バッファや mmap などでヒープ外に移す方法も有効です。
有名プロジェクトでの最適化例
| プロジェクト | 最適化方法 | 使用シーン |
|---|---|---|
| Prometheus | 事前割り当てバッファ + オブジェクトプール | 時系列データ集計 |
| Kubernetes |
sync.Pool によるスケジューリングキャッシュ最適化 |
スケジューリングキュー |
| Etcd | バッチ書き込みで拡張回避 | Raft ログ同期 |
共通点は 高頻度データ構造で自動的な拡張を避ける ことです。
Java の ArrayList と比較
| 特性 | Go Slice | Java ArrayList |
|---|---|---|
| デフォルト拡張戦略 | <256 は 2 倍、それ以上は約 1.25 倍 | 1.5 倍 |
| メモリ割り当て方式 |
make で手動指定可能 |
自動割り当て |
| メモリ管理 | GC 管理、値型のコピーは断片化やオーバーヘッドあり | GC 管理、拡張時は参照コピーのみ |
| 拡張コスト | 高い、値型の要素をコピーする必要がある | 比較的低コスト、参照コピーのみ |
Go slice の拡張コストは値型配列で顕著です。Java の ArrayList は参照のみをコピーするため、大きなオブジェクト配列でもパフォーマンスへの影響は軽微です。
まとめ
Go のメモリ管理は優れているとはいえ、大量データ・高性能環境では、slice 容量を意識せずに append() を多用すると Java よりもパフォーマンスコストが高くなる場合があります。
高パフォーマンス Go コードのための推奨実践:
-
容量を事前に予測して
makeで作成:複数回の拡張を回避 -
オブジェクト再利用:
sync.Poolを活用してヒープ割り当てと GC 負荷を軽減 - データを分割処理:一度に大きな slice を作らず、瞬間的なメモリピークを避ける