メインコンテンツへスキップ
  1. Posts/

Go 1.27:本当に大事なもの

·343 文字·2 分·
Go Release Runtime Pprof Json
アミット・デイブ
著者
アミット・デイブ
ソフトウェアエンジニアで、しっかりしたシステムを作るのが得意です。難しい問題をわかりやすくすることも得意です。好きなことは、分散システムクリーンアーキテクチャ、そしてオープンソースプロジェクトです。仕事以外では、山を歩くことや日本語の勉強、そしてstderrなどから学ぶことを楽しんでいます。
目次

断り書き:俺はN5レベルで日本語を勉強中です。間違いがあるかもしれないけど、頑張っています!

というわけで Go 1.27 が出た — 8月19日、1.26 から 6 ヶ月後、メトロノームどおりのタイミングだ — で、記事を何か見たなら、見出しはもう知っているはずだ:ジェネリックメソッド。10 年前から欲しいと言われていた機能が、ついに言語に入った。今週は Go 系のメディアがみんな playground でデモをやるだろうし、それはそれでいい。ただ、本番でサービスの挙動を変えるのは、リリースのその部分じゃない。

リリースノートはちゃんとあるし、細かいところまで書いてあるから、changelog を上から下までなぞることはしない。代わりにやりたいのはランキングだ:みんなが話題にする機能、今週ステージングで実際にオンにする価値のある機能、そして go.mod を上げる前に読んでおきたい、少数の静かな挙動の変更。

みんなが話題にしているやつ:ジェネリックメソッド
#

メソッドが自分自身の型パラメータを宣言できるようになった(go.dev/issue/77273)。リリースノートの例は math/rand/v2 で、*Rand にジェネリックな N メソッドが増えた — つまり r.N(10) がどの整数型でも動く。パッケージレベルの rand.N が前からそうだったのと同じだ。1.27 より前は、このメソッドはそもそも書けなかった。レシーバは型パラメータを持てたけど、メソッド自身は追加してはいけなかったからだ。

この制限は、うっかりミスでもなかった。もともとのジェネリクスの設計ドキュメントには「No parameterized methods」というセクションが丸ごとあって、そこに書かれた理由はインスタンス化の話だ:リンカがプログラム全体のコールグラフを必要とするし、リフレクションが壊れるし、残った選択肢はどれも JIT を出荷するのと同じことになった。だからこれは、ジェネリクスが 1.18 で入ってから 4 年以上、既知の穴として残っていた。じゃあ実装側で何が変わって、今できるようになったのか — 正直、俺は知らない。ノートには書いていないし、コミット履歴もまだ掘っていない。

ライブラリをこれ前提で作り直す前に、飲み込んでおくべき制限が一つ:インターフェースのメソッドは型パラメータを宣言できないし、ジェネリックメソッドはインターフェースのメソッドを実装できない。リリースノートにはっきりそう書いてあるし、ライブラリ作者が最初に聞く質問がこれだ。つまり interface { Decode[T any]() (T, error) } は定義できないし、既存のインターフェースをジェネリックメソッドで満たすこともできない — ジェネリックメソッドは、具象型(インターフェースじゃない、普通の型)が具象メソッドを呼ぶためのものだ。

つまり:API はきれいになる、呼び出し側もすっきりする、ライブラリをメンテしている人には本物の改善で、デプロイ済みのバイナリの挙動への影響はゼロ。ページャーは、この機能があることを知らない。

(小さい言語変更が 2 つ、ついでに一緒に入ってくる:struct リテラルのキーが、有効なフィールドセレクタなら何でも OK になった。それと、ジェネリック関数を一致する関数型の変数に代入するときにも型推論(type inference)が効くようになった。)

ページャーが気にするやつ:ゴルーチンリークのプロファイルが GA
#

これがこのリリースの本当の見出しだと、俺は思っている。goroutineleak プロファイル — 1.26 では experimental だった — が、runtime/pprof の正式なプロファイル型になった。pprof のハンドラをマウントしてあるところなら、/debug/pprof/goroutineleak で取れる。Uber の Vlad Saioc のコントリビューションで、どのくらいの規模のフリートに向けて作られたか、それだけでだいたい分かる。

理解しておく価値があるのは、その仕組みだ。普通のゴルーチンダンプは全部を見せてくる — 仕事を待って理由があって停まっている何千個のゴルーチンも含めて — で、その山の中からリークしたやつを手で探すのは、終わりの見えない作業だ。リークプロファイルは代わりに、GC の到達可能性(reachability)の解析を借りる:ゴルーチンがチャネルか sync のプリミティブでブロックしていて、そのブロック相手に他のどの生きているゴルーチンももう到達できないと GC が証明できるなら、そのゴルーチンは二度と起きられない — で、プロファイルはそれを、勘じゃなくて証明つきで報告する。

本番の症状は、この話のもう半分で、たぶん見たことがあるはずだ:デプロイのたびに少しずつ増えるヒープ、増える一方のゴルーチン数、誰もちゃんと理由を説明できない週 1 回の再起動 cron で回っているサービス。長く動く Go サービスで一番よくある静かな故障モードはリークしたゴルーチンだ、というのが俺の賭けで(はっきり言って賭けだ — 見せられる調査データは持っていない)、一つ一つがスタック全体と、そこから到達できる全部をピン留めする。それでいて、デフォルトのメトリクスのどれも、リークを作った select を指差してはくれない。

やることは短い:ステージングを 1 台アップグレードして、実トラフィックをかけたまましばらく置いて、それから /debug/pprof/goroutineleak を叩いて、返ってきたものを読む — いつもの pprof のルールは守ったままで。つまり、ハンドラは localhost にバインドしたポートに置いて、公開リスナーには絶対に載せない。空のプロファイルは、持っておく価値のある証明だ。空じゃなかったら、たぶん何年も出荷され続けてきたバグを、たった今見つけたということだ。

注意点を一つ、リリースノートそのままの内容で:解析は到達可能性ベースだから、リークしているのにまだ到達可能なゴルーチン — 永遠に停まっているけど、グローバル変数や、実行できる状態のゴルーチンのローカル変数から参照されている — は見逃すかもしれない。プロファイルに入るのは GC が証明できるものだけで、本物のリークの一部は、証明できる範囲の外にある。確実さが売りのツールとしては悪くないトレードだけど、自分のケースがどっちなのかは知っておいたほうがいい。

そもそも本番のコンテナからプロファイルを取り出す話の全体像は、コンテナ・デバッグの実践ワークフローにある。このエンドポイントは、あの道具箱にそのまま収まる。

頼んでもいない無料の性能
#

アップグレードそのもの以外のコストがゼロの、ランタイムレベルの改善が 2 つ。

アロケータがサイズ特化になった。 一部の小さいアロケーション — 80 バイト未満 — が、特化した malloc ルーチンを通るようになった。Go チームによれば最大 30% 安く、アロケーションの多い実際のプログラムでは全体で 1% くらいの改善で、代わりにバイナリサイズが約 60 KB 増える。これは彼らの数字だ。俺は自分でベンチマークしていないし、ニュース記事のためにやるつもりもない。GC 圧力の記事を読んだなら、これは同じレバーを反対側から動かした話だ — あの記事では、アロケーションの圧力でコレクタがついていけなくなるのを見た。ランタイムは今回、一回一回のアロケーションを安くした。オプトアウト(GOEXPERIMENT=nosizespecializedmalloc)はあるけど、もう 1.28 で削除される予定だから、これをあてにしたコードは書くな。

encoding/json が json/v2 の上で動くようになった。 既存のコードは、無変更のまま、新しい実装の上で v1 API を保って動く。ノートの言葉を訳すと:「Marshal の性能はだいたい前の実装と同じで、unmarshal の性能はかなり速い」。数字は出ていないし、俺も勝手に作らない。ただ、触っていないコードの unmarshal が速くなるというのは、unmarshal の重いサービスならカナリアデプロイをする理由になる類の変更だ。そのカナリアの前に確認しておくことが一つ:エラーメッセージの文字列が実装の間で違うから、JSON のエラー文字列をアサートしているテストは、直す必要が出てくる。何かおかしくなったときの逃げ道は GOEXPERIMENT=nojsonv2 だ。

トレースバックが一人前になる
#

go 1.27 を宣言しているモジュールでは、ゴルーチンのトレースバックにデフォルトで pprof ラベルが入るようになった。tracebacklabels という GODEBUG 自体は 1.26 で来ていた — 1.27 が変えたのは、そのつまみの存在をもう知らなくていいことだ。モジュールが 1.27 と言えば、ラベルは勝手に出てくる。すでに pprof.Do でゴルーチンにラベルを付けているなら — リクエスト ID、テナント、エンドポイント — 本番の panic が、死んでいくゴルーチンがどのリクエストのものだったかを、クラッシュ出力の中でやっと教えてくれる。

逆に気をつけたいのは、ログに何を入れるかだ。ラベルには、クラッシュログに入れたくないものが入るかもしれない — ユーザー ID、テナント名、そこに入れたもの何でも — で、クラッシュ出力は、アクセスログより多くの場所に行くことが多い。まさにそれが、オプトアウト(GODEBUG=tracebacklabels=0)がずっと残ると見られている理由だ。ラベルをトレースバックに入れるかどうかは、デフォルトをそのまま受け取るんじゃなくて、意図して決めたほうがいい判断だ。

go.mod を上げる前にチェックするやつ
#

どれも、ちゃんと設定されたサービスを壊しはしないはずだ。ただ、どれも、違いがグラフに出る前に読んでおきたい類の変更だ:

  • HTTP/1 の Response.Body が、Close のときに未読データを自動で読み捨てる(drain する)ようになった。小さめの上限つきで。だから接続は切られずに再利用される。普通のコードには純粋に良い話だけど、ノートは極端な設定 — MaxIdleConns=0 や、リクエストごとに新しい Client — だと逆に遅くなるかもしれないと書いている。接続を一度も再利用しないまま、drain のコストだけ払うからだ。それが自分のコードベースなら、直すべきは設定であって、リリースじゃない。
  • HTTP/2 サーバーが、ストリームをラウンドロビンでスケジュールする代わりに、RFC 9218 のクライアント優先度を尊重するようになった。ブラウザはこのヒントを送ってくるから、これは大体、欲しい挙動のはずだ。優先度の付け方がひどいクライアントがいたら、Server.DisableClientPriority で前の挙動に戻せる。
  • time パッケージのチャネルが、完全にアンバッファになった — もう元には戻らないasynctimerchan という GODEBUG は消えた。これで、1.23 で入った同期的な挙動が唯一の挙動になって、古いバッファ付きタイマーのセマンティクスにまだ依存しているコードの逃げ道は閉じた。
  • go test が、デフォルトで stdversion の vet チェックを走らせるようになった。コードが go ディレクティブの主張より新しい stdlib の機能を使っていると、テストが落ちる。go.mod のバージョンが適当なリポジトリでは CI のノイズが出るはずだけど、それはたまたま真実を言っているノイズだ。
  • Darwin は macOS 13 以上が必須になった。ほとんどのチームには CI イメージの話で、それ以上ではない。

おまけの山、正直なランキングつき
#

  • stdlib の uuid パッケージgo.dev/issue/62026)。v4(ランダム)と v7(時刻順)を生成して、標準フォーマットをパースする。ほとんどのサービスが github.com/google/uuid を実際に使っている用途はこれでカバーできるから、よくあるケースでは依存を外せる。v1、v5、namespace UUID を使っている人には、依存はまだ必要だ。
  • 明示的な encoding/json/v2jsontext パッケージ、オプトインするコード向け:もっと厳しいデフォルトで、不正な UTF-8 は拒否、重複キーも拒否。これは、v1 が最初から持っているべきだったと言ってもいい挙動だ。新しいコードは、たぶんここから始めるべきだ。
  • ML-DSA ポスト量子署名crypto/mldsa、FIPS 204)が x509 と TLS に組み込み済みで、鍵交換用の MLKEM1024 もある。コンプライアンスが厳しい業界にいるなら、あるいは — 鍵交換の半分について — harvest-now-decrypt-later(今収穫して後で復号する攻撃)を心配しているなら、関係がある。そうでなければ、いつか必要になったとき、そこにある。
  • 実験的なポータブル simd パッケージGOEXPERIMENT=simd の後ろにいる。この experimental は飾りじゃなくて、言葉どおりの意味だ。安定したら、そのとき、それだけで 1 本の記事になる。

今回、コンテナには何もない
#

GOMAXPROCSGC シリーズから来た読者へ:俺はまさにこれを探してノートを読んだ — で、何もない。GOMAXPROCS の変更なし、cgroup の仕事なし、GOGC も GOMEMLIMIT も変更なし、GC ペーシングの変更もなし。1.25 のコンテナ対応の話は、今も書いたとおりのままだし、あのシリーズの中身は全部 1.27 でも現役だ。ランタイムのつまみが動かなかったサイクルというのは、それ自体が小さな良い知らせだ。

俺が今週、実際にやること
#

  • ステージングをアップグレードして、実トラフィックをかけたままで /debug/pprof/goroutineleak を叩く — いつもどおり、localhost の pprof ポートで。このリリースで一番価値の高いアクションで、コストは curl 1 回だ。
  • json/v2 の切り替えをカナリアで流す:unmarshal の重いエンドポイントでタダで速くなる分を見ながら、テストスイートに JSON のエラー文字列をアサートしているものがないかも見張る。
  • アップグレード安全リストを、自分のコードベースと突き合わせる:リクエストごとの HTTP クライアント、古いタイマーチャネルの仮定、stdversion にこれから指摘される go.mod のバージョン。
  • トレースバックラベルの判断を、意図して決める。クラッシュログに入れたくないものでゴルーチンにラベルを付けているなら。

アップグレードの緊急度:普通の .0 リリースより高い。そして理由は、言語機能じゃない。ジェネリックメソッドは来四半期もまだそこにある。一方でリークプロファイラは、標準ツールチェーンの中で初めて、ゴルーチンリークを、悲しそうなグラフから推測させるんじゃなくて証明できるツールだ……で、俺が実際にスケジュールを動かすとしたら、そっちのためだ。そのためなら、.0 リリース 1 個分のリスクを取っていい。

関連記事

コンテナ・デバッグの実践ワークフロー
·913 文字·5 分
Go Debugging Kubernetes Observability
GC ポーズとレイテンシ:時間は実際どこに隠れているのか
·664 文字·4 分
Go Kubernetes Containers Garbage-Collection Latency
圧力下でのガベージコレクション
·851 文字·4 分
Go Kubernetes Containers Garbage-Collection Memory
GOMAXPROCS の謎を解く
·870 文字·5 分
Go Kubernetes Containers Scheduler Performance
AIエージェントのタスク管理、GitHub Issuesでやってみた
·678 文字·4 分
Ai Go Automation Github Devops