The Go Blog

testing/synctest を使用した並行コードのテスト

Damien Neil
2025年2月19日

Goの特長の一つは、並行処理の組み込みサポートです。Goルーチンとチャネルは、並行プログラムを記述するためのシンプルで効果的なプリミティブです。

しかし、並行プログラムのテストは難しく、エラーが発生しやすいものです。

Go 1.24では、並行コードのテストをサポートする新しい実験的なtesting/synctestパッケージを導入します。この記事では、この実験の動機、synctestパッケージの使用方法、およびその潜在的な将来について説明します。

Go 1.24では、testing/synctestパッケージは実験的であり、Go互換性の約束の対象ではありません。デフォルトでは表示されません。使用するには、環境変数にGOEXPERIMENT=synctestを設定してコードをコンパイルしてください。

並行プログラムのテストは難しい

まず、簡単な例を考えてみましょう。

context.AfterFunc関数は、コンテキストがキャンセルされた後に、独自のGoルーチンで関数が呼び出されるように手配します。以下はAfterFuncのテストの可能性のある例です。

func TestAfterFunc(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())

    calledCh := make(chan struct{}) // closed when AfterFunc is called
    context.AfterFunc(ctx, func() {
        close(calledCh)
    })

    // TODO: Assert that the AfterFunc has not been called.

    cancel()

    // TODO: Assert that the AfterFunc has been called.
}

このテストでは、2つの条件をチェックしたいと考えています。関数はコンテキストがキャンセルされる前には呼び出されないこと、そしてコンテキストがキャンセルされた後には関数が呼び出されることです。

並行システムで否定をチェックすることは困難です。関数がまだ呼び出されていないことは簡単にテストできますが、呼び出されないことをどのようにチェックすればよいでしょうか?

一般的なアプローチは、イベントが発生しないと結論付ける前に、ある程度の時間待つことです。これを実現するヘルパー関数をテストに導入してみましょう。

// funcCalled reports whether the function was called.
funcCalled := func() bool {
    select {
    case <-calledCh:
        return true
    case <-time.After(10 * time.Millisecond):
        return false
    }
}

if funcCalled() {
    t.Fatalf("AfterFunc function called before context is canceled")
}

cancel()

if !funcCalled() {
    t.Fatalf("AfterFunc function not called after context is canceled")
}

このテストは遅いです。10ミリ秒は短い時間ですが、多くのテストで合計するとかなりの時間になります。

このテストは不安定でもあります。10ミリ秒は高速なコンピューターでは長い時間ですが、共有され過負荷になっているCIシステムでは、数秒間続く一時停止が見られることは珍しくありません。

テストを遅くする代わりに不安定性を減らすことはできますし、不安定性を増やす代わりにテストを速くすることもできますが、高速で信頼性のあるものにすることはできません。

testing/synctest パッケージの紹介

testing/synctestパッケージはこの問題を解決します。テストされるコードに変更を加えることなく、このテストをシンプル、高速、かつ信頼性の高いものに書き直すことができます。

このパッケージには、RunWaitの2つの関数しか含まれていません。

Runは新しいGoルーチンで関数を呼び出します。このGoルーチンとそれが開始するすべてのGoルーチンは、私たちがバブルと呼ぶ隔離された環境に存在します。Waitは、現在のGoルーチンのバブル内のすべてのGoルーチンが、バブル内の別のGoルーチンでブロックされるまで待機します。

上記のテストをtesting/synctestパッケージを使用して書き直してみましょう。

func TestAfterFunc(t *testing.T) {
    synctest.Run(func() {
        ctx, cancel := context.WithCancel(context.Background())

        funcCalled := false
        context.AfterFunc(ctx, func() {
            funcCalled = true
        })

        synctest.Wait()
        if funcCalled {
            t.Fatalf("AfterFunc function called before context is canceled")
        }

        cancel()

        synctest.Wait()
        if !funcCalled {
            t.Fatalf("AfterFunc function not called after context is canceled")
        }
    })
}

これは元のテストとほとんど同じですが、テストをsynctest.Run呼び出しで囲み、関数が呼び出されたかどうかをアサートする前にsynctest.Waitを呼び出しています。

Wait関数は、呼び出し元のバブル内のすべてのGoルーチンがブロックされるまで待機します。それが戻ると、コンテキストパッケージが関数を呼び出したか、または私たちがさらに何らかのアクションを取るまで関数を呼び出さないかのいずれかであることがわかります。

このテストは、高速かつ信頼性の高いものになりました。

テストもよりシンプルになりました。calledChチャネルをブール値に置き換えました。以前は、テストGoルーチンとAfterFuncGoルーチン間のデータ競合を避けるためにチャネルを使用する必要がありましたが、Wait関数がその同期を提供するようになりました。

レース検出器はWait呼び出しを理解し、このテストは-raceで実行すると合格します。2番目のWait呼び出しを削除すると、レース検出器はテスト内のデータ競合を正しく報告します。

テスト時間

並行コードはしばしば時間と関連します。

時間に関連するコードのテストは難しい場合があります。上記で見たように、テストで実時間を使用すると、テストが遅くなり不安定になります。偽の時間を使用するには、timeパッケージ関数を避け、テスト対象のコードをオプションの偽のクロックで動作するように設計する必要があります。

testing/synctestパッケージは、時間を使用するコードのテストをより簡単にします。

Runによって開始されたバブル内のGoルーチンは、偽のクロックを使用します。バブル内では、timeパッケージの関数は偽のクロック上で動作します。すべてのGoルーチンがブロックされると、バブル内の時間が進みます。

これを示すために、context.WithTimeout関数のテストを記述してみましょう。WithTimeoutは、指定されたタイムアウト後に期限切れになるコンテキストの子を作成します。

func TestWithTimeout(t *testing.T) {
    synctest.Run(func() {
        const timeout = 5 * time.Second
        ctx, cancel := context.WithTimeout(context.Background(), timeout)
        defer cancel()

        // Wait just less than the timeout.
        time.Sleep(timeout - time.Nanosecond)
        synctest.Wait()
        if err := ctx.Err(); err != nil {
            t.Fatalf("before timeout, ctx.Err() = %v; want nil", err)
        }

        // Wait the rest of the way until the timeout.
        time.Sleep(time.Nanosecond)
        synctest.Wait()
        if err := ctx.Err(); err != context.DeadlineExceeded {
            t.Fatalf("after timeout, ctx.Err() = %v; want DeadlineExceeded", err)
        }
    })
}

このテストは、実時間で作業しているかのように記述します。唯一の違いは、テスト関数をsynctest.Runで囲み、各time.Sleep呼び出しの後にsynctest.Waitを呼び出して、コンテキストパッケージのタイマーが実行を終了するのを待つことです。

ブロッキングとバブル

testing/synctestの重要な概念は、バブルが耐久的にブロックされることです。これは、バブル内のすべてのGoルーチンがブロックされ、バブル内の別のGoルーチンによってのみアンブロックできる場合に発生します。

バブルが耐久的にブロックされたとき

  • 未処理のWait呼び出しがある場合、それは戻ります。
  • それ以外の場合、もしあれば、Goルーチンをアンブロックできる次の時間まで時間が進みます。
  • それ以外の場合、バブルはデッドロック状態になり、Runはパニックを起こします。

バブル内のいずれかのGoルーチンがブロックされているが、バブルの外部からのイベントによって目覚めさせられる可能性がある場合、バブルは耐久的にブロックされません。

Goルーチンを耐久的にブロックする操作の完全なリストは次のとおりです。

  • nilチャネルでの送信または受信
  • 同じバブル内で作成されたチャネルでブロックされた送信または受信
  • すべてのケースが耐久的にブロックされているselectステートメント
  • time.Sleep
  • sync.Cond.Wait
  • sync.WaitGroup.Wait

Mutex

sync.Mutexに対する操作は、耐久的にブロックするものではありません。

関数がグローバルミューテックスを取得することはよくあります。たとえば、reflectパッケージの多くの関数は、ミューテックスによって保護されたグローバルキャッシュを使用します。synctestバブル内のGoルーチンが、バブルの外部のGoルーチンによって保持されているミューテックスを取得中にブロックされた場合、それは耐久的にブロックされません。ブロックはされますが、バブルの外部からのGoルーチンによってアンブロックされます。

ミューテックスは通常、長期間保持されないため、testing/synctestの考慮事項から単純に除外しています。

チャネル

バブル内で作成されたチャネルは、外部で作成されたチャネルとは異なる動作をします。

チャネル操作は、チャネルがバブル化されている(バブル内で作成された)場合にのみ、耐久的にブロックされます。バブルの外部からバブル化されたチャネルを操作するとパニックが発生します。

これらのルールにより、Goルーチンはバブル内のGoルーチンと通信している場合にのみ、耐久的にブロックされることが保証されます。

I/O

ネットワーク接続からの読み取りなどの外部I/O操作は、耐久的にブロックするものではありません。

ネットワークからの読み取りは、バブルの外部からの書き込み、場合によっては他のプロセスからの書き込みによってブロックが解除される可能性があります。ネットワーク接続への書き込み元が同じバブル内にのみ存在する場合でも、ランタイムは、さらにデータが到着するのを待っている接続と、カーネルがデータを受信し、現在配信している途中の接続を区別できません。

synctestでネットワークサーバーまたはクライアントをテストするには、通常、偽のネットワーク実装を提供する必要があります。たとえば、net.Pipe関数は、インメモリのネットワーク接続を使用するnet.Connのペアを作成し、synctestテストで使用できます。

バブルの寿命

Run関数は、新しいバブルでGoルーチンを開始します。バブル内のすべてのGoルーチンが終了すると、戻ります。バブルが耐久的にブロックされ、時間の進行によってブロック解除できない場合、パニックを起こします。

Runが戻る前にバブル内のすべてのGoルーチンが終了する必要があるため、テストは完了する前にバックグラウンドGoルーチンを注意深くクリーンアップする必要があります。

ネットワークコードのテスト

別の例を見てみましょう。今回はtesting/synctestパッケージを使用して、ネットワークプログラムをテストします。この例では、net/httpパッケージの100 Continue応答の処理をテストします。

リクエストを送信するHTTPクライアントは、「Expect: 100-continue」ヘッダーを含めることで、クライアントが追加のデータを送信することをサーバーに伝えることができます。サーバーは、リクエストの残りを要求するために100 Continue情報応答で応答するか、コンテンツが不要であることをクライアントに伝えるために他のステータスで応答することができます。たとえば、大きなファイルをアップロードするクライアントは、ファイルを送信する前にサーバーがファイルを受け入れる準備ができていることを確認するためにこの機能を使用する場合があります。

私たちのテストでは、「Expect: 100-continue」ヘッダーを送信するときに、HTTPクライアントがサーバーが要求する前にリクエストのコンテンツを送信しないこと、および100 Continue応答を受信した後にコンテンツを送信することを確認します。

クライアントとサーバーが通信するテストでは、多くの場合、ループバックネットワーク接続を使用できます。しかし、testing/synctestを使用する場合、すべてのGoルーチンがネットワーク上でブロックされたことを検出できるように、通常は偽のネットワーク接続を使用したいと考えるでしょう。このテストでは、net.Pipeによって作成されたインメモリのネットワーク接続を使用するhttp.Transport(HTTPクライアント)を作成することから始めます。

func Test(t *testing.T) {
    synctest.Run(func() {
        srvConn, cliConn := net.Pipe()
        defer srvConn.Close()
        defer cliConn.Close()
        tr := &http.Transport{
            DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
                return cliConn, nil
            },
            // Setting a non-zero timeout enables "Expect: 100-continue" handling.
            // Since the following test does not sleep,
            // we will never encounter this timeout,
            // even if the test takes a long time to run on a slow machine.
            ExpectContinueTimeout: 5 * time.Second,
        }

このトランスポートに「Expect: 100-continue」ヘッダーを設定してリクエストを送信します。リクエストはテストの最後まで完了しないため、新しいGoルーチンで送信されます。

        body := "request body"
        go func() {
            req, _ := http.NewRequest("PUT", "http://test.tld/", strings.NewReader(body))
            req.Header.Set("Expect", "100-continue")
            resp, err := tr.RoundTrip(req)
            if err != nil {
                t.Errorf("RoundTrip: unexpected error %v", err)
            } else {
                resp.Body.Close()
            }
        }()

クライアントから送信されたリクエストヘッダーを読み取ります。

        req, err := http.ReadRequest(bufio.NewReader(srvConn))
        if err != nil {
            t.Fatalf("ReadRequest: %v", err)
        }

ここでテストの核心に入ります。クライアントがまだリクエストボディを送信しないことをアサートしたいのです。

サーバーに送信されたボディをstrings.Builderにコピーする新しいGoルーチンを開始し、バブル内のすべてのGoルーチンがブロックされるのを待ち、ボディからまだ何も読み取っていないことを確認します。

synctest.Wait呼び出しを忘れると、競合検出器はデータ競合について正しく文句を言いますが、Waitがあればこれは安全です。

        var gotBody strings.Builder
        go io.Copy(&gotBody, req.Body)
        synctest.Wait()
        if got := gotBody.String(); got != "" {
            t.Fatalf("before sending 100 Continue, unexpectedly read body: %q", got)
        }

クライアントに「100 Continue」応答を書き込み、クライアントがリクエストボディを送信したことを確認します。

        srvConn.Write([]byte("HTTP/1.1 100 Continue\r\n\r\n"))
        synctest.Wait()
        if got := gotBody.String(); got != body {
            t.Fatalf("after sending 100 Continue, read body %q, want %q", got, body)
        }

最後に、「200 OK」応答を送信してリクエストを完了します。

このテスト中にいくつかのGoルーチンを開始しました。synctest.Run呼び出しは、すべてのGoルーチンが終了するまで待機してから戻ります。

        srvConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
    })
}

このテストは、サーバーが要求しなかった場合にリクエストボディが送信されないことや、サーバーがタイムアウト内に応答しなかった場合に送信されることなど、他の動作をテストするために簡単に拡張できます。

実験の状況

Go 1.24では、testing/synctest実験的パッケージとして導入します。フィードバックと経験に応じて、修正を加えてリリースするか、実験を続けるか、または将来のGoバージョンで削除する可能性があります。

このパッケージはデフォルトでは表示されません。使用するには、環境変数にGOEXPERIMENT=synctestを設定してコードをコンパイルしてください。

皆様からのフィードバックをお待ちしております!testing/synctestを試してみて、良かった点、悪かった点にかかわらず、go.dev/issue/67434にご報告ください。

次の記事: Swiss Tables による Go マップの高速化
前の記事: Go を使用した拡張可能な Wasm アプリケーション
ブログインデックス