Works by

Ren's blog

@rennnosuke_rk 技術ブログです

【Golang】Go のSSA表現形式のダンプとその解析

SSA 形式とは

静的単一代入(static simple-assignment)形式と呼ばれ、コンパイラによって生成される中間表現のこと。 SSA は、各変数への代入が一度のみ行われる式によって構成される。

例えば、以下のような式を含むコードを見ると、一行目のコードが不要であることが明らかにわかる。ただし、プログラム上で一行目を判断するためには、 Reaching-Defenitionのような分析手法とそれを実現するアルゴリズムを実行しなければならない。

y := 1
y := 2
x := y

(上記擬似コードでは := で変数の宣言・代入を表現している。重複した宣言は許可されている)

SSA 表現形式 では、変数への代入がただ一度になるようにコードが変換される。
上記のコードは、SSA 形式の表現では以下のように表現される。

y_1 := 1
y_2 := 2
x_1 := y_2

SSA では、重複する変数には各変数を別物として扱うため添字が付与される。
上記の SSA 形式表現では y_1 が参照されないことは明らかであり、プログラム上からもデッドコードとして容易に判断できる。

ちなみに、分岐内で同じ変数への代入が発生しても、SSA 形式では別の変数への代入として表現される。分岐終了後に元の変数への参照が発生した場合は、SSA 表現内では分岐によって参照する変数を切り替える phi 関数(実際には条件によって切り替わるマーカー)を参照する。

x := 1
if x > 2 {
    x := 2
} else {
    x := x + 1
}
y := x * 2
x := y
x_1 := 1
if x_1 > 2 {
    x_2 := 2
} else {
    x_3 := x_1 + 1
}
y_1 := phi(x_2, x_3) * 2
x_4 := y_1

この SSA 形式表現は、上記のデッドコードの発見・削除のようなコンパイラ最適化処理のために利用できる。
Go においても、この中間表現によるコンパイル最適化が行われている。

Go コードから SSA 形式表現を出力する

Go による SSA 形式出力は Go1.7 よりサポートされた。

  • GOSSAFUNC 環境変数を変更して go build
  • go tool compile オプション指定

のどちらかを実行すると、SSA 形式表現を記述したファイルをダンプすることができる。

GOSSAFUNC 環境変数を変更して go build する

GOSSAFUNC 環境変数にターゲットとなる関数を指定して .go ファイルをビルドすると、 .html 形式の SSA 形式表現ファイルが得られる。

$ env GOSSAFUNC=[関数名] go build main.go

この html ファイルには標準の SSA 形式表現のほか、最適化後の SSA 形式表現なども描画されている。

go tool compile オプションを指定する

go tool compile のオプション 'ssa' を指定することでも SSA 形式を出力できる。

$ go tool compile -d 'ssa/build/dump=main' main.go

ssa/help を指定することで、オプションの使用方法を確認できる。

$ go tool compile -d 'ssa/help'
compile: PhaseOptions usage:

    go tool compile -d=ssa/<phase>/<flag>[=<value>|<function_name>]

where:

- <phase> is one of:
    check, all, build, intrinsics, number_lines, early_phielim
    early_copyelim, early_deadcode, short_circuit, decompose_args
    decompose_user, opt, zero_arg_cse, opt_deadcode, generic_cse, phiopt
    nilcheckelim, prove, fuse_plain, decompose_builtin, softfloat, late_opt
    dead_auto_elim, generic_deadcode, check_bce, branchelim, fuse, dse
    writebarrier, insert_resched_checks, lower, lowered_cse
    elim_unread_autos, lowered_deadcode, checkLower, late_phielim
    late_copyelim, tighten, late_deadcode, critical, phi_tighten
    likelyadjust, layout, schedule, late_nilcheck, flagalloc, regalloc
    loop_rotate, stackframe, trim

- <flag> is one of:
    on, off, debug, mem, time, test, stats, dump

- <value> defaults to 1

- <function_name> is required for the "dump" flag, and specifies the
  name of function to dump after <phase>

Phase "all" supports flags "time", "mem", and "dump".
Phase "intrinsics" supports flags "on", "off", and "debug".

If the "dump" flag is specified, the output is written on a file named
<phase>__<function_name>_<seq>.dump; otherwise it is directed to stdout.

Examples:

    -d=ssa/check/on
enables checking after each phase

    -d=ssa/all/time
enables time reporting for all phases

    -d=ssa/prove/debug=2
sets debugging level to 2 in the prove pass

Multiple flags can be passed at once, by separating them with
commas. For example:

    -d=ssa/check/on,ssa/all/time

例えば、 'ssa/build/dump=[関数名]' を指定して、SSA 形式表現のダンプファイル( .dump )を得る。

$ go tool compile -d 'ssa/build/dump=main' main.go
$ ls
main.go              main.o               main_01__build.dump

'ssa/opt/dump=[関数名]' を指定すると、最適化後の SSA 形式表現のダンプファイル() .dump )を得られる。

Go の SSA を解析する

実際に、SSA 形式表現の Go コードをダンプしてその中身を見てみる。

hoge.go

package hoge

func hoge() int {
  x := 1
  return x
}

用意したのは素朴な関数一つを宣言した .go ファイル。
関数 hoge 内では変数 x を宣言、 整数 1 を代入し関数の返戻値としている。
この関数のダンプを取ると以下のようになる。

hoge func() int
  b1:                                   // ブロックラベル
    v1 = InitMem <mem>                  // ヒープメモリ領域の初期化。先頭アドレスがv1に格納される。
    v2 = SP <uintptr>                   // スタックポインタ(スタックの先頭アドレス)
    v3 = SB <uintptr> DEAD              // スタックベース(スタックの底アドレス。デッドコード)
    v4 = LocalAddr <*int> {~r0} v2 v1   // int型のローカルアドレス : v2上にマウントされる。{~r0}は結果を格納する変数
    v5 = Const64 <int> [0] DEAD         // int型定数 (x := 1のうち 宣言 var x; の部分。デッドコード)
    v6 = Const64 <int> [1] (x[int])     // int型定数 (x := 1のうち 代入 x = 1; の部分。)
    v7 = VarDef <mem> {~r0} v1          // 新規変数{~r0}を定義。参照先はv1
    v8 = Store <mem> {int} v4 v6 v7     // v7上のメモリでv6 を v4に格納する。該当するアドレスがv8に格納される。
    Ret v8

上記 SSA の変数のうち、 v3 v5 は使われていないためデッドコード扱いとなっている。
デッドコードを削除し、最適化された SSA 形式表現は以下のようになる。

hoge func() int
  b1:
    v1 = InitMem <mem>
    v2 = SP <uintptr>
    v4 = LocalAddr <*int> {~r0} v2 v1
    v6 = Const64 <int> [2] (x[int])
    v7 = VarDef <mem> {~r0} v1
    v8 = Store <mem> {int} v4 v6 v7
    Ret v9

表現形式こそ異なれど、処理の流れは Go バイナリと同じなのでバイナリ実行時も同様の最適化が行われている。
このように、最適化前後の SSA 形式表現を比較することでどの処理が最適化されているのか把握できる。

試しに、 hoge.go を以下のように改変してみる。

package hoge

func hoge() int {
  x := 1
  x = 2
  return x
}

明らかに、 x := 1 における代入部分が無駄なことがわかる。
SSA に起こすと以下のようになる。

hoge func() int
  b1:
    v1 = InitMem <mem>
    v2 = SP <uintptr>
    v3 = SB <uintptr> DEAD                // デッドコード
    v4 = LocalAddr <*int> {~r0} v2 v1
    v5 = Const64 <int> [0] DEAD           // x = 0 (x := 1 の宣言、デッドコード)
    v6 = Const64 <int> [1] (x[int]) DEAD  // x = 1 (x := 1 の代入、デッドコード)
    v7 = Const64 <int> [2] (x[int])       // x = 2
    v8 = VarDef <mem> {~r0} v1
    v9 = Store <mem> {int} v4 v7 v8
    Ret v9

代入 x = 2 が一つ増えていることがわかる。
また x = 2 によって x = 1 の代入がデッドコードになっている。
これを最適化すると、

hoge func() int
  b1:
    v1 = InitMem <mem>
    v2 = SP <uintptr>
    v4 = LocalAddr <*int> {~r0} v2 v1
    v7 = Const64 <int> [2] (x[int])
    v8 = VarDef <mem> {~r0} v1
    v9 = Store <mem> {int} v4 v7 v8
    Ret v9

のように変数ラベルこそ異なるものの同じ最適化結果となる。

参考文献

【Golang】Go 1.14 の重複するメソッドを持つインターフェース埋め込みの許可

proposal/6977-overlapping-interfaces.md at master · golang/proposal

Go 1.14 より、重複するメソッドを持ったインタフェースの複数埋め込みが可能になった。

インタフェースの埋め込みは、あるインタフェース A を別のインタフェース B 内で宣言することで、A で宣言したメソッドを B でも宣言したことにできる機能。

// on Package io :
// type ReadCloser interface {
//     Reader
//     Closer
// }
import io

type CustomReadCloser interface {
    io.ReadCloser
    isClosed() bool
}

Go1.13 までは、言語仕様としてインタフェースのメソッドには一意性制約があるため、B が A の宣言するメソッドを既に宣言していたり、埋め込んだインタフェース同士が同じメソッドを持つとコンパイルエラーとなっていた。

Go1.14 では「埋め込んだインタフェース同士が同じメソッドを持つ」場合に関してはコンパイルが通る様になる。

例えば、下記のインタフェース埋め込みは Go 1.13 以前は不可能だったが、1.14 より可能になる。

// on Package io :
// type ReadCloser interface {
//     Reader
//     Closer
// }
// type WriteCloser interface {
//     Writer
//     Closer
// }
import io

type ReadWriteCloser interface {
    io.ReadCloser
    io.WriteCloser
}

io.ReadCloserio.WriteCloser は共に Close() メソッドを宣言しているが、Go1.14 ではコンパイルが通る。

ただし、メソッドの名称が同一だがシグネチャが異なる場合、埋め込みは許可されない。

type E1 interface {
    m(x int) bool
}
type E2 interface {
    m(x float32) bool
}
type I  interface {
    E1
    E2
}

議論として、埋め込まれた側のインタフェースが埋め込みインタフェースと同一のメソッドを持つ場合どうするか問題が議論に上っていたが、上記の通りGo の言語仕様のインタフェース一意性の項目

Explicitly declared methods in an interface must remain unique, as before.

と書かれているため、多分許可されないままだと思われる。

参考文献

【Golang】Go ModulesのGo依存パッケージ管理

Go 1.11 から導入された Go Modules

Go 1.11 より、Go 依存パッケージの解決のための Go Modules の概念が導入された。簡単に言うと、Go 公式でサポートされた npm のようなパッケージバージョン管理ツール。

Go プロジェクト配下に見受けられる go.mod go.sum と、それにまつわる依存パッケージの話でちょっと混乱してきたので、過去の経緯から一旦整理してまとめてみました。

基本に立ち返って、パッケージのインストール

基本的な Go パッケージのインストールは、 go get で実施する。

$ go get github.com/hoge/fuga

ローカルに置きたいプロジェクトにせよ依存ライブラリが欲しいにせよ、基本的にはこの方法でパッケージを取得していたし、今もそうする。

従来(Go 1.11 以前)の依存パッケージ管理

Go 1.11 以前では、go get で取得されたパッケージは $GOPATH 配下に置かれていた。コンパイラは、このディレクトリに配置されたライブラリを参照し、パッケージ間の依存関係を解決していた。

しかし、$GOPATH 配下には最新のパッケージしか管理されず、特定のバージョンのパッケージを使用したいときに依存関係を解決できなかった。そこで Go プロジェクト配下に vendor というディレクトリを作り、そこに所望のバージョンの依存パッケージを置くことで解決した。Go コンパイラコンパイル時、 $GOPATH 配下より vendor 配下のパッケージを優先的に見てくれる。この vendor 配下でのパッケージバージョン管理は vendoring と呼ばれていたようだ。

当時、Go 言語自体がこの vendoring を管理する手段を提供していたわけではなかった。そのため、glidedepといったサードパーティツールが使用されていた。

Go Modules (Go 1.11 以降)におけるパッケージ管理

Go 1.11 になり、依存パッケージのバージョン管理の機能 Go Modules を公式が提供することになった。元々はVersioned Go Command (vgo)と呼ばれるバージョン管理 Go のプロトタイプがあり、Go 公式が 1.11 より Go Modules としてこれをサポートしたもの。

既に依存パッケージのバージョン管理手段があったとはいえ、公式が直々に提供しているのであればそちらを使用したい。 go fmt のような Go の思想の前例もあり、新規の Go プロジェクトでは Go Modules による依存パッケージバージョン管理が多く見られる。

Go Modules の概念

Go Modules では、以下の2つのパッケージ管理モードがある。

  • GOPATH モード
  • module aware モード

これらのモードは環境変数 GO111MODULE によって切り替えられる(詳細は後述)。

GOPATH モード

Go1.11 と同様のパッケージ管理を提供するモード。
go get により $GOPATH 配下にパッケージが置かれ、管理およびビルド時の参照はこのディレクトリに対して行われる。管理されるパッケージのバージョンは最新版のみなので、 vendor によるバージョン管理を要する。

module aware モード

Go Modules の概念に対応したモード。
go get により取得したパッケージを $GOPATH/pkg/mod 配下に置く。module aware モード中は、Go プロジェクトをどこに配置していようが $GOPATH/pkg/mod 配下のパッケージを参照する。

ちなみに、Go Modules では「モジュール」と呼ばれる概念を使用している。標準ライブラリ以外のパッケージをモジュールと呼ぶが、一方でバージョンによって同じパッケージも別のモジュールとして扱う。

module aware モードでの依存解決と、モジュールの作り方

module aware モードでは、パッケージの管理先ディレクトリが変わるだけではなく、Go プロジェクト(モジュール)が必要とするパッケージのダウンロードも自動化してくれる。

その前に、Go プロジェクトをモジュールとして初期化する必要がある。Go プロジェクトをモジュール化するには、 go mod init を実行する。

$ go mod init

するとプロジェクトルートに go.mod ファイルが作成される。ファイルにはモジュール名と、使用する Go ランタイムのバージョンが書かれている。

module github.com/rennnosuke/forblog

go 1.13

このプロジェクトに、外部のモジュールを参照するコードを追加し、ビルドしてみよう。

package main

import (
    "fmt"
    "rsc.io/quote"
)

func main() {
    fmt.Println(quote.Hello())
}
$ go build hello.go

すると、なんと勝手に必要となる依存モジュールをダウンロードし始める。

go: finding rsc.io/quote v1.5.2
go: downloading rsc.io/quote v1.5.2
go: extracting rsc.io/quote v1.5.2
go: downloading rsc.io/sampler v1.3.0
go: extracting rsc.io/sampler v1.3.0
go: downloading golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
go: extracting golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
go: finding rsc.io/sampler v1.3.0
go: finding golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c

module aware モードでは、ビルドしたモジュールの依存関係から必要な外部モジュールを自動的に割り出し、インストールしてくれる。この依存モジュールは先程の go.mod に記述される。

module github.com/rennnosuke/forblog

go 1.13

require rsc.io/quote v1.5.2

npm で言うところの package.json に依存パッケージが記述された状態で npm i しているようなもの。 Go Modules が導入される前と比較し、格段に依存パッケージを取得しやすくなった。また Go Modules 内ではバージョン別にパッケージが管理されるため、 vendor も不要になる。

この依存パッケージ解決を繰り返していると、不要なパッケージが溜まってくる。
不要なパッケージを削除するには、 go mod tidy を実行する。

$ go mod tidy

GOPATH モード・module aware モードの切り替え

先述の通り、GOPATH モードと module aware モードを切り替えるには、GO111MODULE 変数の値を変更する。 GO111MODULE の値には次の 3 種類がある。

  • on
  • off
  • auto

on

module aware モードを有効にする。

off

GOPATH モードを有効にする。

auto

バージョンによって動作を変える。
Go1.12 以前の場合、カレントディレクトリが $GOPATH 配下であれば GOPATH モードになり、そうでなければ module aware モードになる。
ただし Go1.13 からは、auto の状態でもカレントディレクトリに go.mod が存在する場合には module aware モードとして振る舞う。

Go モジュールのチェックサム

Go Module 内に生成される go.sum には依存モジュールのチェックサムが記録される。このチェックサムを使用して、依存モジュールの内容に変更があったかどうかを検出できる 。これにより、インストールするパッケージが改ざんされたものかどうかをチェックできる。

初回インストール時のパッケージのチェックサム検証はできないが、Go1.13 よりチェックサム DBからチェックサムを参照することで初回パッケージのチェックサムも検証できるようになった。

module aware モードでの go get

module aware モード上で go build して依存関係解決できるからと言って、 go get の重要性は依然変わらない。例えば Go プロジェクト内で新たに外部パッケージを使用したい場合、npm install [package] のように新しく依存関係に追加+パッケージインストールしたくなる。

module aware モードで go get を実行すると、従来どおりパッケージ(モジュール)はインストールされるが、保存先は module aware モード管理上のディレクトリになる。また go.mod go.sum に新しい依存パッケージとして記録される。

Go 1.14 以降における Go Modules への公式見解

Go 1.14 では、モジュールサポートは実稼働で使用できる状態にあると見なされ、すべてのユーザーは他の依存関係管理システムからモジュールに移行することが推奨されます。

来る Go1.14 では、すべての Go ユーザが module aware モードへ移行することが推奨されている。移行が高コストな Go パッケージなどでない限り、Go Modules への移行がどんどん進んでいくものと思われる。

所感、メモ

  • Go プロジェクトに途中から参画したときに、何も考えずに必要なパッケージがすぐインストールできてビルドできるのは本当にありがたい。

  • module aware モードでのモジュール管理先ディレクトリは任意に変更可能らしい、が変え方がわからない。要追記。

参考文献

モジュール・golang / go Wiki・GitHub

Go 言語の依存モジュール管理ツール Modules の使い方 | MMM ブログ

Go 1.13 に向けて知っておきたい Go Modules とそれを取り巻くエコシステム

go1.11 の modules の使い方について

Go と vendoring

Glide: Vendor Package Management for Golang

GitHub - golang/dep: Go dependency management tool

【Golang】Go 1.13 モジュールミラーリングサーバーとチェックサム DB

Go 1.13 より、Go モジュール関連のサポートが強化された。主な機能は以下の2つ。

依存モジュールのミラーリング

Go 1.13 では、モジュールリポジトリミラーリングサーバーを指定できるようになった。 GOPROXYミラーリングサーバーの URL を指定することで、指定したサーバーにキャッシュが置かれ、go get などを実行した際にミラーリングサーバーのキャッシュをインストールするようになる。

Go 1.13 での GOPROXY のデフォルト値は以下のようになっている。

GOPROXY="https://proxy.golang.org,direct"

proxy.golang.orgGoogle が提供するミラーサーバー。 direct を設定した場合、ミラーサーバーを介さず直接リポジトリからインストールする。

もし、一部のモジュールをキャッシュさせたくない場合には、 GOPRIVATE にモジュールリポジトリ URL を設定する。

$ go env -w GOPROXY=github/hoge/fuga

また、ミラーリングそのものを避けたい場合はミラーリングdirect を指定すれば無効化できる。

$ go env -w GOPROXY=direct

モジュールのチェックサム検査機構

Go 1.11 より、 go.sum ファイルに依存パッケージのチェックサム一覧が書かれるようになった。これにより、 go get などでパッケージをインストールした際、ダウンロードされたパッケージのチェックサムと比較することでパッケージの改ざん検出を行う。

Go プロジェクトを新規に作成したとき、 go.sum にはチェックサムはなく、改ざん検出も行えない。これを防ぐため、Go モジュールのチェックサムを保存する DB にチェックサムを問い合わせる。

チェックサム保存先 DB は GOSUMDB に設定する。デフォルト値は GOPROXY 同様公式が提供する DB サーバー URL になる。

GOSUMDB="sum.golang.org"

ただしチェックサム DB の URL を設定していると、 パッケージインストール時に

これはプライベートなリポジトリに Go モジュールを管理している場合などに発生する。そのようなモジュールを扱う場合、 GOPRIVATE にそのリポジトリを設定するか、 GOSUMDBoff を設定する。

$ go env -w GOSUMDB=off

参考文献

Module Mirror and Checksum Database Launched - The Go Blog

Go モジュールのミラーリング・サービス【正式版】 — プログラミング言語 Go | text.Baldanders.info

【Golang】Go1.13 errors ライブラリによるerror値の取り扱い

error のラッピング

Go 1.13 より、 fmt.Errorf が error インタフェースを実装する値のラッピングをサポートした。この機能により、ある error 値を別の error に内包する事ができる。

func UpdateHoge(hoge: Hoge) error {
    err := app.UpdateHoge(hoge)
    if err != nil {
        return fmt.Errorf("Internal Server Error : %w", err)
    }
}

%wverb を format 内に含めることで、error 文字列内に第 2 引数以降にとる error 文字列を含めつつ、内部に error を保持させることができる。新規に作成した error がラップする error を取り出すには Unwrap() 関数を使用する。この関数は同じく Go 1.13 からサポートされるライブラリ errors 内に含まれる。

func Update(){
    hoge := NewHoge()
    err := UpdateHoge(hoge)
    if err != nil {
        panic(errors.Unwrap(err))
    }
}

この errors.Unwrap(error) 関数は、引数に取る error が Unwrap() を実装していればその関数が返す error 値を、そうでなければ nil を返す。そのため、自前の error ラッピング構造体を定義したい場合、 Unwrap() 関数を定義する。

type AppError struct {
    err  error
    msg  string
    code ErrorCode
}

func (e AppError) Error() string {
    return e.msg
}

func (e AppError) Unwrap() error {
    return e.err
}

fmt.Errorf() のラッピングよって生成される error もこの Unwrap() を定義している様子。

cerr := errors.New("child error")
perr := fmt.Errorf("parent error : %w", cerr)

fmt.Println(perr) // parent error : child error
fmt.Println(errors.Unwrap(perr)) // child error
fmt.Println(errors.Unwrap(cerr)) // <nil>

error の実装する Unwrap() から取得できる error もまた、 Unwrap() を実装しうる。この Unwrap() の連鎖から取得できる一連の error を公式では err's chain と呼んでいる。この chain によって、複数の error 表現をコード内で取り回すことができる。

err's chain 内 error の有無チェック

errors パッケージでは、err's chain のためのの比較用関数も提供する。

errors.Is() は、第 1 引数の err's chain に第 2 引数の error が含まれるかどうかを bool で返す。

cerr := errors.New("child error")
perr := fmt.Errorf("parent error : %w", cerr)

fmt.Println(errors.Is(cerr, cerr)) // true
fmt.Println(errors.Is(cerr, perr)) // false
fmt.Println(errors.Is(perr, cerr)) // true

err's chain に含まれる error の参照

errors.As() 関数は、第 1 引数にとる err's chain のうち、第 2 引数と一致する error 型の値があれば、その値で第 2 引数の参照先を置き換えて true を返す。すなわち、第 2 引数は error ポインタ型となる。第 2 引数に他の型の値、及びポインタが渡されればパニックになり、nil が渡されれはfalse を返す。

cerr := errors.New("child error")
perr := fmt.Errorf("parent error : %w", cerr)

fmt.Println(errors.As(perr, &cerr)) // true
fmt.Println(errors.As(perr, nil)) // false

エラーチェーン中に特定の具象 error が含まれれば特定の処理を実行しつつ具象 error を参照する、といった場合に有用そう。

// id : Int
prod, err := GetProduct(id)
var aerr AppError
if errors.As(err, &aerr) {
    fmt.Errorf("error - %s", aerr)
}

参考文献

errors - The Go Programming Language

【Golang】 関数引数 - 値渡しとポインタ渡しの指針

Golangでの値渡しとポインタ渡しの指針

Golang にはポインタの概念がある。なので、C/C++同様に関数引数型を値型・ポインタ型の2つが指定できる。

func print(s string) {
    fmt.Println(s)
}

func printPtr(s *string) {
    fmt.Println(*s)
}

値型で引数を渡した場合、関数スタックフレーム領域に引数値がコピーされる。一方、ポインタ型の場合は参照値のみがコピーされるのみで、実体となる値はヒープ上に存在する。

なお、ある関数のローカル変数を別の関数にポインタ渡しすると、その値はヒープ領域へエスケープされる。

code
package main

import "fmt"

type Product struct {
    name string
    price string
}

func main() {
    p := Product{}
    printProductPointer(&p)
}

func printProductPointer(p *Product) {
    fmt.Println(*p)
}
build with escape analysis
$ go build -gcflags "-m -l"
# github.com/rennnosuke/forblog/20200216
./func_arg.go:15:26: leaking param content: p
./func_arg.go:16:13: printProductPointer ... argument does not escape
./func_arg.go:16:14: *p escapes to heap

参考: Golang でのエスケープ処理とその解析

値/ポインタ渡しとエスケープ処理の声質を考慮した上で、関数の引数渡しの方針を個人的に決めてみた。なお、以下では基本型の値は特別な事情がない場合は値渡しするものとし、構造体型値をどう渡すかについて考える。

関数引数渡しの個人的方針

値渡しするとき

引数の構造体型が小さい場合

構造体のサイズが小さい場合、値コピーによるコストよりも、関数スタックフレーム領域からヒープ領域への割り当てコストが大きくなる。そのため、コピーコストを恐れて参照渡しするよりも素直にコピーしたほうがパフォーマンスに寄与する場合もある。また、値渡しのコピーによる副作用(ポインタ型プロパティをいじらない場合)回避の恩恵も受けられる。

Map, Slice, Channel などの参照型

型自体が実体となるデータへの参照+メタデータからなる型であり、この型の値そのもののサイズは肥大化しないので、基本値渡しする。

ポインタ渡しするとき

関数内で引数の値を変更する場合

sort 関数のように、仮引数値に対する変更を呼び出し元実引数値に反映したい場合は必然的にポインタ渡しになる。 個人的には副作用がある実装なので、あまり多用はしない。。

引数の構造体型が大きい場合

値渡しのコピーが無視できない場合。構造体のプロパティ値が多いときや、プロパティの型のサイズが大きい場合など。 ただし、ポインタ渡しにしたほうがよい構造体サイズの基準はないので、チーム内で規約として予め決めておくとやりやすいかもしれない。

関数呼び出しの回数が多い

実引数の呼び出し回数が多くなる、あるいはそれが予測できる場合は、コピーが頻発するため引数を参照渡しとしておく。あるいは実装内容によってはそもそも処理をインライン展開したほうが良いかもしれない。


とりあえず、現状は上記の基準で関数引数の型を決めている。 Java の名残で、以前は特別な理由がない限り構造体型はひたすらポインタ型で渡していたが、大きいサイズでなければ値渡しするようになってきた。

参考文献

Pass by pointer vs pass by value in Go

【Golang】エスケープ処理とその解析

Golang の memory allocation

GolangC/C++などと異なり、いい感じに変数のメモリ領域を割り当ててくれる。
例えば、以下のようなコードでも Golang は動作する。

type Hoge struct {}

func NewHoge() *Hoge {
    h := Hoge{}
    return &h
}

NewHoge() 内で生成される構造体値の変数 h の参照がそのまま返戻されている。もし、 hNewHoge 用の関数スタックフレーム内に割り当てられた変数であれば、関数から return したあとに参照するのは不可能になる。が、Golang ではこのような変数の参照を追跡し、自動的にヒープ領域へと割り当てるようコンパイル時に最適化してくれる。そのため上記のコードはコンパイルも通るし、正常に実行される。

この処理はエスケープと呼ばれている。エスケープのおかげで、Golang を書くときに値がどの領域に割り当てられていて、どのように渡してよいのか・いけないのかを気にする必要がほとんど無くなる。こういうところでうっかりミスしがちな自分としては、とてもありがたい。。。

Golangエスケープ処理を追跡する

ところで、コンパイル時のエスケープの動きを見るには、コンパイラ最適化オプションが使える。上記のようなコードが本当にエスケープされているのか、実際に見てみよう。

CompilerOptimizations · golang/go Wiki

Use -gcflags -m to observe the result of escape analysis and inlining decisions for the gc toolchain.

go build -gcflags "-m -l" target.go

-gcflags "-m" でビルド時のエスケープ解析が有効になる。 また -gcflags "-l" でコード上のインライン展開最適化を無効にする。 インライン展開を無効にするのは、エスケープされる変数が最適化されて解析がうまく行かなくなるのを防ぐため。

対象とするコード(memesc.go)
package main

import "fmt"

func main() {
    hoge := NewHoge()
    fmt.Printf("%#v\n", hoge)
}

type Hoge struct {
    id string
}

func NewHoge() *Hoge {
    h := Hoge{id: "000001"}
    return &h
}

分析開始

$ go build -gcflags "-m -l" memesc.go
# command-line-arguments
./memesc.go:15:2: moved to heap: h
./memesc.go:7:12: main ... argument does not escape
./memesc.go:7:13: hoge escapes to heap
1 行目

本来 NewHoge() 関数内ローカル変数であった h が、返戻値となって外部からの参照を持ちうるために、ヒープ領域へと割り当てられている。

2 行目

main 関数内にはエスケープ対象はいないよ、と言われている。

3 行目

NewHoge() から返ってきた hoge はヒープ領域に割り当てられているよ、と言われている。被参照側視点。

バイナリを実行する

実行しても、特に問題はない。

$ ./memesc
&main.Hoge{id:"000001"}

標準出力ベースだけれど、コンパイル時にどのように最適化されて、どの変数がヒープに移動しているのか見ることができた。


Golang を書き始めて最初に「すごい!」と思ったのがこの仕組みだった。 改めて、Golang の言語仕様をシンプルたらしめるとても良い仕組みの一つだと思う。

とはいえ、本当に何も考えずに変数を定義したり渡したりしていいかというとそうではなく、この仕組みが働いてしまうことでパフォーマンスに少なからず影響を与える箇所がある(関数の引数渡しなど)。なので、コードレビューの際などにはパフォーマンスを考慮する観点としてエスケープのことを頭の隅においておくと良いかもしれない。

参考文献