フューチャー技術ブログ

Go 1.25のgo vetのアップデート

unnamed.jpg

※画像はGemini 2.5 Flashで生成しました(プロンプトで格闘しましたが、カワウソのサイズがどうしても小さくならず、巨大なアニマルになりました)


TIG 真野です。

Go 1.25 リリース連載の最終回は、go vet に新しく追加された2つのアナライザー waitgrouphostport について紹介します。

サマリ

Go 1.25では、go vet には以下の2つの新しいアナライザーが追加されます

  • waitgroup
    • sync.WaitGroup.Add の呼び出しミスを警告
  • hostport
    • IPv6アドレスを正しく扱えない可能性がある fmt.Sprintf("%s:%d", host, port) を警告

どちらも、今までの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
package main

import (
"fmt"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func(i int) {
// このAddの呼び出しは間違っている!
wg.Add(1) // ✗ vet: misplaced call to WaitGroup.Add
defer wg.Done()
fmt.Printf("goroutine %d running\n", i)
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait()
fmt.Println("all goroutines finished")
}

このコードを実行すると、go vet ./... は以下のように警告を出力します。

$ go vet ./...
# go1.25vet/vet-waitgroup
./main.go:15:10: WaitGroup.Add called from inside new goroutine

このコードはなにが問題なのでしょうか? 。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
all goroutines finished

$ go run main.go
all goroutines finished
goroutine 0 running

$ go run main.go
all goroutines finished

正しいコードは、goroutineを起動する前に Add を呼び出すことです。

// (修正後)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
// goroutineを起動する前にAddを呼ぶ
wg.Add(1) // ✓ OK
go func(i int) {
defer wg.Done()
fmt.Printf("goroutine %d running\n", i)
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait()
fmt.Println("all goroutines finished")
}

この変更により go vet ./... が成功します。もちろん、goroutineも想定通り3本実行されます。

$ go run main.go
goroutine 2 running
goroutine 0 running
goroutine 1 running
all goroutines finished

また、そもそも1.25から追加された WaitGroup.Go() を積極的に使うと良いでしょう。解説は辻さんのsync記事を参照ください。

検知されない例

waitgroup アナライザーですが、かなり割り切った仕様になっています。例えば、以下のようにクロージャの最初にログ出力を追加すると、go vet は警告を出しません。

func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func(i int) {
fmt.Println("goroutine start") // 標準出力を追加
wg.Add(1) // 検知されない!!

defer wg.Done()
fmt.Printf("goroutine %d running\n", i)
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait()
fmt.Println("all goroutines finished")
}

アナライザーはクロージャーのコメントや空行を除く、最初の1行目に、 wg.Add() が存在するかをチェックする仕様のためです。vet の思想として、偽陽性(本当は問題ないのに間違って検知してしまうこと)をゼロにすることを優先し、偽陰性(本当は問題があるのに、警告を出さないこと)が出てしまうことを許容する方針があることから、このような割り切った設計になっているようです。

例えば、以下のように、起動したgoroutine からさらに別のgoroutineを起動するケースも考えられ、必ずしも Add() がクロージャー内部で呼ばれていたとしても、悪いとは言い切れません。

func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Printf("outer goroutine running\n")

wg.Add(1)
go func() { // さらに呼ぶ!!
defer wg.Done()
fmt.Printf("inner goroutine running\n")
}()
}()

wg.Wait()
fmt.Println("all goroutines finished")
}

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
package main

import (
"fmt"
"net"
)

func main() {
host := "::1" // IPv6のループバックアドレス
port := 8080

// この形式の結合はIPv6アドレスで問題を起こす
addr := fmt.Sprintf("%s:%d", host, port) // ✗ vet: call of fmt.Sprintf forms a host:port string

// 本来は "[: :1]:8080" となるべきだが、"::1:8080" になってしまう
fmt.Println(addr)

_, err := net.Dial("tcp", addr)
if err != nil {
fmt.Println(err)
}
}

このコードに対して go vet ./... を実行すると、以下のように警告され、net.JoinHostPort の使用を促されます。

$ go vet ./...
# go1.25vet/vet-hostport1
./main.go:13:22: address format "%s:%d" does not work with IPv6 (passed to net.Dial at L18)

fmt.Sprintf を使うと、host::1 (IPv6のループバックアドレス) のようなコロンを含むアドレスの場合、結果は ::1:8080 となってしまいます。これは不正なアドレス形式です。正しいIPv6のホストポート形式は、ホスト部分を [] で囲んだ [::1]:8080 です。

この問題を解決してくれるのが net.JoinHostPort です。

// (修正後)
import (
"fmt"
"net"
"strconv"
)

func main() {
host := "::1"
port := 8080

// net.JoinHostPort を使って安全に結合する
addr := net.JoinHostPort(host, strconv.Itoa(port)) // ✓ OK

fmt.Println(addr) // "[::1]:8080"

// ...
}

検知されない例

hostport アナライザーもまた、検知できるパターンには限界があります。例えば、フォーマット文字列が文字列リテラルでない場合、検知されません。

// 検知されない例
func main() {
host := "::1"
port := 8080

format := "%s:%d" // フォーマット文字列を変数に入れた場合
addr := fmt.Sprintf(format, host, port) // ✗ vetでは検知されない

fmt.Println(addr)

_, err := net.Dial("tcp", addr)
if err != nil {
fmt.Println(err)
}
}

vet の多くのアナライザーは、コードの構造を静的に解析します。フォーマット文字列が実行時に決まるような動的なケースまで追跡するのは困難です。そのため、解析はコンパイル時に確定している文字列リテラル ("%s:%d") に限定されています。

さらにいうと、もっとシンプルな以下のコードも検知されません。

func main() {
host := "::1"
port := 8080

addr := fmt.Sprintf("%s:%d", host, port) // ✗ vetでは検知されない

fmt.Println(addr)
}

fmt.Sprintf() の結果を、 net.Dial() などの引数に渡していないためです。Vetとしては、ホスト・ポートを結合をしているのか否かを知りようがないためです。 net.Dial() の引数に渡している、fmt.Sprintf() を探すという、仕様のようです。

そのため、 http.ListenAndServe() にわたすアドレスや、 http.Get() に渡すURLも対象外となります。

Vetの対象外
func f1() {
host := "::1"
port := 8080

addr := fmt.Sprintf("%s:%d", host, port) // ✗ vetでは検知されない
fmt.Println(addr)

http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello, World!")
})
if err := http.ListenAndServe(addr, nil); err != nil {
fmt.Println(err)
}
}

func f2() {
host := "::1"
port := 8080

targetURL := fmt.Sprintf("http://%s:%d/hello", host, port) // ✗ vetでは検知されない
fmt.Println(targetURL)

resp, err := http.Get(targetURL)
// ... 省略 ...
}

これは waitgroup アナライザーと同様、確実性を重視した vet らしい設計だなと思います。

サードパーティ製Linterで同様のチェックが無かったっけ..?

似たような機能がサードパーティ製のLinterで存在しなかったか探しました。

  • waitgroup
    • staticcheckSA2000 という類似のチェックがあります。テストコード を見た限りほぼ同等なチェックな気がします
  • hostport
    • 少し探しましたが、golangci-lint にも追加できる nosprintfhostport が、一番類似のチェックです
    • とはいえ、 nosprintfhostport の方は、http://%s:%d/foo といったURLのフォーマットを拾うもので、少し役割が違うと思います。これについては、併用が良いでしょう

どちらも、現在有効にしているのであれば特に設定を変える必要は無いと思いますが、有識者の方のコメントをお待ちしています。

まとめ

Go 1.25で導入される go vet の新しいアナライザー waitgrouphostport について紹介しました。

  • waitgroup は、sync.WaitGroup.Add の不適切な呼び出しを検知する
  • hostport は、fmt.Sprintf による不安全なホスト・ポート文字列の結合を検知する

上記は、Goの標準APIの改善や変化する技術的背景(IPv6の普及など)を反映したものです。公式ツールに組み込まれることで、Goで開発されたアプリケーションがより安全かつ、品質の底上げが期待できますね! Go! Go!