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ならわかるシステムプログラミング」を理解していればもっと解像度が高く理解できていたなと反省/痛感しています。引き続き理解できる範囲も広げていこうと思います。最後まで読んでいただき、ありがとうございます。