🦔

Goのスライス操作時の内部挙動をみる

2024/09/05に公開

はじめに

この記事ではスライスの実態である基底配列について、どのタイミングでどのように作成されているか、裏側の処理を具体例とともに追っていきます。

基底配列とは

Goにおける基底配列(underlying array)とは、スライスのデータを実際に格納するメモリ上の配列です。これは固定サイズの配列で、スライスの操作の基礎となる重要な要素です。

スライスは、この基底配列の参照と、その長さや容量の情報を保持しています。私たちが普段スライスを操作する際は、この基底配列が裏側でいい感じに作成され、管理されています。

基底配列はいつ作られるか

スライスの操作において、基底配列が作成されるタイミングは大きく分けて以下の2つです。

  • スライス作成時
  • 基底配列の容量(cap)を超えて要素を追加した時

以降では実際のコードとともに、上記の2つのポイントの確認や、元の基底配列がどうなるかの観察をします。実際にコードを触りたい人はこちらの go playgroundへ (以下のコードは一部抜粋しているため、そのままでは動きません。)

// 初期スライスを作成
// このタイミングで最初の基底配列が作成される
s := make([]int, 0, 4) // len=0, cap=4
s = append(s, 1, 2, 3) // len=3, cap=4

// 最初の基底配列のアドレスを確認
originalPtr := unsafe.Pointer(&s[0])
fmt.Printf("最初の基底配列のアドレス: %p\n", originalPtr) // 0xc000108000

// スライスに新しい要素を追加。ここで最大容量(cap:4)を超える
s = append(s, 4, 5)
fmt.Printf("容量超過後: len=%d, cap=%d\n", len(s), cap(s)) // len=5, cap=8

// 新しい基底配列のアドレスを確認
newPtr := unsafe.Pointer(&s[0])
fmt.Printf("新しい基底配列のアドレス: %p\n", newPtr) // 0xc00010c040

// 最初の基底配列の内容を確認
for i := 0; i < 4; i++ {
	value := *(*int)(unsafe.Pointer(uintptr(originalPtr) + uintptr(i)*unsafe.Sizeof(int(0)))) //unsafe.Pointerの使用について、Goの管理しているメモリを直接触りにいってるので一般的には危険な操作です。今回は教育目的で使用しています。
	fmt.Printf("%d,", value)
}

以降では何が起きているのか、さらに詳細な説明にうつります。

詳細説明

基底配列のアドレス

まず、基底配列のアドレスをみてみると「スライス作成時(0xc000108000)」と「基底配列の容量を超えたとき(0xc00010c040)」のアドレスが異なりますね。したがって、このタイミングで基底配列が作成されているといえそうです。

lenとcapの変化

基底配列のlen,capは、以下のように変化しています。

初期状態:    len=0, cap=4
3要素追加後: len=3, cap=4
容量超過後:  len=5, cap=8

注目すべきは、容量を超えて新たに基底配列が作成された際に容量(cap) が倍になっている点です。Goのスライスは、容量を超えた際に新たに基底配列が作成され、その容量は倍になっていきます。(正確には、一定以上を超えた場合は1.25倍になります。詳しくはこちらの記事をご覧ください。)

全体の流れ

あらためて、今回の流れについて整理してみましょう。

  1. 初期スライス作成
    a. このタイミングで最初の基底配列作成される
  2. スライスに要素を追加
    a. 容量を超えるかどうかの判定が行われる
    b. 超える場合、新たにより大きな基底配列の作成がされる
    c. 最初の基底配列のデータが、新しい基底配列にコピーされる
    d. 新しい基底配列に、新たな要素が追加される
    e. スライスが新たな基底配列を参照するように更新される。この際最初の基底配列への参照がきえる。(正確にはGoが内部で保持するsliceオブジェクトのarrayフィールドの参照先を切り替えてます。詳しくはruntimeパッケージを見ましょう。(ref))

2.eからわかるように、新たな基底配列が作成されたタイミングで最初の基底配列への参照が消えてしまいます。それを回避するために、originalPtrで持っていました。

最初の基底配列の観察

最後にunsafe.Pointerを用いてメモリの値を見たところ 1,2,3,0, と出ています。これにより、最初の基底配列がそのまま残っていることが確認できましたね。ちなみに0は初期化時に自動で埋められた未使用の要素で、直接参照するのはよろしくないです。

このデータは、参照が切れた時点でGoのGCが回収するか、プログラム終了時にOSにメモリを返却する際になくなります。APIのような常に動いてるサーバでは基本的に前者が多いです。いつもありがとうGC

パフォーマンスの影響

今回の検証でわかったように、基底配列は容量を超えた時点で毎回作成され、その再割り当ても行われています。鋭い人はもう察しているかもしれませんが、capを初期化時に適切に設定することでパフォーマンスの向上を見込めますね。

例えば、100,000個のデータを毎回処理することがわかっている場合は、以下のように事前にcapを設定しておくと良いです。

s := make([]int, 0, 100000)

これにより新たな基底配列の作成や再割り当ての操作がなくなり、より効率的に動作します。特に大きな構造体を管理するスライスなどは、再割り当てに時間がかかるため、スライスのcapを意識しておくと良いかもしれません。

まとめ

Goのスライスは、その便利さの裏で複雑なメモリ管理を行っています。スライスにおけるlenとcapの概念を理解し、基底配列の作成や再割り当ての仕組みを把握することで、より効率的なコードを書くことができるはずです。

最後に

記事の中に間違いやさらに深掘りできる場所などありましたら気軽にコメントいただけると嬉しいです。

Discussion