The Gopher character is based on the Go mascot designed by Renée French
TIG 真野です。Go1.22連載の8本目です。
Go 1.22 ライブラリのマイナーアップデートである net
, net/http
, net/netip
を取り上げます。
アップデートサマリ
- net:
TCPConn
からUnixConn
へのio.Copy()
で、Linux’s splice(2)システムコールが使われ性能改善 #58808 - net: WindowsでDNSリゾルバは
-tags=netgo
付きでビルドすると、DNSクエリの前に%SystemRoot%\System32\drivers\etc\hosts
から検索するようになる [#57757]https://github.com/golang/go/issues/57757) - net/http:
ServeFileFS()
,FileServerFS()
,NewFileTransportFS()
が新規追加 #51971 - net/http: HTTPクライアント、サーバともに、空っぽの
Content-Length
ヘッダを拒否するようになった #61679 - net/http:
Request.PathValue()
が新規追加 #61410 - net/netip:
AddrPort.Compare()
が新規追加 #61642
net: TCPConnからUnixConnへのio.Copy() で、Linux’s splice(2)システムコールが使われ性能改善 #58808
前提知識となる、 splice(2)
ですが、入力用と出力用のファイル記述子を繋ぎ、カーネル空間とユーザー空間でのデータコピーを行わず(ゼロコピーと言われる所以です)、データ転送を行うシステムコールです。(2)
の 2
は引数ではなく、システムコールを指す番号です。
例えば静的ファイルをホストしているGoのHTTPサーバを構築するとします。極めて素朴に実装すると、ファイルの要求に対して、os.Open()
でファイルを開き、 io.ReadAll()
で[]byte
を取得し、http.ResponseWriter
に Write()
で実現できます(※実際は http.FileServer()
を使うでしょうが)。このとき io.ReadAll()
するとカーネル空間から、ユーザー空間にデータコピーが行われます。また、読み取った値を Write()
で書き込みHTTP応答する際に、再びユーザー空間からカーネル空間にデータコピーが行われます。
これを splice(2)
を用いて、ユーザー空間にメモリコピーせず、カーネル空間上に閉じてやり取りをさせたいよね、というのは背景となるモチベーションです。順序的には pipe(2)
のシステムコールを呼び、次に左のsplice(2)
でパイプに書き込み、最後に右側のsplice(2)
を呼びパイプから読み取りネットワークインタフェースに書き込ませます。
splice(2)
を利用するためには、2つのファイル記述子のうち、1つがパイプである必要があるそうです。そのため pipe(2)
を呼び出しています。パイプにコピーしているからゼロコピーじゃないじゃん! って思いましたが、多分カーネル空間に閉じていればノーカンなんだと思います。多分。
参考:
- Zero-Copy Optimization in the Golang)
- Apache Kafkaの中の人によるzero copyの解説ポスト
- Goとシステムコール周りについては、澁川さんのGoから見たシステムコール記事がオススメです。書籍もありますがWeb版で相当な分量です
Go1.21以前のステータスでは、以下のケースは splice(2)
を用いてゼロコピーになるように io.Copy()
が実装されていました。
- TCPソケットからTCPソケット
- UNIXドメインソケットからTCPソケット
- ファイルからTCPソケット
- TCPソケットからファイル
- UNIXドメインソケットからファイル
- ファイルからファイル
先ほど例に上げた静的ファイルをHTTP応答で返すケースは、ファイル→TCPソケットで対応済み、例えば、http.FileServer()
は内部で io.Copy()
を使っているのですでに最適化されています。
#58808
ではこの対応を以下の2つにも広げようというものです。
- ファイルからUNIXドメインソケット
- TCPソケットからUNIXドメインソケット
そレを実現するため、Go1.22では、net.TCPConn
と os.File
に WriteTo(io.Writer)
を追加されました。それらの内部で、 splice(2)
や sendfile(2)
を可能であれば利用する実装になっています。
io.Copy()の内部実装
io.Copy() ですが、引数に io.Writer
, io.Reader
を取りますが、 GoDocにも書かれている通り io.WriterTo
が実装されていれば src.WriteTo(dst)
が、io.ReaderFrom
が実装されていれば dst.ReadFrom(src)
が呼ばれます。io.WriterTo
で条件が揃えばシステムコールのsendfile(2)
や splice(2)
を呼び、無理であれば genericWriteTo()
というio.Writer
とio.Reader
をfor分でループさせて転送する処理にフォールバックします。
例として、ファイル→UNIXドメインソケットにデータをコピーし、どのように呼び出し階層が変わるか go tool
で可視化します。
まずはサーバ側の実装です。こちらはUNIXドメインソケットに書き込まれた内容を標準出力するだけで、今回は特に何もしません。
package main |
続いてUNIXドメインソケットのクライアント側です。
メソッド呼び出しのコールグラフを作りたかったので、ベンチマーク形式で作っています。
package unixdomainclient |
これを実行し、可視化します。
go test -v -cpuprofile cpu.prof -memprofile mem.prof -bench . |
そうすると、io.Copy()
から システムコール sendfile(2)
が呼ばれているのが分かります。
比較のため、Go1.21.7 で動かしてみます。
$ go version |
そうすると、今度は sendfile(2)
ではなく read(2)
が呼ばれていることが分かります。
今回は go tool
でシステムコールがどのような流れ呼び出されているか確認しました。
Goならわかるシステムプログラミング 第5回 Goから見たシステムコール に書かれている通り、 strace
を見て確認するのも良いかと思います。
先ほどとほぼ類似の main.go
を作ります。
package main |
stace
でシステムコールの発行状況を確認します。 Go1.22
の場合は sendfile(2)
を利用しています。
$ go version |
Go1.21
の場合は read(2)
, write(2)
を用いていることが分かります。
$ go version |
私の業務範囲だとUNIXドメインソケットを使う場面はあまり考えられないのですが、Linuxの機能を上手く活用した改善が入るのは嬉しいですね。
net: WindowsでDNSリゾルバ挙動更新 #57757
-tags=netgo
をつけてビルドした場合、Windowsで %SystemRoot%\System32\drivers\etc\hosts
のhosts
を参照しない不備があったようです。TODO が残っていたとのこと。
netgo
ってなんだ? という方も多いかと思いますが、golangの名前解決について - okzkメモに説明されている通り、GoではDNS名前解決の方法が2種類あり、pure Go実装版を利用するためには、 CGO_ENABLED=0
か -tags=netgo
を付けてビルドする必要があります。
今回はpure Go版かつWindowsで hosts
ファイルを見る実装が漏れていたので修正したということです。Windowsサーバ上もそうですが、GoでCLIツールを開発して展開している人なんかは、ちゃんと hosts
を見るようになって嬉しいかもしれませんね。
net/http: ServeFileFS()など新規追加 #51971
net/http
には ServeFile()、ServeContent() など静的ファイルをホストするような便利関数が存在します。しかし、これらは io/fs
パッケージが登場したGo 1.16 以前に開発されていたもので、互換性のため io.FS
で動作する版を追加しようという提案です。
サーバ側には2つ追加されました。
func ServeFileFS(w ResponseWriter, r *Request, fsys fs.FS, name string) |
クライアント側には1つ追加されました。
func NewFileTransportFS(fsys fs.FS) RoundTripper |
http.NewFileTransportFS()
を取り上げます。
最初に検証用のファイルを作成します。
echo -e "test\ntest\ntest" > ~/example.txt |
続いて RegisterProtocol()
で file
スキーマに http.NewFileTransportFS()
登録します。アクセス先は先ほど作成した example.txt
です。
package main |
そうすると実行結果は次のように、先ほど作成した example.txt
の結果が表示されます。
test |
従来ですと次のように FS
がついていない http.NewFileTransport()
を使っていました。
package main |
挙動としては同じですし、deprecatedという訳でもないですが、今後は FS
がついている方を利用する方が拡張性などの観点で良いでしょう。
net/http: 空っぽのContent-Lengthヘッダ拒否 #61679
Content-Length: 0
ではなく、 Content-Length:
をサーバにHTTPリクエストした場合、従来ですと200が返ってきた(400ではない)ことが、RFC 9110 のセクション 8.6などに反しているということで、修正されました。この拒否する挙動は、Apacheやnginxと同じらしく、影響を受けるユーザーはほぼゼロだろうということも話されていました。
..にも関わらず、従来の挙動で動かしたい場合 GODEBUG
に httplaxcontentlength=1
を設定すれば切り戻し可能だそうです。芸が細かい..!!
net/http: Request.PathValue()新規追加 #61410
Go1.22リリースの目玉機能の1つで、HTTPサーバのルーティングが大幅に強化されました。
武田さんのGo1.22 リリース連載 HTTPルーティングの強化 を参照ください。機能面の紹介だけではなく性能面もベンチマークを取っており、参考になります。
net/netip: AddrPort.Compare()新規追加 #61642
func (p AddrPort) Compare(p2 AddrPort) int
が追加され、AddrPort
の比較ができるようになりました。 time.Compare()
などと同様、 p<p2 → -1
、p=p2 → 0
、ip>ip2 → 1
を返します。
背景としては、Go1.21で追加された slices.SortFunc()
と組み合わせたいとのことです。
- 【参考】
slices.SortFunc()
について → Go1.21:slicesパッケージのチートシート
どのような感じになるか slices.SortFunc()
に netip.Compare()
を組み合わせてみます。
package main |
実行すると次のような並び順になります。まずIP部分でソート、その後ポート番号でソートといった形で、直感的だと思います。IPv4とIPv6を混ぜた場合は、v4→v6の順になるようです。
1.2.3.4:80 |
ちなみに、元のIssueでは netip.Prefix
にも Compare()
を追加しようという提案でしたが、次回以降に持ち越しとなりました。理由として 10.0.0.0/8
→ 0.0.0.0/32
のような違和感ある並び順となる実装になっていたようで、既存の標準順序があればそれに合わせようということで、取り下げられました。
Compare()
1つ追加するにしても、どのような順序が一般的か(慣習に乗っ取っていて、利用者の驚きが最小化されるか)、周辺知識も深めていかないと駄目だなと感じました。
さいごに
Go1.22のnet
, net/http
, net/netip
の3パッケージのアップデートについて取り上げました。
最近、低レイヤーについてどこまで抑えておくべきか、といった言説をXで見かけた気がしており、私が新人のときの研修リーダー的な先輩に、「自分の業務で用いる1つ下まで抑えるべき。2つ下まで深掘りできたら相当差がつく、凄い」と言われたことを思い出しました。
リリースノートの内容も、「Goならわかるシステムプログラミング」を理解していればもっと解像度が高く理解できていたなと反省/痛感しています。引き続き理解できる範囲も広げていこうと思います。最後まで読んでいただき、ありがとうございます。