HN 日本語サマリー

← 一覧へ戻る
プログラミング

Goroutineリークプロファイル

Goroutine Leak Profiles (go.dev)

54 pointsby torutofu6 コメント

要約

Goの並行処理機能は強力ですが、デバッグが難しいGoroutineのリークを引き起こすことがあります。Go 1.27で導入された新しいGoroutineリークプロファイラーは、本番環境でもこれらのリークを特定するのに役立ちます。この記事では、その使い方と検出可能なリークの例、および実装の仕組みについて解説します。

全文翻訳

Goroutineリークプロファイル

Goの並行処理機能は強力で使いやすいですが、その使いやすさゆえに、経験豊富な開発者でさえ間違いを犯すことがあります。幸いなことに、Goのエコシステムには、レース検出器のようなデバッグに役立つツールが用意されていますが、既存のツールでも、この記事のトピックであるGoroutineリークのような一部の並行処理バグを見逃すことがあります。

Goroutineは、チャネル、ロック、ウェイトグループなどの共有された並行処理プリミティブを介して同期したり情報を交換したりします。通信中、Goroutineはこれらのプリミティブでしばしばブロックします。つまり、何らかの条件が満たされるまで待機します。一般的な例としては、ロックされているミューテックスの取得を待つ、チャネルでメッセージを受信する、などが挙げられます。Goroutineは、ネットワークソケットやファイルからの読み取りのようなオペレーティングシステム操作でブロックすることもできます。

Goroutineがブロックされており、そのブロックを解除するために必要な条件が決して満たされない場合、そのGoroutineはリークしていると見なすことができます。時間の経過とともに、リークしたGoroutineが蓄積すると、リークしたGoroutine自体やそれらが参照するメモリによる過剰なメモリ使用量、およびガベージコレクタによるCPU使用量(特にGOMEMLIMITが使用されている場合)によってパフォーマンスが低下します。

Goroutineリークの検出は、しばしば非常に困難です。単体テストにおいては、最も重要な進歩はオープンソースライブラリgoleakです。これは個々のテストをインストゥルメントして、テスト終了後に終了していないGoroutineがあれば警告を発することができます。同様に、Go 1.25では標準ライブラリにsynctestパッケージが導入されました。これは、並行処理コードの単体テストの品質を大幅に向上させ、開発者が並行イベントの順序をより細かく制御できるようにすることで、再現が難しいシナリオを確実にテストできるようにします。

残念ながら、これらのアプローチのいずれも、特に大規模な本番システムでのGoroutineリークをチェックすることはできません。本番システムは、テストでは考慮されないような動作をする可能性があります。Goroutineプロファイルは、多すぎるGoroutineをブロックしている操作をチェックしたり、成長トレンドを分析したりするための基本的な方法です。しかし、Goroutineプロファイルは、リークしているGoroutineと、設計上意図的に多数一時的にブロックされているGoroutine(例えば、マイクロサービスのトラフィック増加によるもの)を区別することはできません。同様に、数が少ないリークは何年も検出されずに見過ごされる可能性があります。

Go 1.27では、Goroutineリークプロファイラーが導入されました。これは、本番システムを含む実行中のGoプログラムでGoroutineリークを見つけるための、柔軟で軽量なメカニズムです。以前のアプローチとは異なり、人間の分析を必要とするこれらのアプローチとは異なり、このメカニズムは正確であり、誤検知はほとんど、あるいは全く生成しません。トレードオフとして、Goroutineリークのサブセットに限定されます。つまり、チャネルまたはsyncパッケージのプリミティブで永続的にブロックされているGoroutineです。幸いなことに、例で示すように、これはすでにGoroutineリークの非常に大きなサブセットをカバーしています。

次のセクションでは、この機能の使用方法、検出可能なリークの追加例、および基盤となる実装とトレードオフについて説明します。

例:並行ワーカー

作業項目を並行して処理する関数を考えてみましょう。

type result struct {
    res workResult
    err error
}

func processWorkItems(ws []workItem) ([]workResult, error) {
    // 作業項目を並列処理し、結果をchに集約します。
    ch := make(chan result)
    for _, w := range ws {
        go func() {
            res, err := processWorkItem(w)
            ch <- result{res, err}
        }()
    }

    // chから結果を収集するか、エラーが見つかった場合はエラーを返します。
    var results []workResult
    for range len(ws) {
        r := <-ch
        if r.err != nil {
            // この早期リターンはGoroutineリークを引き起こす可能性があります。
            return nil, r.err
        }
        results = append(results, r.res)
    }
    return results, nil
}

chはバッファなしチャネルであるため、各ワーカーGoroutineは、メインGoroutineがチャネルから受信するまで送信時にブロックされます。processWorkItemsがエラーのために早期に返された場合、受信ループは終了し、残りのすべての送信者Goroutineは永遠にブロックされます。

この例は、Uberの本番サービスを含む実際のGoプログラムで発見される一般的な間違いを象徴しています。新しいGoroutineリークプロファイラーを使用して、これらのリークをどのように見つけるかを見てみましょう。

Goroutineリークプロファイラーによるデバッグ

プロファイルは、runtime/pprofパッケージからgoroutineleakプロファイルタイプとして、またはnet/http/pprofパッケージによって定義されたプロファイルハンドラをインストールすることによって利用できます。サービスにnet/http/pprofが既に設定されている場合は、他に何もする必要はありません!プロファイルは、ハンドラがインストールされているホストとポートで、/debug/pprof/goroutineleakエンドポイントで自動的に収集可能になります。

この並行処理バグをコンテキストに入れ、net/http/pprofパッケージを設定しましょう。これにより、自分で試すことができます!

package main

import (
    "errors"
    "log"
    "net/http"
    _ "net/http/pprof"
    "time"
)

type workItem int
type workResult int

func processWorkItem(w workItem) (workResult, error) {
    time.Sleep(10 * time.Millisecond)
    if w == 5 {
        return 0, errors.New("simulated error")
    }
    return workResult(w * 2), nil
}

type result struct {
    res workResult
    err error
}

func processWorkItems(ws []workItem) ([]workResult, error) {
    ch := make(chan result)
    for _, w := range ws {
        go func() {
            res, err := processWorkItem(w)
            ch <- result{res, err}
        }()
    }

    var results []workResult
    for range len(ws) {
        r := <-ch
        if r.err != nil {
            return nil, r.err
        }
        results = append(results, r.res)
    }
    return results, nil
}

func main() {
    // pprofサーバーを起動
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // リークを繰り返しトリガー
    for {
        items := []workItem{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
        _, err := processWorkItems(items)
        if err != nil {
            log.Printf("Error processing items: %v", err)
        }

        time.Sleep(time.Second)
    }
}

上記のプログラムをビルドし、実行します。

$ go build -o leaky
$ ./leaky
プロファイルの収集

プログラムがリークを蓄積し始めるのに時間はかかりません。その後、http://localhost:6060/debug/pprof でWeb UIを使用して確認できます。

または、curlを使用してGoroutineリークプロファイルを収集し、go tool pprofで検査することもできます。

$ curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof
$ go tool pprof leak.prof
Type: goroutineleak
Time: 2026-03-01 13:19:49 UTC
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list processWorkItems
Total: 116
ROUTINE ======================== main.processWorkItems.func1 in .../main.go
         0        116 (flat, cum)   100% of Total
         .          .     31:           go func() {
         .          .     32:                   res, err := processWorkItem(w)
         .        116     33:                   ch <- result{res, err}
         .          .     34:           }()

プロファイルは、ch <- result{res, err}(33行目)でリークしたGoroutineを示し、原因となっている操作を特定します。注目すべきは、プログラムが実行されている時間が長いほど、リークしたGoroutineの数が増加することです。

リークの修正

このリークは、chにバッファを与えることで簡単に修正できます。

ch := make(chan result, len(ws))

これにより、processWorkItemsの早期リターンが発生した場合でも、すべての作業項目Goroutineがブロックせずにメッセージを送信できるようになります。

このセクションで、より現実的な例をいくつか紹介します。

実装

このセクションは、Goroutineリークプロファイラーの内部でリーク検出がどのように機能するかに関心のある方向けです。パフォーマンスオーバーヘッドと制限に関する詳細は、このセクションに進んでください。

コアコンセプト

最初の観察から始めましょう。もしGoroutineが、他のGoroutineがアクセスできない(この場合、メモリ内の参照を介して)並行処理プリミティブでブロックされている場合、それは明らかにリークしています。これはすでに強力な手がかりであり、Goroutineがリークしていない場合の定義に一般化できます。このプロパティをライブネスと呼びます。ライブネスを帰納的なプロパティとして正式に定義します。

Goroutineはライブであると定義されます。

  1. 並行処理プリミティブによってブロックされていない場合、または
  2. それをブロックしている少なくとも1つの並行処理プリミティブが、別のライブなGoroutineによって参照されている場合。

自明なケースでは、ブロックされていないGoroutineは明らかにリークしていません。帰納的なケースでは、リークしていないGoroutineは、参照している並行処理プリミティブを使用して、それらのプリミティブによってブロックされている他のGoroutineを最終的に解除できるという根本的な仮定があります。

すべてのライブなGoroutineを見つけるために、明らかにライブなブロックされていないGoroutineから開始し、それらが保持している参照(ローカル変数などを介して)をトレースして、アクセスできる並行処理プリミティブを見つけます。次に、それらのプリミティブでブロックされているGoroutineを段階的に含めてライブと見なし、追加のライブなGoroutineが発見されなくなるまでプロセスを繰り返します。

幸いなことに、Goランタイムはすでにガベージコレクタ(GC)を通じてメモリの到達可能性を計算しているため、次のステップはGCを目的のために適応させることです。次の図で2つのGCを簡単に比較できます。

GCの完全なオーバーホールは必要ありません。Goランタイムは、並行トリカラーマークアンドスイープガベージコレクタ(現在はGreen Teaバリアントを使用!)を使用しているため、その動作はすでに私たちの目標とよく一致しています。必要な変更はわずかです。

  1. 初期段階では、通常のGCはすべてのGoroutine(およびグローバル変数)を到達可能としてマークし、それらが決してガベージと見なされないようにします。つまり、それらはマークルートです。これを、ライブであることが保証されているブロックされていないGoroutineのみを含めるように変更します。
  2. 次にマークフェーズが続きます。GCはマークルートによって(推移的に)参照されるオブジェクトをトレースし、それらを使用可能なメモリとしてマークします。このフェーズを直接変更しませんが、ステップ1の変更により、GCがライブなGoroutineによって参照されるメモリのみをマークすることが暗黙的に保証されます。
  3. マークフェーズは、ステップ1でマークルートとして含まれていないすべてのブロックされたGoroutineを検査することによって完了します。Goroutineがステップ2でマークされた少なくとも1つの並行処理プリミティブによってブロックされている場合、それはマークルートとして追加され、GCはステップ2からマークフェーズを再開します。これはライブネスの定義における帰納ステップと一致します。
  4. すべてのライブなGoroutineが発見されると、マークルートとして追加されなかったGoroutineは、そのステータスがリーク済みとして設定されます。
  5. マークフェーズは、リークしたすべてのGoroutineをマークルートとして追加して、最後にもう一度再開され、GCは通常の実行中にマークしたであろうすべてのメモリをマークできるようになります。

GCサイクルが完了すると、Goroutineリークプロファイラーは通常のGoroutineプロファイルのように機能し、厳密にリークしたGoroutineをフィルタリングします。

制限事項

上記の例は、Goroutineリークプロファイルの有用性を示しています。それにもかかわらず、ガベージコレクタにはリークを見逃す可能性のあるいくつかの制限があります。

  1. メモリのオーバーリーチ:並行処理プリミティブがグローバル変数または実行可能なGoroutineを通じて一貫して到達可能な場合、その並行処理プリミティブが将来使用されない場合でも、それにブロックされているGoroutineはリークしているとは報告されません。

    これは、並行処理プリミティブ参照へのアクセスをより適切に管理し、それらのライフサイクルをより明確に区別することによって軽減できます。

  2. 非標準的なブロック:正しさのために、Goroutineリーク検出は、チャネルの送受信操作(nilチャネル経由のものを含む)、ブロックするselectステートメント(defaultケースがない場合)、ケースのないselectステートメント、およびsyncパッケージのメンバー(特にMutex、RWMutex、WaitGroup、Cond)を含む、Goのファーストクラスの並行処理プリミティブに厳密に限定されます。

    ファイルやネットワークI/O、直接のシステムコールなど、他の理由でブロックされているGoroutineは、リークしているとは見なされません。これは、それらが基盤となる実装にそれらのプリミティブに依存しない限り、カスタムでユーザー定義の並行処理(スピンロックなど)にも同様に適用されます。

  3. 非決定性:リークは発生後にのみ検出でき、それ以外に予測することはできないため、不安定なプログラムでのリークの再現と診断は引き続き課題です。最良の結果を得るために、Goroutineリークプロファイルをさまざまなレイヤー(本番環境まで)で使用し、goleakとsynctestでインストゥルメントされた包括的なテストスイートと組み合わせるアプローチを推奨します。

パフォーマンスへの影響

Goroutineリーク検出はパフォーマンスへの影響を最小限に抑えるように慎重に設計されていますが、それでもいくつかのコストがかかります。

メモリオーバーヘッドは無視できる程度で、ブックキーピングに必要な小さな追加のみに限られますが、Goroutineリーク検出は通常のGCよりも遅くなる可能性があります。これは、「デイジーチェーン」と呼ぶ病的なケースで最もよく説明できます:[画像]このリークのない例では、実行可能なGoroutine G₀はプリミティブP₁への参照を持ち、G₁をブロックし、以下同様です。

これは、一部のPᵢ₊₁のライブネスを証明するにはPᵢのライブネスを証明する必要があることを意味し、2つのコストが発生します。

  1. GCマークフェーズは、Goroutineがスキャンされる順序に対して事実上シリアライズされます。なぜなら、Pᵢから到達可能なすべてのメモリは、Pᵢ₊₁がルートとして追加される前にマークされる必要があるからです。
  2. 検査は現在、各マークラウンドの最後にすべてのブロックされたGoroutineをチェックしており、1つのGCサイクルで最悪O(n²)ステップになり、nはGoroutineの総数です。

2番目の点は最終的に最適化される可能性がありますが、最初の点はリーク検出の固有の制限であり、回避することはできません。

それにもかかわらず、読者には、ランタイムフラグを介して明示的に設定されない限り、GCはユーザーコードと並行して動作することを思い出します。さらに、Goroutineリークがいつかの時点で観測可能であれば、同じ実行中の将来のいつでも観測可能になります。したがって、定期的なプロファイリングインフラストラクチャは、リーク検出機能にほとんどコストをかけずに、例えば4時間ごとにプロファイリング頻度を調整できます。

謝辞

Goroutineリーク検出は、Aarhus大学、ワシントン大学セントルイス校、およびUberの研究協力の結果であり、“Dynamic Partial Deadlock Detection and Recovery via Garbage Collection”(Saioc et al., ASPLOS 2025)で発表されました。

学術的なプロトタイプから実際のGo機能への移行は、GoogleのGoチームのMichael Knyszek氏とMichael Pratt氏、およびPJ Malloy氏(@thepudds)の指導により可能になりました。

追加例

以下は、産業規模のコードベースやオープンソースプロジェクトで観察された、リークにつながるコーディングパターンです。複雑度の昇順で示します。

Go Playgroundでこれらの例をすぐに試したり、独自のリークを実験したりできます。

例:二重送信

最も単純なリークの一部は、チャネルで予期されるよりも多くのメッセージが送信された場合に発生します。以下では、Goroutineはバッファなしチャネルを介してメインGoroutineに1つのメッセージを送信することが期待されています。しかし、エラーケースでの送信操作の後にreturnステートメントが欠落しています。したがって、エラーが発生するたびに、送信者は2つのメッセージを送信しようとし、これがリークを引き起こします。

func DoubleSend() {
    ch := make(chan any)
    go func(err error) {
        if err != nil {
            // エラーの場合、nilを送信します。
            ch <- nil
            // Returnステートメントが欠落しています。
        }
        // それ以外の場合は、通常の動作を続行します。
        // この送信はまだ実行され、エラーケースでリークを引き起こします。
        ch <- struct{}{}
    }(fmt.Errorf("error"))
    // メッセージを1つだけ受信します。
    <-ch
}

プロファイルは欠落しているreturnを原因として明示的に強調しませんが、少なくともリークしている送信操作を強調することで、問題のある関数に誘導します。

(pprof) list DoubleSend
Total: 1
ROUTINE ======================== main.DoubleSend.func1 in .../main.go
         0          1 (flat, cum)   100% of Total
         .          .    118:   go func(err error) {
         .          .    119:           if err != nil {
         .          .    121:                   ch <- nil
         .          .    123:           }
         .          1    126:           ch <- struct{}{}
         .          .    127:   }(fmt.Errorf("error"))
         .          .    129:   <-ch

このリークは、エラーケースの送信操作の後にreturnステートメントを追加するだけで簡単に修正できます。

例:早期リターン

逆の状況も同様に一般的です。受信側が一部の制御フローパスで通信を省略し、事実上、導入例の単純化されたバージョンとなっています。

// Incoming error simulates an error produced internally.
func EarlyReturn(err error) {
    ch := make(chan any)

    // ワーカーGoroutineを作成します。
    go func() {
        // チャネルに何かを送信します。
        // 親Goroutineが早期に終了するとリークします。
        ch <- struct{}{}
    }()

    if err != nil {
        // エラーの場合、親Goroutineは早期に終了します。
        // 送信者がリークします。
        return
    }

    // 受信はエラーがない場合にのみ実行されます。
    <-ch
}

Goroutineリークはプロファイルによって明らかにされます。

ROUTINE ======================== main.EarlyReturn.func1 in .../main.go
         0          1 (flat, cum)   100% of Total
         .          .    140:   go func() {
         .          1    143:           ch <- struct{}{}
         .          .    144:   }()
         .          .    145:   
         .          .    146:   if err != nil {

リークは、chにサイズ1のバッファを与えることで対処できます。

例:タイムアウト

上記の早期リターンパターンのバリエーションには、コンテキストと非決定的な選択(selectステートメント)が含まれます。

func Timeout(ctx context.Context) {
    // ワーカーと親スレッドを調整するために
    // バッファなしチャネルが使用されます
    ch := make(chan any)

    // ワーカーGoroutineを作成します
    go func() {
        // いくつかの作業を実行してから親スレッドにシグナルを送ります。
        ch <- struct{}{}
    }()

    // ワーカーからのメッセージ、またはコンテキストが
    // キャンセルまたはタイムアウトされるのを待ちます。
    select {
    case <-ch: // ワーカーからのメッセージを受信
    case <-ctx.Done():
        // 送信者が永遠にブロックされるため、送信者がリークします。
        // チャネルでの将来のランデブーがないため。
    }
}

コンテキストが送信者が親と同期する前にキャンセルされた場合、送信者はリークします。

(pprof) list Timeout
Total: 10
ROUTINE ======================== main.Timeout.func1.1 in .../main.go
         0         10 (flat, cum)   100% of Total
         .          .    198:           go func() {
         .         10    201:                   ch <- struct{}{}
         .          .    202:           }()

前の例と同様に、修正はチャネルにサイズ1のバッファを与えることです。

例:チャネルのクローズなしでの範囲反復

rangeを使用してチャネルを反復処理すると、ループ内でチャネルから値を繰り返し受信できます。チャネルが閉じられ、チャネルのバッファにエンキューされたすべての値が受信されると、ループは終了します。

重要なのは、チャネルが決して閉じられない場合、rangeループは実行中のGoroutineを永遠にブロックすることです。close操作を省略するのは一般的な間違いです。以下はその例です。

// アイテムのリストとワーカーの数を受け取ります。
func noCloseRange(list []any, workers int) {
    // 作業項目を配布するチャネルを作成します。
    ch := make(chan any)

    // ワーカーGoroutineを作成します。
    for i := 0; i < workers; i++ {
        go func() {
            // 各ワーカーはチャネルからアイテムを取得し、
            // それを処理します。
            for item := range ch {
                // 各アイテムを処理します
                _ = item
            }
        }()
    }

    // チャネルを使用してワーカーにアイテムをキューに入れます。
    for _, item := range list {
        // 親は、workers == 0の場合、
        // またはすべてのワーカーがパニックしたがパニックが回復された場合に、アイテムを送信してリークします。
        ch <- item
    }
    // それ以外の場合、チャネルは決して閉じられないため、ワーカーは
    // アイテムがなくなるとリークします。
}

...
go noCloseRange([]any{1, 2, 3}, 3) // すべての3つのワーカーがリークします

このようなプログラムのGoroutineリークプロファイルには、以下が含まれます。

Type: goroutineleak
(pprof) list noCloseRange.func1
Total: 4
ROUTINE ======================== main.noCloseRange.func1 in .../main.go
         0          3 (flat, cum) 75.00% of Total
         .          .     82:           go func() {
         .          3     84:                   for item := range ch {
         .          .     86:                           _ = item
         .          .     87:                   }
         .          .     88:           }()

リークの原因を十分に示唆するrange ch操作でブロックされている3つのワーカーが見られます。リークは、チャネルが閉じられた後にすべてのメッセージが送信されたことを単純に追加することで対処できます。

    for _, item := range list {
        ch <- item
    }
    // すべてのアイテムが送信されました。ここで閉じるのは安全です。
    close(ch)

ボーナス! 鋭い読者は、この例で別の潜在的なリークに気づいたかもしれません。ワーカー数が誤ってゼロに設定された場合、親送信者がリークします。

go noCloseRange([]any{1, 2, 3}, 0) // 送信者が0ワーカーでリークします

これもプロファイルでキャプチャされます。

(pprof) list noCloseRange$
Total: 4
ROUTINE ======================== main.noCloseRange in .../main.go
         0          1 (flat, cum) 25.00% of Total
         .          .     76:func noCloseRange(list []any, workers int) {
...
         .          .     92:   for _, item := range list {
         .          1     95:           ch <- item
         .          .     96:   }

現実的な本番システムではworkers > 0が仮定されるかもしれませんが、Goroutineリークプロファイルは、保守的なworkers <= 0チェックなしで、偶然の違反を暗黙的に監視するために使用できます。

例:メソッド契約違反

これまでのパターンは、レキシカルスコープにおいて比較的制約されていました。しかし、機能が関数、メソッド、パッケージ全体に分散され、インターフェースによって実装が難読化されると、手動でリークを検出する難易度は劇的に増加します。

このようなケースは、このセクションで、2つのチャネルフィールドchとdoneを埋め込み、StartメソッドでループするGoroutineを作成するカスタムworkerタイプで例示されています。このGoroutineはselectステートメントで両方のチャネルから読み取ります。そのGoroutineは、Stopメソッドによって閉じられるdoneチャネルを介してメッセージを受信することによってのみ終了できます。

Startメソッドは何度でも呼び出すことができますが、少なくとも一度呼び出された場合は、最終的にStopを呼び出す必要があります。

結果として、StartとStopは、メソッドが呼び出されるべき順序を規定する暗黙的な契約を形成します。この契約を破ると、望ましくない動作、この場合はGoroutineリークにつながる可能性があります。

func MethodContractViolation() {
    items := make([]any, 10)
    // 新しいワーカーを作成します
    w := NewWorker()

    // ワーカーを開始します
    w.Start()

    // ワーカーを操作します
    for _, item := range items {
        w.AddToQueue(item)
    }
    // 'Stop'を呼び出さずに終了します。
}

type worker struct {
    ch   chan any
    done chan any
}

type Worker interface {
    Start()
    Stop()
    AddToQueue(item any)
}

func NewWorker() Worker {
    return &worker{
        ch:   make(chan any),
        done: make(chan any),
    }
}

// Startは、キューにプッシュされたアイテムを抽出するバックグラウンドGoroutineを生成します。
func (w *worker) Start() {
    go func() {
        for {
            select {
            case <-w.ch: // 通常のワークフロー
            case <-w.done:
                return // シャットダウン
            }
        }
    }()
}

func (w *worker) Stop() {
    // Startによって作成されたGoroutineを終了できるようにします
    close(w.done)
}

func (w *worker) AddToQueue(item any) {
    w.ch <- item
}

この問題は、カスタムタイプがインターフェースとしてのみエクスポートされる場合、この場合はわかりにくいWorkerタイプを介して、さらに悪化します。クライアントは基盤となる実装を認識していない可能性があり、結果として、気づかずに暗黙的な契約を破る可能性があります。

幸いなことに、Goroutineリークプロファイルを要求することで、欠陥を明らかにできます。

(pprof) list Start
Total: 1
ROUTINE ======================== main.(*worker).Start.func1 in .../main.go
         0          1 (flat, cum)   100% of Total
         .          .    266:   go func() {
         .          .    267:           for {
         .          1    268:                   select {
         .          .    269:                   case <-w.ch:
         .          .    270:                   case <-w.done:
         .          .    271:                           return

当然、修正はStart呼び出しへの追跡とStopの呼び出しの追加を含みます。

例(Cockroach):アンロックの欠落

以下の例は、CockroachDBから取られたものです。ロックの取得と解放をループで行いますが、breakステートメントを実行する前にロックを解除し忘れるというものです。

type Gossip struct {
    mu     sync.Mutex
    closed bool
}

func (g *Gossip) bootstrap() {
    for {
        g.mu.Lock()
        if g.closed {
            // g.mu.Unlockが欠落しています
            break
        }
        g.mu.Unlock()
    }
}

func Cockroach584() {
    g := &Gossip{
        closed: true,
    }
    // ...
    g.bootstrap()
    g.bootstrap() // リークを引き起こします
}

この場合、ロックの取得に失敗するとGoroutineがリークします。

(pprof) list Gossip
Total: 1
ROUTINE ======================== main.(*Gossip).bootstrap in .../main.go
         0          1 (flat, cum)   100% of Total
         .          .    165:func (g *Gossip) bootstrap() {
         .          .    166:   for {
         .          1    167:           g.mu.Lock()
         .          .    168:           if g.closed {
         .          .    170:                   break
         .          .    171:           }
         .          .    172:           g.mu.Unlock()

breakの前にUnlock呼び出しを追加することで問題が解決します。

例(etcd):予期しないチャネル操作順序

etcdで見つかったこの例は、チャネル操作間の予期しない順序がGoroutineリークにつながる方法を示しています。

type node struct {
    status chan chan struct{}
    stop   chan struct{}
    done   chan struct{}
}

func (n *node) Status() struct{} {
    c := make(chan struct{})
    n.status <- c
    return <-c
}

func (n *node) run() {
    for {
        select {
        case c := <-n.status:
            c <- struct{}{}
        case <-n.stop:
            close(n.done)
            return
        }
    }
}

func (n *node) Stop() {
    select {
    case n.stop <- struct{}{}:
    case <-n.done:
        return
    }
    <-n.done
}

func Etcd6857() {
    n := &node{
        status: make(chan chan struct{}),
        stop:   make(chan struct{}),
        done:   make(chan struct{}),
    }
    go n.run()
    go n.Status()
    go n.Stop()
}

runメソッドは、statusチャネル(Statusメソッドの呼び出しによって送信される)から繰り返しメッセージを受信することを期待するループを発火させます。同時に、stopチャネルから1つのメッセージ(Stopメソッド経由で送信される)を受信することもできます。その時点でdoneチャネルを閉じ、終了します。Stopメソッド自体は、doneが閉じられるとブロック解除されるdoneでメッセージを受信するのを待ちます。

run、Status、Stopメソッドが並行して実行されると、リークが発生する可能性があります。StopとrunのGoroutineは同期して終了し、Statusによって発行されたメッセージを受信せずに終了する可能性があり、Statusが永遠にブロックされる原因となります。

(pprof) list Status
Total: 8
ROUTINE ======================== main.(*node).Status in .../main.go
         0          8 (flat, cum)   100% of Total
         .          .     16:func (n *node) Status() struct{} {
         .          .     17:   c := make(chan struct{})
         .          8     18:   n.status <- c
         .          .     19:   return <-c
         .          .     20:}

Statusへの送信を、doneチャネルからのメッセージを受信しようとする別のcaseブランチを持つselectステートメントでラップすることで、Stop呼び出しとのレースに負けた場合に、Statusを実行しているGoroutineが正常に終了できるようになります。

例(Kubernetes):チャネルとミューテックス間の相互ブロック

Kubernetesで発生するこの例は、チャネルとロックの混在の結果です。

type Connection struct {
    closeChan chan bool
}

type idleAwareFramer struct {
    resetChan chan bool
    writeLock sync.Mutex
    conn      *Connection
}

func (i *idleAwareFramer) monitor() {
    var resetChan = i.resetChan
    for range i.conn.closeChan {
        i.writeLock.Lock()
        close(resetChan)
        i.resetChan = nil
        i.writeLock.Unlock()
        break
    }
}

func (i *idleAwareFramer) WriteFrame() {
    i.writeLock.Lock()
    defer i.writeLock.Unlock()
    if i.resetChan == nil {
        return
    }
    i.resetChan <- true
}

func NewIdleAwareFramer() *idleAwareFramer {
    return &idleAwareFramer{
        resetChan: make(chan bool),
        conn: &Connection{
            closeChan: make(chan bool),
        },
    }
}

func Kubernetes6632() {
    i := NewIdleAwareFramer()

    go func() {
        i.conn.closeChan <- true
    }()
    go i.monitor()
    go i.WriteFrame()
}

WriteFrameを実行するGoroutineは、アイドルアウェアフレーマーロックを取得し、次にresetChanチャネルでメッセージを送信する可能性があります。一方、monitorGoroutineはcloseChanチャネルでメッセージを受信するのを待っています。メッセージがディスパッチされると、monitorGoroutineは同じロックを取得しようとします。しかし、resetChanにトラフィックがないため、送信操作は永遠にブロックされ、monitorGoroutineがロックを解放するのを妨げます。これにより、両方のGoroutineがリークします。

(pprof) list AwareFramer
Total: 200
ROUTINE ======================== main.(*idleAwareFramer).WriteFrame in .../main.go
         0        100 (flat, cum) 50.00% of Total
         .          .     32:func (i *idleAwareFramer) WriteFrame() {
         .          .     33:   i.writeLock.Lock()
         .          .     34:   defer i.writeLock.Unlock()
         .          .     35:   if i.resetChan == nil {
         .          .     36:           return
         .          .     37:   }
         .        100     38:   i.resetChan <- true
         .          .     39:}
ROUTINE ======================== main.(*idleAwareFramer).monitor in .../main.go
         0        100 (flat, cum) 50.00% of Total
         .          .     21:func (i *idleAwareFramer) monitor() {
         .          .     22:   var resetChan = i.resetChan
         .          .     23:   for range i.conn.closeChan {
         .        100     24:           i.writeLock.Lock()
         .          .     25:           close(resetChan)

修正は、monitorGoroutineでcloseChanでメッセージを受信した後、ロックを取得しようとする前にresetChanをドレインする別のGoroutineを設定することです。

例(Moby):sync.WaitGroupの誤用

Mobyの以下の例は、WaitGroupがどのようにリークを引き起こすかを示しています。

type Manager struct {
    plugins []int
}

func (pm *Manager) init() {
    var group sync.WaitGroup
    group.Add(len(pm.plugins))
    for _, p := range pm.plugins {
        go func(p int) {
            defer group.Done()
        }(p)
        group.Wait() // ここでブロックします
    }
}

func Moby25384() {
    pm := &Manager{
        plugins: []int{1, 2},
    }
    go pm.init()
}

groupWaitGroupは、プラグインマネージャーpmが保持するプラグインの数に応じてカウンターをインクリメントし、次に各プラグインを反復処理してGoroutineを生成します。各Goroutineは、Doneメソッドでタスクを完了するとカウンターをデクリメントします。しかし、groupはループ本体内で誤ってWaitを呼び出しており、ループの後ではありません!これにより、マネージャーに複数のプラグインがある場合にinitメソッドを実行しているGoroutineがリークします。

(pprof) list init
Total: 1
ROUTINE ======================== main.(*Manager).init in .../main.go
         0          1 (flat, cum)   100% of Total
         .          .     17:   group.Add(len(pm.plugins))
         .          .     18:   for _, p := range pm.plugins {
         .          .     19:           go func(p int) {
         .          .     20:                   defer group.Done()
         .          .     21:           }(p)
         .          1     22:           group.Wait() // ここでブロックします
         .          .     23:   }

これは、Waitをループの外に移動することで簡単に解決できます。

例(Moby):チャネルとミューテックス間の相互ブロック

Mobyの別の例は、チャネルとロックの混合リークを示しています。

type (
    State struct {
        Health *Health
    }
    Container struct {
        sync.Mutex
        State *State
    }

    Store struct {
        ctr *Container
    }

    Daemon struct {
        containers Store
    }

    Health struct {
        stop chan struct{}
    }
)

func (d *Daemon) StateChanged() {
    c := d.containers.ctr
    c.Lock()
    d.updateHealthMonitorElseBranch(c)
    defer c.Unlock()
}

func (d *Daemon) updateHealthMonitorElseBranch(c *Container) {
    c.State.Health.CloseMonitorChannel()
}

func (s *Health) CloseMonitorChannel() {
    if s.stop != nil {
        s.stop <- struct{}{}
    }
}

func monitor(c *Container, stop chan struct{}) {
    for {
        select {
        case <-stop:
            return
        default:
            handleProbeResult(c)
        }
    }
}

func handleProbeResult(c *Container) {
    c.Lock()
    defer c.Unlock()
    // 追加の作業...
}

func NewDaemonAndContainer() (*Daemon, *Container) {
    c := &Container{
        State: &State{&Health{
            stop: make(chan struct{}),
        }},
    }
    d := &Daemon{Store{c}}
    return d, c
}

func Moby28462() {
    d, c := NewDaemonAndContainer()
    go monitor(c, c.State.Health.stop)
    go d.StateChanged()
}

StateChangedを呼び出すGoroutineは、デーモンによって格納されたコンテナのロックを取得し、次にデーモンのupdateHealthMonitorElseBranchメソッドを呼び出す可能性があります。このメソッドは、コンテナのstopチャネルにメッセージを送信しようとします。しかし、monitorを実行しているGoroutineは、メッセージがまだインフライトでない場合、stopチャネルからメッセージを受信できず、代わりにselectステートメントのdefaultケースを選択してブロック解除される可能性があります。これにより、StateChangedGoroutineがすでに保持しているのと同じコンテナロックを取得しようとし、両方のGoroutineがリークします。

(pprof) list .CloseMonitorChannel
Total: 2
ROUTINE ======================== main.(*Health).CloseMonitorChannel in .../main.go
         0          1 (flat, cum) 50.00% of Total
         .          .     66:func (s *Health) CloseMonitorChannel() {
         .          .     67:   if s.stop != nil {
         .          1     68:           s.stop <- struct{}{}
         .          .     69:   }
         .          .     70:}
(pprof) list main.handleProbeResult
Total: 2
ROUTINE ======================== main.handleProbeResult in .../main.go
         0          1 (flat, cum) 50.00% of Total
         .          .     83:func handleProbeResult(c *Container) {
         .          1     84:   c.Lock()
         .          .     85:   // 追加の作業...
         .          .     86:   defer c.Unlock()
         .          .     87:}

修正は、monitorGoroutineでcloseChanでメッセージを受信した後、ロックを取得しようとする前にresetChanをドレインする別のGoroutineを設定することです。StateChangedGoroutineがロックを解放できるように、stopチャネルを送信する代わりに閉じることです。これにより、monitorGoroutineがブロック解除され、次のループイテレーションでselectステートメントのブロック解除されていない<-stopケースブランチを選択して終了できるようになります。