※画像はGemini 2.5 Flashで生成しました(プロンプトで格闘しましたが、カワウソのサイズがどうしても小さくならず、巨大なアニマルになりました)
TIG 真野です。
Go 1.25 リリース連載の最終回は、go vet に新しく追加された2つのアナライザー waitgroup と hostport について紹介します。
サマリ
Go 1.25では、go vet には以下の2つの新しいアナライザーが追加されます。
- waitgroup
sync.WaitGroup.Addの呼び出しミスを警告
- hostport
- IPv6アドレスを正しく扱えない可能性がある
fmt.Sprintf("%s:%d", host, port)を警告
- IPv6アドレスを正しく扱えない可能性がある
どちらも、今までのVetと同様のコンセプトである、Goプログラミングでよく見かける典型的な間違いを未然に防いでくれる系です。ありがたいですね。
sync.WaitGroup のよくある間違いを防ぐwaitgroupアナライザー
2016年に起票された cmd/vet: add check for sync.WaitGroup abuse #18022 で議論されてきました。この数年は動きが無かったのですが、 sync: add WaitGroup.Go #63796 で#18022 が進んだため、合わせてこちらのチェックが追加する運びになったようです。
どんなコードが検知される?
WaitGroup は非常に便利ですが、Add メソッドを呼び出すタイミングを間違えると、デッドロック(無限待機)、空振り(処理を待たずに終了)、実行時にパニックを引き起こす可能性があります。これを防ぐため、waitgroup アナライザーは、goroutineを起動した後に wg.Add(1) を呼び出してしまうパターンを検知します。
// vet-waitgroup/main.go |
このコードを実行すると、go vet ./... は以下のように警告を出力します。
$ go vet ./... |
このコードはなにが問題なのでしょうか? 。forループ内のgoroutineにある wg.Add(1) 実行前に wg.Wait() が実行されると、WaitGroup のカウンタは0のままなので、Waitは待たずに終了してしまいます。さらにその後に wg.Add(1) が呼び出されると、カウンタが負になるパニック panic: sync: negative WaitGroup counter が発生してしまう懸念があります。
実際に何度か動かすと、高確率でwg.Wait() が先に呼び出され、goroutineは空振り(か1つだけ起動)になってしまいました。私の環境ではpanicは引き起こせませんでしたが、やりたいことを実現できてないにも関わらず正常終了するため、むしろたちが悪い言えるでしょう。
$ go run main.go |
正しいコードは、goroutineを起動する前に Add を呼び出すことです。
// (修正後) |
この変更により go vet ./... が成功します。もちろん、goroutineも想定通り3本実行されます。
$ go run main.go |
また、そもそも1.25から追加された WaitGroup.Go() を積極的に使うと良いでしょう。解説は辻さんのsync記事を参照ください。
検知されない例
waitgroup アナライザーですが、かなり割り切った仕様になっています。例えば、以下のようにクロージャの最初にログ出力を追加すると、go vet は警告を出しません。
func main() { |
アナライザーはクロージャーのコメントや空行を除く、最初の1行目に、 wg.Add() が存在するかをチェックする仕様のためです。vet の思想として、偽陽性(本当は問題ないのに間違って検知してしまうこと)をゼロにすることを優先し、偽陰性(本当は問題があるのに、警告を出さないこと)が出てしまうことを許容する方針があることから、このような割り切った設計になっているようです。
例えば、以下のように、起動したgoroutine からさらに別のgoroutineを起動するケースも考えられ、必ずしも Add() がクロージャー内部で呼ばれていたとしても、悪いとは言い切れません。
func main() { |
Issue上でもヒューリスティックではあるけれど、クロージャーの最初の行に Add() が書かれているのはかなり上手く機能すると、コーパスのコードでの実験結果にも触れ、このような割り切りに至ったようです。
hostport アナライザー でIPv6対応へ
次に、hostport アナライザーです。これは、2018年に起票された cmd/vet: flag using %s:%d to construct network addresses #28308 で議論されていました。
開発者が意図せずIPv6に非対応なコードを書いてしまうのを防ぎ、Goで書かれたネットワークアプリケーションの堅牢性を高めることが目的です。同様に、strings.Split() の代わりに net.SplitHostPort() を使う必要性にプログラマーが気付くキッカケになることも期待しているとありました。こちらは静的解析が難しいけれど…、とのことです。
どんなコードが検知される?
net.Dial() や net.DialTimeout() に "host:port" 形式の文字列を引数に取る関数に、fmt.Sprintf("%s:%d", host, port) を使ってアドレスを渡しているコードを検知します。
// vet-hostport1/main.go |
このコードに対して go vet ./... を実行すると、以下のように警告され、net.JoinHostPort の使用を促されます。
$ go vet ./... |
fmt.Sprintf を使うと、host が ::1 (IPv6のループバックアドレス) のようなコロンを含むアドレスの場合、結果は ::1:8080 となってしまいます。これは不正なアドレス形式です。正しいIPv6のホストポート形式は、ホスト部分を [] で囲んだ [::1]:8080 です。
この問題を解決してくれるのが net.JoinHostPort です。
// (修正後) |
検知されない例
hostport アナライザーもまた、検知できるパターンには限界があります。例えば、フォーマット文字列が文字列リテラルでない場合、検知されません。
// 検知されない例 |
vet の多くのアナライザーは、コードの構造を静的に解析します。フォーマット文字列が実行時に決まるような動的なケースまで追跡するのは困難です。そのため、解析はコンパイル時に確定している文字列リテラル ("%s:%d") に限定されています。
さらにいうと、もっとシンプルな以下のコードも検知されません。
func main() { |
fmt.Sprintf() の結果を、 net.Dial() などの引数に渡していないためです。Vetとしては、ホスト・ポートを結合をしているのか否かを知りようがないためです。 net.Dial() の引数に渡している、fmt.Sprintf() を探すという、仕様のようです。
そのため、 http.ListenAndServe() にわたすアドレスや、 http.Get() に渡すURLも対象外となります。
func f1() { |
これは waitgroup アナライザーと同様、確実性を重視した vet らしい設計だなと思います。
サードパーティ製Linterで同様のチェックが無かったっけ..?
似たような機能がサードパーティ製のLinterで存在しなかったか探しました。
waitgrouphostport- 少し探しましたが、golangci-lint にも追加できる nosprintfhostport が、一番類似のチェックです
- とはいえ、
nosprintfhostportの方は、http://%s:%d/fooといったURLのフォーマットを拾うもので、少し役割が違うと思います。これについては、併用が良いでしょう
どちらも、現在有効にしているのであれば特に設定を変える必要は無いと思いますが、有識者の方のコメントをお待ちしています。
まとめ
Go 1.25で導入される go vet の新しいアナライザー waitgroup と hostport について紹介しました。
waitgroupは、sync.WaitGroup.Addの不適切な呼び出しを検知するhostportは、fmt.Sprintfによる不安全なホスト・ポート文字列の結合を検知する
上記は、Goの標準APIの改善や変化する技術的背景(IPv6の普及など)を反映したものです。公式ツールに組み込まれることで、Goで開発されたアプリケーションがより安全かつ、品質の底上げが期待できますね! Go! Go!