Closed10

Go言語100Tipsを読んで

ぱんだぱんだ

第二章 コードとプロジェクト構成

  • やたらとinterfaceを作るとinterface汚染を引き起こす。
  • それは、JavaやC#などでよくみられる手法でありGoらしくない。
  • interfaceは発見されるべき
  • interfaceを返すのではなく、具体的な構造体を返すようにしたほうがよい
  • 呼び出し元がinterfaceとして受け付けるのは良い。
  • 型埋め込み(embedded)には気をつける。不用意に公開したくないフィールドを見えるようにしてしまうかもしれない。
  • Go言語には関数の引数にデフォルト引数を設定できないが、もしそのような場合には関数オプションパターンが使える。もしくはビルダーパターン
  • ビルダーパターンはオプションの設定が不要な場合もビルドした空の構造体を渡す必要があったり、メソッドチェーンを実現するためにはerrorを返せないなどのデメリットがあるので、Goでは関数オプションパターンが採用されることが多い。
  • utilのような意味のないパッケージ名を使わない。
ぱんだぱんだ

第三章 データ型

  • Goの整数型はオーバーフローをコンパイルエラーで検知したり、実行時エラーとなったりしない。
  • ので、math.MaxInt16などを使用し、オーバーフローを検知するような実装が必要。
  • float32float64といった小数において、最小値math.SmallestNonzeroFloat64と最大値math.MaxFloat64の間には無限の実数値が存在する。有限で無限を表現しようとすることになるので小数型は近似値になることを覚えておく必要がある。
  • スライスは基底配列のポインタ容量長さを保持した構造体。
  • 多くの場合、スライスはmake()で初期化されるべき。
s := make([]int, 3, 6) // 長さ3、容量6
  • この場合、[0 0 0]と長さ3でゼロ値で埋められている。容量は6あるのでappendで後ろに要素追加してもまだ、アロケーションは発生しない。ただ、ゼロ値で埋まってるので初期化するなら以下のように書く。
s := make([]int, 3) 
for i := 0; i < 3; i++ {
  s[i] = val
}

or

s := make([]int, 0, 3) 
for i := 0; i < 3; i++ {
  s = append(s, val)
}
  • 容量がいっぱいのスライスに要素を追加した場合、規定配列の容量を2倍にしてコピーを作ります。以前の基底配列はGCの対象になればメモリは解放されます。
  • もとのスライスからスライス化で部分スライスを作成した場合、基底配列は同じものを指す。ただし、部分スライスの方に要素を追加したことでアロケーションが起こる場合、基底配列はコピーされ別のものを指すことになることもある。
package main

import "fmt"

func main() {
	s1 := make([]int, 3, 6)
	s2 := s1[1:3]

	// s1とs2は同じ基底配列を指す

	s2[1] = 1

	fmt.Println(s1, s2) // [0 0 1] [0 1]

	// s2にappendすると基底配列は更新されているがs2の長さが3のままなのでs1からは見えない

	s2 = append(s2, 2)

	fmt.Println(s1, s2) // [0 0 1] [0 1 2]

	s2 = append(s2, 3, 4, 5)

	fmt.Println(s1, s2)

	// s1: len 3 cap 6
	// s2: len 6 cap 10
        // このときs1とs2の基底配列は違うものを指す
	fmt.Printf("s1: len %d cap %d\n", len(s1), cap(s1))
	fmt.Printf("s2: len %d cap %d\n", len(s2), cap(s2))
}

  • Goではnilスライスと空スライスがあります。nilスライスは空スライスでもある。
  • 他の言語では防御的に空スライスを返すことが多いが、Goの場合それにさほど意味はなくnilスライスを返すべき。
  • Goではnilスライスと空スライスを明確に区別すべきでない。使用する側はnilの検査をするのではなく長さを検査すると確実。
  • スライスのappendは予期せぬ元の配列の要素を変更させてしまうことがある。もしそのような場合、完全スライス式を使用することができる。
	s1 := []int{1, 2, 3} // len 3 cap 3 [1 2 3]
	s2 := s1[1:2]        // len 1 cap 2 [2]
	s3 := append(s2, 10) // len 2 cap 2 [2 10]

	// [1 2 10] [2] [2 10]
	// 直接s2をいじっていないのにs1の要素が。変わってしまった。
	fmt.Println(s1, s2, s3)
	s := []int{1, 2, 3}

	f := func(s []int) {
		_ = append(s, 10)
	}

        // f(s[:2]) これだとsの要素が[1 2 10]と変更されてしまう
	f(s[:2:2]) // これは完全スライス式で回避できる

        // [1 2 3]
	fmt.Println(s)
  • ちなみに、完全スライス式は[x:y:z]でzでcapを指定する。上記の例で言うと、capを2で指定している。
  • 大きなスライスをコピーしてい処理するとき、簡易スライス式を使うとcapの大きさは引き継ぐのでメモリリークになる。これを完全スライス式で回避することも可能だがGCが適切にメモリを解放してくれるかが微妙なところなのでこの場合はcopy関数を使用してcopyするのが適切。
  • また、スライスの要素がポインタ、またはポインタフィールドを持つ構造体の場合、GCの対象にならない。そのような場合はcopy関数でコピーするか、不要な要素をnilで上書きするなどの対応が必要。
  • mapもスライス同様、最初にサイズがわかっていればサイズを指定して初期化したほうがいい。
  • ただし、map内部のバケットと呼ばれるテーブルのサイズを縮小することはできないのでもし、どうしてもmapの縮小がしたい場合は定期的にmapをコピーして作り直したり値の型をポインタ型にして容量を少なくしたり工夫が必要。
ぱんだぱんだ

第四章 制御構造

  • rangeループは値をコピーして、使う。
  • そのため、rangeループで構造体のフィールドを更新しても反映されない。その場合はスライスにインデックスを指定してオリジナルのスライスの値を更新するようにする必要がある。
  • 以下の例はf4はrangeを使用していてスライス自体もコピーされているのでループ内でappendしていても影響でない。f5だと普通のループなのでスライスの長さがループのたびに増えるので無限ループになる。
func f4() {
	s := []int{1, 2, 3}

	for i, num := range s {
		s = append(s, num)
		fmt.Println(i)
	}
}

func f5() {
	s := []int{1, 2, 3}

	for i := 0; i < len(s); i++ {
		fmt.Println(i)
		if len(s) >= 100 {
			break
		}
		s = append(s, i)
	}
}
ぱんだぱんだ

第五章 文字列

  • 文字列を以下のようにrangeループさせるとマルチバイト文字がちゃんと表示されない。
func main() {
	str := "hêllo"
	for i := range str {
		fmt.Printf("position %d: %c\n", i, str[i])
	}
}
position 0: h
position 1: Ã
position 3: l
position 4: l
position 5: o
  • これをちゃんと表示させるにはrangeループで値変数を使うもしくは文字列をruneスライスにキャストしてループさせる必要がある。
  • ただし、文字列をruneスライスにキャストするにはO(n)の計算量がかかるので実際は前者の方法を使用すべき。
  • しかし、i番目のruneにアクセスしたいときは、runeスライスにキャストする必要がある。
  • stringsパッケージのTrimRightTrimSuffixには以下のような違いがある。
	str2 := "123oxo"
	fmt.Println(strings.TrimRight(str2, "xo"))
	fmt.Println(strings.TrimSuffix(str2, "xo"))
123
123o
  • 文字列の結合に+=を使用すると、文字列は不変な値なのでメモリが増え続けてしまう。
  • なので、Javaと同様strings.Builderを使用して文字列を構築する。
  • また、ビルダーは内部的にバイトスライスを保持しているためstrings.Builder.Grow()を使用して、総バイト数を指定するとパフォーマンスが上がる。
  • 目安としては5個以上の文字列を結合させる時にBuilderを使うといい
  • 文字列をスライス化を使用して部分文字列として扱う場合、指定する範囲がruneではなくバイト数に基づいているということと元の基底配列を参照するのでメモリリークの恐れがある
  • もし、部分文字列を安全に扱いたいならstrings.Cloneという関数が Go1.18で導入されてるよ
ぱんだぱんだ

第6章 関数とメソッド

  • メソッドのレシーバは特に理由がなければポインタ型のほうがいい
  • 関数の戻り値の名前付き結果パラメーターはインターフェイスの可読性にもつながる
ぱんだぱんだ

第7章 エラー管理

  • errorをマークして何か処理がしたいときなどは独自のerror構造体を作成することができる
  • 伝搬してきたerrorをそのまま引き継ぎたい場合はfmt.Errorf()と%wを使用してラップできる。
  • 値だけ伝搬したいならfmt.Errorf()と%vでもいい
  • エラーを使用して条件分岐する場合、エラーがラップされているかもしれないことを考慮すべき
  • 具体的にはerrors.Aserrors.Isを使用すると再帰的にUnwrapして探してくれる。
ぱんだぱんだ

第8章 並行処理:基本編

  • 並行処理と並列処理が混同しがち
  • 並列処理は一度に多くを行うことで、実行に関するもの
  • 並行処理は一度に多くを扱うこと、構造に関するもの
  • チャネルはバッファなしチャネルとバッファありチャネルがある
  • バッファなしチャネルの場合、送信ゴルーチンは受信ゴルーチンの準備が整うまで待たされる
  • 並列処理ではゴルーチン間で共有資源にアクセスする必要があるときがあるため、同期処理をする必要がある。チャネルの場合、バッファなしチャネルを使い、もしくはミューテックスを使用してそれを達成する。
  • 以下のようなコードはデータ競合が発生する
func f1() int {
	var i int

	go func() {
		i++
	}()

	go func() {
		i++
	}()

	return i
}
  • i++が読み込みと書き込みを行なっているためで、sync/atomicパッケージを使用してアトミックにインクリメントすることでデータ競合を避けたりすることもできる
func f2() int32 {
	var i int32

	go func() {
		atomic.AddInt32(&i, 1)
	}()

	go func() {
		atomic.AddInt32(&i, 1)
	}()

	time.Sleep(time.Second)

	return i
}
  • sync/atomicを使えばデータ競合は起きないかもしれないけど、-raceオプションつけると警告はちゃんと出る
  • mutexを使うと以下のようになる
func f3() {
	i := 0
	mx := sync.Mutex{}

	go func() {
		mx.Lock()
		i++
		mx.Unlock()
	}()

	go func() {
		mx.Lock()
		i++
		mx.Unlock()
	}()
}
  • これは-raceオプションをつけても警告はでない
  • チャネルを使うとこうも書ける
func f4() {
	i := 0
	ch := make(chan int)

	go func() {
		ch <- 1
	}()

	go func() {
		ch <- 1
	}()

	i += <-ch
	i += <-ch

	fmt.Println(i)
}
  • ゴルーチンではデータ競合に気をつけなければならないが実行順序の保証もされないので実行順序にも気をつかなければならない
  • バッファありチャネルとバッファなしチャネルの話
func f5() {
	i := 0
        // バッファありチャネル
	ch := make(chan struct{}, 1)
	go func() {
		i = 1
		<-ch
	}()

	ch <- struct{}{}
	fmt.Println(i)
}
  • これはゴルーチン内のiの書き込みと親ゴルーチンのiの読み込みがデータ競合する
func f5() {
	i := 0
	// バッファなしチャネル
	ch := make(chan struct{})

	go func() {
		i = 1
		<-ch
	}()

	ch <- struct{}{}
	fmt.Println(i)
}
  • バッファなしチャネルの場合、データの書き込みがデータ読み込みよりも先に実行されることが保証されるのでデータ競合は起きない
  • 並列処理は通常ミューテックスで同期することを必要とし、並行処理ではチャネルにおけるオーケストラレーションが必要
  • contextを使用する場合、空のcontextはcontext.Backgroundでもcontext.TODOでもどちらでもいいが、意図的にはTODOのほうが良い
  • どちらでもいいというか、どのcontextを使うべきかわからないような状況ではTODO使ってねみたいな感じっぽい
ぱんだぱんだ

第9章 並行処理: 実践編

  • selectを使用した複数チャネルの受信はランダムに選択される。
func f1() {
	messageCh := make(chan int, 10)
	disconnectCh := make(chan struct{})

	go func() {
		for {
			select {
			case v := <-messageCh:
				fmt.Println(v)
			case <-disconnectCh:
				fmt.Println("disconnect, return")
				return
			}
		}
	}()

	for i := 0; i < 10; i++ {
		messageCh <- i
	}
	disconnectCh <- struct{}{}
}
  • これは0から9まで出力されることを期待するが、出力されない。
  • なぜなら、Goはランダムにcaseを選択するからである。これは、意図的な仕様である。
  • これを全てのメッセージを出力して終了させるには以下のようにする
func f2() {
	messageCh := make(chan int, 10)
	disconnectCh := make(chan struct{})

	go func() {
		for {
			select {
			case v := <-messageCh:
				fmt.Println(v)
			case <-disconnectCh:
				for {
					select {
					case v := <-messageCh:
						fmt.Println(v)
					default:
						fmt.Println("disconnect, return")
						return
					}
				}
			}
		}
	}()

	for i := 0; i < 10; i++ {
		messageCh <- i
	}
	disconnectCh <- struct{}{}
}
  • Goではstruct{}{}のような空構造体は0バイトのため、通知チャネルとしてやmapなどに使用することができる。ちなみに、空インターフェースは0バイトではない。
  • バッファありチャネルが必要な時、そのサイズに指定する値は根拠のあるものでなければならない。よくわからないならデフォルトとして1を指定すべきです
  • 以下のコードは動作する
func f3() {
	type Dunation struct {
		mu      sync.RWMutex
		balance int
	}

	donation := Dunation{}

	f := func(goal int) {
		donation.mu.RLock()
		for donation.balance < goal {
			donation.mu.RUnlock()
			donation.mu.RLock()
		}
		fmt.Printf("$%d goal reached\n", goal)
		donation.mu.RUnlock()
	}

	go f(10)
	go f(15)

	go func() {
		for {
			time.Sleep(time.Second)
			donation.mu.Lock()
			donation.balance++
			donation.mu.Unlock()
		}
	}()
}
  • ただし、これはビジーループが存在し、CPU使用率をとても高くする。
  • では、以下のようにコードを修正する
func f4() {
	type Donation struct {
		balance int
		ch      chan int
	}

	donation := Donation{ch: make(chan int)}

	f := func(goal int) {
		for balance := range donation.ch {
			if balance >= goal {
				fmt.Printf("$%d goal reached\n", balance)
				return
			}
		}
	}

	go f(10)
	go f(15)

	for {
		if donation.balance >= 15 {
			return
		}
		time.Sleep(time.Second)
		donation.balance++
		donation.ch <- donation.balance
	}
}

これは以下のような出力になる

$11 goal reached
$15 goal reached
  • なぜ、$10ではなく$11となってしまうのか?
  • これは2つのゴルーチンの内両方ではなくどちらか片方が受信するからである。
  • $10のメッセージは一つ目ではなく二つ目のゴルーチンで受信したということ
  • これを解決するにはsync.Condが使える
func f5() {
	type Donation struct {
		balance int
		cond    *sync.Cond
	}

	donation := Donation{cond: sync.NewCond(&sync.Mutex{})}

	f := func(goal int) {
		donation.cond.L.Lock()
		for donation.balance < goal {
			donation.cond.Wait()
		}
		fmt.Printf("$%d goal reached\n", donation.balance)
		donation.cond.L.Unlock()
	}

	go f(10)
	go f(15)

	for {
		time.Sleep(time.Second)
		donation.cond.L.Lock()
		donation.balance++
		donation.cond.L.Unlock()
		donation.cond.Broadcast()
	}
}
  • 通常、複数チャネル全てに通知をする場合closeの通知のみである
  • closeしないでこれを実現するにはsync.Condが使用することができる
  • 上記の例だとdonation.balanceの更新をブロードキャストし、その通知を待ちます
  • そのため、ビジーループが発生することによるCPUの消費も避けることができる
  • 並行処理におけるエラー処理はerrorgroupを使いましょう
ぱんだぱんだ

第10章 標準パッケージ

  • time.Afterはチャネルを返します。処理を単純に待ちたいだけならtime.Sleepを使ってね
  • time.Afterは以下のような実装を期待する
func consumer(ch <-chan struct{}) {
	for {
		select {
		case <-ch:
			fmt.Println("Receved message!!")
			return
		case <-time.After(3 * time.Second):
			log.Fatal("Time out!!")
		}
	}
}

func main() {
	ch := make(chan struct{})
	consumer(ch)
	time.Sleep(5 * time.Second)
	ch <- struct{}{}
}
  • 上記のコードは動作するがメモリリークが発生している
  • time.Afterが返すチャネルはクローズされない。受信専用チャネルのためクローズすることもできない。
  • そのため、この場合はcontextを使用するほうが最適。
func consumer2(ch <-chan struct{}) {
	for {
		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		select {
		case <-ch:
			fmt.Println("Receved message!!")
			cancel()
			return
		case <-ctx.Done():
			log.Fatal("Time out!!")
		}
	}
}

func main() {
	ch := make(chan struct{})
	consumer2(ch)
	time.Sleep(5 * time.Second)
	ch <- struct{}{}
}
  • しかし、この実装ではループの度にcontextを再作成する必要がある
  • Goにおいてcontextの作成は軽量な操作でない
  • これはtime.NewTimerを使用することが最善です
func consumer3(ch <-chan struct{}) {
	d := 3 * time.Second
	timer := time.NewTimer(d)
	defer timer.Stop()
	for {
		timer.Reset(d)
		select {
		case <-ch:
			fmt.Println("Receved message!!")
			return
		case <-timer.C:
			log.Fatal("Time out!!")
		}
	}
}

func main() {
	ch := make(chan struct{})
	consumer3(ch)
	time.Sleep(5 * time.Second)
	ch <- struct{}{}
}
  • Goのtime.Now()で取得できるtimeはウォールクロックモノトニッククロックの二種類のクロックを含んでいる。
  • jsonにtime構造体をアンマーシャルするとモノトニッククロックが削除されてしまう。
func f1() {
	t := time.Now()
	event1 := Event{Time: t}

	b, err := json.Marshal(event1)
	if err != nil {
		log.Fatal(err)
	}

	var event2 Event
	if err = json.Unmarshal(b, &event2); err != nil {
		log.Fatal(err)
	}

	fmt.Println(event1.Time)
	fmt.Println(event2.Time)
}
2023-09-22 23:03:45.245254 +0900 JST m=+0.000361042
2023-09-22 23:03:45.245254 +0900 JST
  • そのため、時刻が等しいか確認する場合はtime.Equal()を使用して比較するとモノトニック時間を考慮しない
  • もしくは、time.Truncate()を使用して、モノトニック時間を切り捨てる方法もある。
  • jsonをmapにアンマーシャルする時、数値はデフォルトでfloat64に変換される。
  • sql.Open必ずしもデータベースのコネクションを確立しない
  • コネクションを確率するかしないかは使用するSQLドライバーに依存する.
  • コネクションの確率を確認したければdb.Ping()を使用する
  • sql.Openが返す*sql.DBはデータベースとのコネクションではなく、コネクションプールを表す
  • クエリを実行するときは*sql.DB.Prepareを使用すべき
ぱんだぱんだ

第12章 最適化

  • 最近のCPUはメモリアクセスを高速化するためにキャッシングに依存しており、ほとんどの場合、3つのキャッシングレベルである、L1, L2, L3を経由している。
  • メインメモリ、すなわちRAMへのアクセスはL1へのアクセスと比較して50倍から100倍ほど遅い。
  • そのため、Goコードの最適化はCPUキャッシュを使うようにすること
  • キャッシュにおいて同じ位置が、再び参照されることを指して時間的局所性という
  • キャッシュにおいて近くのメモリ位置が参照されることを指して空間的局所性という
  • どちらも参照局所性という原則の一部。
  • 同じ変数への反復的なアクセス(時間的局所性)はCPUキャッシュが必要な理由の一部です。
  • スライスのようなメモリ上に連続して配置されるデータの処理(空間的局所性)を高速化するために1つの変数をコピーするのではなく、キャッシュラインと呼ぶまとまった単位でキャッシュする。
  • キャッシュラインは連続したメモリセグメントで、通常は64バイト
  • 通常メモリへのアクセスはL1, L2, L3, 最後にRAMを調べる。
  • キャッシュがまだないためメインメモリにアクセスすることを初期参照ミスという
func sum2(s []int64) int64 {
	var total int64
	for i := 0; i < len(s); i += 2 {
		total += s[i]
	}
	return total
}

func sum8(s []int64) int64 {
	var total int64
	for i := 0; i < len(s); i += 8 {
		total += s[i]
	}
	return total
}
  • sum2は4回のアクセスのうち初回が初期参照ミスになるが残り3回はキャッシュヒットとなり高速。
  • sum8はキャッシュラインは通常int64の数値が8個のため毎回初回アクセスとなり初期参照ミスになる。
  • ので、想定よりもsum2とsum8は差が出ないよという話。
  • 以下のコードは偽共有という概念について学ぶためのコード。
  • 偽共有とは異なるコアが共通のデータのキャッシュラインを独立して読み書きしようとして起こるパフォーマンス問題のことです
  • 以下の例だとフィールドsumAを更新するゴルーチンとsumBを更新するゴルーチンがある。
  • メインメモリにsumAとsumBが並んで格納されるが、2つのゴルーチンが共通の構造体のフィールドの更新を行なっているとそれぞれのキャッシュラインが作成される。
  • そして、それぞれのゴルーチンの処理でそれぞれのキャッシュラインを参照、更新することになり、それがパフォーマンス問題となる。
type Input struct {
	a int64
	b int64
}

type Result struct {
	sumA int64
	sumB int64
}

type Result2 struct {
	sumA int64
	_    [56]byte
	sumB int64
}

type ResultIF interface {
	IncrementSumA(int64)
	IncrementSumB(int64)
}

func (r *Result) IncrementSumA(a int64) {
	r.sumA += a
}

func (r *Result) IncrementSumB(b int64) {
	r.sumB += b
}

func (r *Result2) IncrementSumA(a int64) {
	r.sumA += a
}

func (r *Result2) IncrementSumB(b int64) {
	r.sumB += b
}

func count(inputs []Input, result ResultIF) {
	wg := sync.WaitGroup{}
	wg.Add(2)

	go func() {
		for i := 0; i < len(inputs); i++ {
			result.IncrementSumA(inputs[i].a)
		}
		wg.Done()
	}()

	go func() {
		for i := 0; i < len(inputs); i++ {
			result.IncrementSumB(inputs[i].b)
		}
		wg.Done()
	}()

	wg.Wait()
}
  • この偽共有の解決策としてパディングを埋めるという方法がある
  • これは、上記のResult2のようにキャッシュラインは64バイトなので64 - 8 = 56バイトのbyteスライスをフィールドに持たせることでそれがパディングとなりsumAとsumBが別々のメモリブロックとなり、結果的に別々のキャッシュラインとなるようにする方法。
  • ILPについて

ILP(Instruction Level Parallelism)は、命令レベルの並列性を指すコンピュータアーキテクチャの用語です。ILPは、単一のプロセッサが複数の命令を同時にまたはほぼ同時に実行する能力を指します。この概念は、プロセッサのパフォーマンスを向上させるために非常に重要です。
(ChatGPTより)

  • プログラミングレベルでILPが有効になるようにすることもできるという話だけどもそのような最適化をちゃんと気づいてできるかというと微妙なのでそういうCPUの最適化を知っとこうねくらいに思っておきます。
  • データアライメントとはCPUによるメモリアクセスを高速化するために、データの割り当て方を整えることである。
// b1 1バイト
// i 8バイト
// b2 1バイト
// int64は8の倍数であるアドレスに配置される必要があり、
// 無駄なパディングがコンパイル時に挿入される
// 結果、Foo構造体は24バイトのメモリを消費する
type Foo struct {
	b1 byte
	i int64
	b2 byte
}

// i 8バイト
// b1 1 バイト
// b2 1バイト
// バイト数が大きい順に並び替えると無駄なパディングの挿入を防ぐことができる
// 結果、Bar構造体は16バイトのメモリを消費する
type Bar struct {
	i int64
	b1 byte
	b2 byte
}
  • Goにおいて変数はスタックヒープのどちらかのメモリに割り当てられる。
  • スタックはデフォルトのメモリで特定のゴルーチンのローカル変数を全て格納する
  • スタック内の変数は既に使われなくなったアドレスに上書きされていく(自己クリーニング)。
  • ヒープはヒープの割り当てが増えるほどGCに負担をかけることになる。
  • そのため、基本的にはヒープへの割り当てを極力減らすことがパフォーマンス改善につながる。
  • ちなみに関数呼び出しがあるとスタック内にスタックフレームが作成される。
  • 関数の戻り値がポインタのとき、その戻り値が関数内の変数を参照しているとその変数は参照することができないのでヒープに割り当てられることになる
  • こういったことがあるのでメモリの節約が目的でポインタを返すようにすることが最善とは限らないということになる。
  • ある変数をスタックとヒープのどちらに割り当てるべきかをコンパイラが判断する処理をエスケープ分析とよぶ。
  • 一般的にメインのスタックから関数呼び出しにより関数のスタックフレームが作成され、その関数が関数内で作成された変数のポインタを返す場合、その値の参照は既に無効なスタックフレームへのアクセスのためスタックからヒープ上に割り当てられる。これはシェアリングアップと呼ばれる。
  • 逆に、ポインタを関数で受け取って値を返す場合、別のスタックフレームにあるにもかかわらず関数内の変数はスタックに残れる。これを通常シェアリングダウンと呼ぶ。
  • 以下のコードだとsum関数内のz変数が無効なスタックフレームとなるためヒープ領域に移される。
//go:noinline
func sum(x, y int) *int {
	z := x + y
	return &z
}

func main() {
	a := 3
	b := 2
	c := sum(a, b)
	println(*c)
}
// -m エスケープ解析の結果を表示 =2は詳細レベル
% go build -gcflags "-m=2"

./main.go:106:2: z escapes to heap:
./main.go:106:2:   flow: ~r0 = &z:
./main.go:106:2:     from &z (address-of) at ./main.go:107:9
./main.go:106:2:     from return &z (return) at ./main.go:107:2
./main.go:106:2: moved to heap: z
  • sum関数を修正してポインタを受け取り、値を返すように修正するとヒープへの移動は起こらない。
//go:noinline
func sum_2(x, y *int) int {
	z := *x + *y
	return z
}

func main() {
	a := 3
	b := 2
	c := sum(a, b)
	println(c)
}
  • ヒープへの割り当てを減らすテクニックは多くあるが一般的な3つの方法として以下のようなものがある。

    • APIを変更する
    • コンパイラの最適化に頼る
    • sync.Poolを使う
  • ミッドスタックインライン展開とは、他の関数を呼び出す関数をインライン展開すること。

  • 低速なパスと高速なパスを含む複雑な関数があるならば、低速なパスを関数として切り出すことでミッドスタックインライン展開ができるようになるかもしれない。

  • パフォーマンス問題の解決、競合の検出、メモリリークなどを発見するためにプロファイリングをおこなうことができる。

  • Goでは以下のようにpprofを有効化する。これでwebブラウザでプロファイリング結果を見ることができる。

import (
	"fmt"
	"log"
	"net/http"
	"sync"

	_ "net/http/pprof" // ブランクインポート必要
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "")
	})

	log.Fatal(http.ListenAndServe(":80", nil))
}
  • GoのGCはマーク&スイープアルゴリズムに基づいている。
  • GCが実行されるとそれは最初にストップザ・ワールドにつながる処理を実行する。
  • これは利用可能な全てのCPU時間はGCを実行するために使われ、アプリケーションのコードは一時停止される。
  • GoのGCは環境変数であるGOGCに左右され、この値はデフォルトで100である。その場合、ヒープの大きさが前回のGCから100%増加していた場合にGCが実行される。
  • また、過去2分間にGCが実行されていない場合も強制的にGCが実行される。
このスクラップは2023/09/26にクローズされました