ポインタが指し示す意味を考える
以下の記事がちょっと面白かったのでこの記事でも試してみる。
- Go: Should I Use a Pointer instead of a Copy of my Struct? | by Vincent Blanchon | A Journey With Go | Medium
- Goにおけるポインタの使いどころ
なお,大元の Should I Use a Pointer instead of a Copy of my Struct?
が書かれたのは2019年5月で,おそらく Go のバージョンも 1.12 あたりだと思うので,その辺を考慮して読むといいだろう。
ちなみに A Journey With Go
は Go の内部動作について割と詳しく解説されていてオススメの読み物である。
ヒープのコスト
そして,この型のインスタンスを生成する(実質的な)構築子を2つ用意する。
2つの関数はいずれもリテラル表現で指定された内容のインスタンスを返すが, byCopy()
関数は値を byPointer()
関数はポインタを返すという違いがある。
また byCopy()
関数ではインスタンスをスタック上に置くが byPointer()
関数ではインスタンスをヒープ上に生成する1。
これらの関数の呼び出しコストを計測するベンチマーク・テストは以下の通り。
func BenchmarkMemoryStack(b *testing.B) {
var s S
b.ResetTimer()
for i := 0; i < b.N; i++ {
s = byCopy()
}
b.StopTimer()
_ = fmt.Sprintf("%v", s.a)
}
func BenchmarkMemoryHeap(b *testing.B) {
var s *S
b.ResetTimer()
for i := 0; i < b.N; i++ {
s = byPointer()
}
b.StopTimer()
_ = fmt.Sprintf("%v", s.a)
}
元記事のコードでは GC (Garbage Collection) の挙動を検証するために色々と仕込んでいるが,今回はコストだけを測ればいいので単純な構成にしてある。
結果はこんな感じ。
$ go test ./... -bench Memory -benchmem
goos: linux
goarch: amd64
pkg: pointer
BenchmarkMemoryStack-4 132169167 9.04 ns/op 0 B/op 0 allocs/op
BenchmarkMemoryHeap-4 15257716 71.6 ns/op 96 B/op 1 allocs/op
PASS
ok pointer 3.233s
まぁ,元記事とだいたい同じ結果かな。 見にくいので表にまとめておこう。
関数名 | 実行時間 (ナノ秒) |
Alloc サイズ |
Alloc 回数 |
---|---|---|---|
BenchmarkMemoryStack |
9.0 | 0 | 0 |
BenchmarkMemoryHeap |
71.6 | 96 | 1 |
言うまでもないが s = byCopy()
は代入文で Go では代入時に必ずコピーが発生する。
ただし s = byCopy()
がインスタンス自体のコピーなのに対し s = byPointer()
ではポインタ値のみコピーされる。
つまり上の結果はヒープ領域の割当と解放にかかる(GC を含む)時間コスト(の平均)がインスタンスのコピーよりもかなり大きいことを示している。 それでも(GC のオーバーヘッドを含めても)平均で100ナノ秒未満で済んでいるなら十分に優秀だと思うけどね。
元記事でも解説されているが Go の GC は独立の goroutine で駆動するため,アーキテクチャ2 や使用するコア数の影響を大きく受ける。 GC を含めてシビアな評価が必要なのであれば,その辺の環境を含めて考えるべきだろう。
コピーのコスト(2020-12-27 訂正)
元記事には続きがある。
さきほどの構造体 S
に対し
というメソッドを用意してベンチマークテストを以下のように書き直す3。
Go の関数引数は値渡し(call by value)なので引数として渡す時点でコピーが発生するが s.heap(s1)
はポインタ値がコピーされるだけなので,単純に考えれば s.stack(s1)
のほうがコストが大きいように思える。
実際にこれを実行すると
$ go test ./... -bench Memory -benchmem
goos: linux
goarch: amd64
pkg: pointer
BenchmarkMemoryStack-4 174 6794688 ns/op 0 B/op 0 allocs/op
BenchmarkMemoryHeap-4 514 2263913 ns/op 0 B/op 0 allocs/op
PASS
ok pointer 3.285s
てな感じになる。
んー。 元記事とは少し違うが,3倍程度の差があるかな。 これも表にまとめておこう。
関数名 | 実行時間 (μ秒) |
Alloc サイズ |
Alloc 回数 |
---|---|---|---|
BenchmarkMemoryStack |
6.8 | 0 | 0 |
BenchmarkMemoryHeap |
2.3 | 0 | 0 |
なお //go:noinline
ディレクティブがないと最適化されてしまいほとんど差がなくなるようだ。
Interface のコスト
ではここで元記事にはなかったテストを考えてみよう。
構造体 S
に以下のメソッドを追加し
func (s S) ValueA() int64 { return s.a }
このメソッドを有効にする interface 型
type IS interface {
ValueA() int64
}
と,この型を返す構築子
func byInterface() IS {
return S{
a: 1, b: 1, c: 1,
e: "foo", f: "foo",
g: 1.0, h: 1.0, i: 1.0,
}
}
を定義する。 この構築子を使ったベンチマークテストも書いておこう。
func BenchmarkMemoryBox(b *testing.B) {
var s IS
b.ResetTimer()
for i := 0; i < b.N; i++ {
s = byInterface()
}
b.StopTimer()
_ = fmt.Sprintf("%v", s.ValueA())
}
これを最初のベンチマークテストと比較してみる。 結果はこんな感じ。
$ go test ./... -bench Memory -benchmem
goos: linux
goarch: amd64
pkg: pointer
BenchmarkMemoryStack-4 132085750 9.08 ns/op 0 B/op 0 allocs/op
BenchmarkMemoryHeap-4 15357787 70.0 ns/op 96 B/op 1 allocs/op
BenchmarkMemoryBox-4 14711439 76.0 ns/op 96 B/op 1 allocs/op
PASS
ok pointer 4.392s
表にまとめておこう。
関数名 | 実行時間 (ナノ秒) |
Alloc サイズ |
Alloc 回数 |
---|---|---|---|
BenchmarkMemoryStack |
9.1 | 0 | 0 |
BenchmarkMemoryHeap |
70.0 | 96 | 1 |
BenchmarkMemoryBox |
76.0 | 96 | 1 |
時間コストについて byCopy()
関数と byPointer()
関数を足したよりちょっと小さい,って感じだろうか。
Interface 型の機能とはボックス化(boxing)である。
ボックス化されたインスタンスは必ずヒープ領域に置かれる。
その意味で byPointer()
関数と byInterface()
関数がメモリ管理で似たような挙動になるのは納得できるのではないだろうか。
ヒープを恐れるな
ヒープメモリ操作が高コストなのは汎用 OS 下で動くアプリケーションであれば自明であり,そこに GC のオーバーヘッドが加わるのだから,そりゃあもう「あたり前田のクラッカー」という奴である。
私のようなロートル世代ではヒープ管理は(可能であれば)忌避したい代物だった。 上述したように操作自体が高コストなのに加えて割当と解放を漏れなく矛盾なく記述しきらなければならないのだから面倒くさいことこの上ない。
Go では goroutine や interface 型を使った抽象化と引き換えに並列処理やヒープ管理などの面倒くさい部分をランタイム・モジュールに丸投げする。 しかもその「面倒くさい部分」を細かく制御できず,これが Go プログラミングにおける重要なトレードオフとなっているのである。
もしヒープ管理をテッペンから見下ろして完全掌握したいと考えるのなら GC は邪魔なだけだし,そもそも Go で書くインセンティブがない。 それこそ近ごろ流行りの Rust とかで書くべきだろう。
今回の記事のような話を知識として知っておくのはいいことだと思うが,設計上の重要なポイントではない(むしろチューニングの話だ)。 ポインタを「概念」で捉えることができれば「ポインタが指し示す意味」について深く考察できるようになる。 それこそが本来の「プログラム設計」というやつである。
ブックマーク
参考図書
- プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)
- Alan A.A. Donovan (著), Brian W. Kernighan (著), 柴田 芳樹 (翻訳)
- 丸善出版 2016-06-20
- 単行本(ソフトカバー)
- 4621300253 (ASIN), 9784621300251 (EAN), 4621300253 (ISBN), 9784621300251 (ISBN)
- 評価
著者のひとりは(あの「バイブル」とも呼ばれる)通称 “K&R” の K のほうである。この本は Go 言語の教科書と言ってもいいだろう。
- Go言語による並行処理
- Katherine Cox-Buday (著), 山口 能迪 (翻訳)
- オライリージャパン 2018-10-26
- 単行本(ソフトカバー)
- 4873118468 (ASIN), 9784873118468 (EAN), 4873118468 (ISBN)
- 評価
- SAVED. / Be mine!
- 坂本 真綾 (メインアーティスト)
- FlyingDog 2014-02-05 (Release 2014-02-05)
- MP3 ダウンロード
- B00HY73M16 (ASIN)
- 評価
「世界征服〜謀略のズヴィズダー〜」OP曲。万能感溢れるノリのいい曲である(笑)
-
よく勘違いされるが(というか私も最初の頃は勘違いしていたが)リテラル表現で
&S{ ... }
と記述する場合は,どっかに固定のインスタンスがあって,その固定インスタンスへのポインタを示しているのではなく,暗黙的にヒープ上にインスタンスを生成してリテラルの内容で初期化している。つまり&S{}
はnew(S)
と等価である。むしろリテラルで初期値を指定できる分だけnew()
関数より簡潔で優れている。詳しくは『プログラミング言語Go』の4.4.1章を参照のこと。これを知ってから組み込みのnew()
関数はほとんど使わなくなった(笑) ↩︎ -
最近の goroutine はプリエンプティブ・マルチタスクが可能になったが,アーキテクチャによっては対応していない場合がある。 ↩︎
-
元記事では
//go:noinline
ディレクティブがなかったが,これがないと最適化されしまうため,コードを変更している。 ↩︎