net/http
の Header
の Clone の実装について調べる機会があったので、そこで気になったことをまとめました。
net/http.Header の cloneHeader の実装
本家の Clone の処理は以下
// Clone returns a copy of h. func (h Header) Clone() Header { // Find total number of values. nv := 0 for _, vv := range h { nv += len(vv) } sv := make([]string, nv) // shared backing array for headers' values h2 := make(Header, len(h)) for k, vv := range h { n := copy(sv, vv) h2[k] = sv[:n:n] sv = sv[n:] } return h2 }
元々は以下のような unexport な実装になっていたが、最新版では export な関数になっている。
func (h Header) clone() Header { h2 := make(Header, len(h)) for k, vv := range h { vv2 := make([]string, len(vv)) copy(vv2, vv) h2[k] = vv2 } return h2 }
メモリ効率の悪さがもともとあったらしく https://github.com/golang/go/issues/29915 のissue で起票され、変更が取り込まれて上記のような現在の実装になっている。
net/http.Header.Cloen() の疑問点
https://github.com/golang/go/issues/29915 で起票されている通り元の実装ではメモリ効率が悪かったので現在のような実装になっているらしい。
ただ、試しに以下のように loop の外で コピー先の実装を確保して loop 内で新しい Header に差し込む実装をしてもコピーの動作に変化はなかった。
(※ for range pointer だけど for 内部でコピーをしなくても差し込む新しい Header が別なので別ものとしてコピーされているらしい)
func (h Header) clone() Header { h2 := make(Header, len(h)) for k, v := range h { h2[k] = v } return h2 }
サンプル実装: https://play.golang.org/p/nnF7ERKOrEA
このサンプル実装の結果、コピー前後の Header の pointer のアドレスの値は変わっているので、やりたいことは同じに見える。
計測する
以下の3つの実装を用意する。
- 本家を踏襲した実装(無印)
- 本家の1つの前のバージョンの実装(V1)
- 今回計測したい実装(V2)
func cloneHeader(h http.Header) http.Header { nv := 0 for _, v := range h { nv += len(v) } sv := make([]string, nv) // shared backing array for headers' values h2 := make(http.Header, len(h)) for k, v := range h { n := copy(sv, v) h2[k] = sv[:n:n] sv = sv[:n] } return h2 } func cloneHeaderV1(h http.Header) http.Header { h2 := make(http.Header, len(h)) for k, v := range h { v2 := make([]string, len(v)) copy(v2, v) h2[k] = v2 } return h2 } func cloneHeaderV2(h http.Header) http.Header { h2 := make(http.Header, len(h)) for k, v := range h { h2[k] = v } return h2 }
ベンチの実装は以下
func Benchmark_cloneHeader(b *testing.B) { header := http.Header{} for i := 0; i < 10000; i++ { header.Add(string(i), string(i)) } for i := 0; i < b.N; i++ { cloneHeader(header) } } func Benchmark_cloneHeaderV1(b *testing.B) { header := http.Header{} for i := 0; i < 10000; i++ { header.Add(string(i), string(i)) } for i := 0; i < b.N; i++ { cloneHeaderV1(header) } } func Benchmark_cloneHeaderV2(b *testing.B) { header := http.Header{} for i := 0; i < 10000; i++ { header.Add(string(i), string(i)) } for i := 0; i < b.N; i++ { cloneHeaderV2(header) } }
ベンチ結果は以下
go test -bench . -benchmem -run Benchmark_cloneHeader # 略 Benchmark_cloneHeader-8 2000 1041890 ns/op 902115 B/op 19 allocs/op Benchmark_cloneHeaderV1-8 1000 1164574 ns/op 899178 B/op 10007 allocs/op Benchmark_cloneHeaderV2-8 2000 827401 ns/op 738271 B/op 18 allocs/op
仮説とわからないこと
ヘッダーのコピーの挙動が同じであれば、計測結果を見る限り V2 の実装の方が効率が良い。
しかし、Go の本家の header.go
の中の実装ではそうなっていないのは何故なんだろう...??
V2 の実装を採用した場合に、何か意図しない挙動がおこなるのでは?とも思って実際のアドレスを見たけどアドレスは異なっているのでやりたいことは同じだと思う。
Header の Clone について本家のような実装になってる意図が見えてこないので教えて欲しいし、計測方法や自分が気づいていないバグがあるかもしれないからその点もフィードバックが欲しい。
追記にて解決しました
その他
Full slice expressions
初めて知った。Go の Slice は index の指定で slice の要素を取り出すときに capacity を指定できる。
func main() { str := []string{"a", "b", "c", "d"} str2 := str[1:2:3] fmt.Printf("str2: %v\n", str2) fmt.Printf("cap(str2): %v", cap(str2)) } // Output // str2: [b] // cap(str2): 2
s[low: high: max]
とあると max で指定した cap のあたいは max - low
の値になる。
ref: https://golang.org/ref/spec#Slice_expressions
追記
解決した。
V2 の実装でもうまいこと動作していたのは cap を指定していなかったから。 cap を指定すると h と h2 の 間で slice を共有してしまっているので、h のcap に余裕がある状態で Add すると あと勝ちが発生 してしまう。
ref: https://play.golang.org/p/6QmI__2DoNw
これは意図した振る舞いではないので shallow copy 出なく deep copy が必要になるので本家のような実装が正しい。
shallow copy による「意図しない振る舞い」をどう再現するのかわからなくて悩んでしまっていた....。