フューチャー技術ブログ

Go 1.22リリース連載 net, net/http, net/netip

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.ResponseWriterWrite()で実現できます(※実際は http.FileServer() を使うでしょうが)。このとき io.ReadAll() するとカーネル空間から、ユーザー空間にデータコピーが行われます。また、読み取った値を Write()で書き込みHTTP応答する際に、再びユーザー空間からカーネル空間にデータコピーが行われます。

データコピー.drawio.png

これを splice(2) を用いて、ユーザー空間にメモリコピーせず、カーネル空間上に閉じてやり取りをさせたいよね、というのは背景となるモチベーションです。順序的には pipe(2) のシステムコールを呼び、次に左のsplice(2)でパイプに書き込み、最後に右側のsplice(2)を呼びパイプから読み取りネットワークインタフェースに書き込ませます。

データコピー-ページ2.drawio.png

splice(2) を利用するためには、2つのファイル記述子のうち、1つがパイプである必要があるそうです。そのため pipe(2) を呼び出しています。パイプにコピーしているからゼロコピーじゃないじゃん! って思いましたが、多分カーネル空間に閉じていればノーカンなんだと思います。多分。

参考:

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.TCPConnos.FileWriteTo(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.Writerio.Readerをfor分でループさせて転送する処理にフォールバックします。

例として、ファイル→UNIXドメインソケットにデータをコピーし、どのように呼び出し階層が変わるか go tool で可視化します。

まずはサーバ側の実装です。こちらはUNIXドメインソケットに書き込まれた内容を標準出力するだけで、今回は特に何もしません。

Unixドメインサーバ
package main

import (
"fmt"
"io"
"log"
"net"
"os"
)

func main() {
os.Remove("/tmp/go122.sock")
listener, err := net.Listen("unix", "/tmp/go122.sock")
if err != nil {
fmt.Printf("error: %v\n", err)
return
}
for {
conn, err := listener.Accept()
if err != nil {
break
}
go func() {
defer conn.Close()
bytes, err := io.ReadAll(conn)
if err != nil {
if err != io.EOF {
log.Printf("error: %v", err)
}
}
log.Printf("receive: %s\n", string(bytes))
}()
}
}

続いてUNIXドメインソケットのクライアント側です。

メソッド呼び出しのコールグラフを作りたかったので、ベンチマーク形式で作っています。

Unixドメインクライアント
package unixdomainclient

import (
"fmt"
"io"
"net"
"os"
"testing"
)

func BenchmarkUnixDomainClient(b *testing.B) {
for i := 0; i < b.N; i++ {
w, err := net.Dial("unix", "/tmp/go122.sock")
if err != nil {
fmt.Printf("error: %v\n", err)
return
}

r, err := os.Open("example.txt")
if err != nil {
fmt.Printf("error: %v\n", err)
return
}

if _, err = io.Copy(w, r); err != nil {
fmt.Printf("error: %v\n", err)
return
}
}

fmt.Println("finish")
}

これを実行し、可視化します。

go test -v -cpuprofile cpu.prof -memprofile mem.prof -bench .
go tool pprof -http :8080 cpu.prof

そうすると、io.Copy() から システムコール sendfile(2) が呼ばれているのが分かります。

image.png

比較のため、Go1.21.7 で動かしてみます。

$ go version
go version go1.21.7 linux/amd64

$ go test -v -cpuprofile cpu.prof -memprofile mem.prof -bench .
$ go tool pprof -http :8081 cpu.prof

そうすると、今度は sendfile(2) ではなく read(2) が呼ばれていることが分かります。

image.png

今回は go tool でシステムコールがどのような流れ呼び出されているか確認しました。

Goならわかるシステムプログラミング 第5回 Goから見たシステムコール に書かれている通り、 strace を見て確認するのも良いかと思います。

先ほどとほぼ類似の main.go を作ります。

package main

import (
"fmt"
"io"
"net"
"os"
)

func main() {
w, err := net.Dial("unix", "/tmp/go122.sock")
if err != nil {
fmt.Printf("error: %v\n", err)
return
}

r, err := os.Open("example.txt")
if err != nil {
fmt.Printf("error: %v\n", err)
return
}

if _, err = io.Copy(w, r); err != nil {
fmt.Printf("error: %v\n", err)
return
}
fmt.Println("finish")
}

stace でシステムコールの発行状況を確認します。 Go1.22 の場合は sendfile(2) を利用しています。

Go1.22 strace結果
$ go version
go version go1.22.0 linux/amd64

$ go build
$ strace ./main
(...省略...)
fcntl(7, F_GETFL) = 0x8800 (flags O_RDONLY|O_NONBLOCK|O_LARGEFILE)
fcntl(7, F_SETFL, O_RDONLY|O_LARGEFILE) = 0
sendfile(3, 7, NULL, 4194304) = 15
sendfile(3, 7, NULL, 4194304) = 0
write(1, "finish\n", 7finish
) = 7
exit_group(0) = ?
+++ exited with 0 +++

Go1.21 の場合は read(2), write(2) を用いていることが分かります。

Go1.21 strace結果
$ go version
go version go1.21.7 linux/amd64

$ go build
$ strace ./main
(...省略...)
fcntl(7, F_GETFL) = 0x8800 (flags O_RDONLY|O_NONBLOCK|O_LARGEFILE)
fcntl(7, F_SETFL, O_RDONLY|O_LARGEFILE) = 0
read(7, "1\r\n2\r\n3\r\n4\r\n5\r\n", 32768) = 15
write(3, "1\r\n2\r\n3\r\n4\r\n5\r\n", 15) = 15
read(7, "", 32768) = 0
write(1, "finish\n", 7finish
) = 7
exit_group(0) = ?
+++ exited with 0 +++

私の業務範囲だとUNIXドメインソケットを使う場面はあまり考えられないのですが、Linuxの機能を上手く活用した改善が入るのは嬉しいですね。

net: WindowsでDNSリゾルバ挙動更新 #57757

-tags=netgo をつけてビルドした場合、Windowsで %SystemRoot%\System32\drivers\etc\hostshosts を参照しない不備があったようです。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)
func FileServerFS(root fs.FS) Handler

クライアント側には1つ追加されました。

func NewFileTransportFS(fsys fs.FS) RoundTripper

http.NewFileTransportFS() を取り上げます。

最初に検証用のファイルを作成します。

echo -e "test\ntest\ntest" > ~/example.txt

続いて RegisterProtocol()file スキーマに http.NewFileTransportFS() 登録します。アクセス先は先ほど作成した example.txt です。

package main

import (
"fmt"
"io"
"log"
"net/http"
"os"
)

func main() {
t := &http.Transport{}
t.RegisterProtocol("file", http.NewFileTransportFS(os.DirFS("/"))) // ★ポイント
c := &http.Client{Transport: t}

res, err := c.Get("file:///home/mano/example.txt")
if err != nil {
// TODO 検証用コードのためFatalで落とす
log.Fatal(err)
}
defer res.Body.Close()

all, err := io.ReadAll(res.Body)
if err != nil {
// TODO 検証用コードのためFatalで落とす
log.Fatal(err)
}
fmt.Print(string(all))
}

そうすると実行結果は次のように、先ほど作成した example.txt の結果が表示されます。

test
test
test

従来ですと次のように FS がついていない http.NewFileTransport() を使っていました。

package main

import (
"fmt"
"io"
"log"
"net/http"
- "os"
)

func main() {
t := &http.Transport{}
- t.RegisterProtocol("file", http.NewFileTransportFS(os.DirFS("/"))) // ★ポイント
+ t.RegisterProtocol("file", http.NewFileTransport(http.Dir("/"))) // ★従来実装
c := &http.Client{Transport: t}

res, err := c.Get("file:///home/mano/example.txt")
if err != nil {
// TODO 検証用コードのためFatalで落とす
log.Fatal(err)
}
defer res.Body.Close()

all, err := io.ReadAll(res.Body)
if err != nil {
// TODO 検証用コードのためFatalで落とす
log.Fatal(err)
}
fmt.Print(string(all))
}

挙動としては同じですし、deprecatedという訳でもないですが、今後は FS がついている方を利用する方が拡張性などの観点で良いでしょう。

net/http: 空っぽのContent-Lengthヘッダ拒否 #61679

Content-Length: 0 ではなく、 Content-Length: をサーバにHTTPリクエストした場合、従来ですと200が返ってきた(400ではない)ことが、RFC 9110 のセクション 8.6などに反しているということで、修正されました。この拒否する挙動は、Apacheやnginxと同じらしく、影響を受けるユーザーはほぼゼロだろうということも話されていました。

..にも関わらず、従来の挙動で動かしたい場合 GODEBUGhttplaxcontentlength=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 → -1p=p2 → 0ip>ip2 → 1を返します。

背景としては、Go1.21で追加された slices.SortFunc() と組み合わせたいとのことです。

どのような感じになるか slices.SortFunc()netip.Compare() を組み合わせてみます。

package main

import (
"fmt"
"net/netip"
"slices"
)

func main() {
inputs := []string{
"1.2.3.4:80",
"1.2.3.4:443",
"2.3.4.5:22",
"[::1]:80",
"[::1]:443",
"[::2]:22",
"[0102:0304::0]:80",
}

addrs := make([]netip.AddrPort, 0, len(inputs))
for _, s := range inputs {
addrs = append(addrs, netip.MustParseAddrPort(s))
}

slices.SortFunc(addrs, func(a, b netip.AddrPort) int {
return a.Compare(b)
})

for _, a := range addrs {
fmt.Printf("%+v\n", a)
}

}

実行すると次のような並び順になります。まずIP部分でソート、その後ポート番号でソートといった形で、直感的だと思います。IPv4とIPv6を混ぜた場合は、v4→v6の順になるようです。

出力結果
1.2.3.4:80
1.2.3.4:443
2.3.4.5:22
[::1]:80
[::1]:443
[::2]:22
[102:304::]:80

ちなみに、元のIssueでは netip.Prefix にも Compare() を追加しようという提案でしたが、次回以降に持ち越しとなりました。理由として 10.0.0.0/80.0.0.0/32 のような違和感ある並び順となる実装になっていたようで、既存の標準順序があればそれに合わせようということで、取り下げられました。

Compare() 1つ追加するにしても、どのような順序が一般的か(慣習に乗っ取っていて、利用者の驚きが最小化されるか)、周辺知識も深めていかないと駄目だなと感じました。

さいごに

Go1.22のnet, net/http, net/netip の3パッケージのアップデートについて取り上げました。

最近、低レイヤーについてどこまで抑えておくべきか、といった言説をXで見かけた気がしており、私が新人のときの研修リーダー的な先輩に、「自分の業務で用いる1つ下まで抑えるべき。2つ下まで深掘りできたら相当差がつく、凄い」と言われたことを思い出しました。

リリースノートの内容も、「Goならわかるシステムプログラミング」を理解していればもっと解像度が高く理解できていたなと反省/痛感しています。引き続き理解できる範囲も広げていこうと思います。最後まで読んでいただき、ありがとうございます。