testing/synctestを用いた並行処理コードのテスト

Testing concurrent code with testing/synctest by Damien Neil

Goの特徴的な機能の一つに、並行処理に対する組み込みのサポートがあります。ゴルーチンとチャネルは、 並行プログラムを書くためのシンプルで効果的なプリミティブです。

しかし、並行プログラムのテストは難しく、間違いが起きやすいものです。

Go 1.24では、並行処理コードのテストをサポートするための新しい実験的パッケージ testing/synctest を導入します。この記事では、 この実験の背景にある動機を説明し、synctestパッケージの使い方を示し、その今後の可能性について 議論します。

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

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

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

context.AfterFunc 関数は、コンテキストが キャンセルされた後に、専用のゴルーチンで関数を呼び出すよう手配します。AfterFuncに対する テストの一例は次のようになります。

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

    calledCh := make(chan struct{}) // AfterFuncが呼ばれたら閉じられる
    context.AfterFunc(ctx, func() {
        close(calledCh)
    })

    // TODO: AfterFuncがまだ呼ばれていないことをアサートする。

    cancel()

    // TODO: AfterFuncが呼ばれたことをアサートする。
}

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

並行システムにおいて、何かが起きていないことを確認するのは難しいものです。関数がまだ 呼ばれて いない ことを確認するのは簡単ですが、呼ばれない であろう ことをどう確認すれば よいでしょうか。

よくあるアプローチは、あるイベントが発生しないと結論づける前に、一定時間待つというものです。 これを行うヘルパー関数をテストに導入してみましょう。

// funcCalled は関数が呼ばれたかどうかを報告する。
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 パッケージはこの問題を解決します。テスト対象のコードを一切変更することなく、 このテストをシンプルかつ高速で信頼性の高いものに書き直せるようになります。

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

Run は新しいゴルーチンの中で関数を呼び出します。このゴルーチンと、そこから起動される すべてのゴルーチンは、私たちが バブル(bubble) と呼ぶ隔離された環境の中に存在します。 Wait は、現在のゴルーチンが属するバブル内のすべてのゴルーチンが、バブル内の他のゴルーチン によってブロックされる状態になるまで待機します。

先ほどのテストを 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 関数は、呼び出し元のバブル内のすべてのゴルーチンがブロックされるまで待機します。 この関数が戻ってきた時点で、contextパッケージがすでに関数を呼び出したか、あるいはこちらが 何らかの操作をするまでは呼び出さないことがわかります。

これでこのテストは高速かつ信頼できるものになりました。

テストはよりシンプルにもなりました。calledCh チャネルをbool値に置き換えています。これまでは、 テストのゴルーチンと AfterFunc のゴルーチンとの間でデータ競合を避けるためにチャネルを 使う必要がありましたが、今では Wait 関数がその同期を提供してくれます。

レースディテクタは Wait の呼び出しを理解しているため、このテストは -race を付けて実行しても 成功します。もし2つ目の Wait 呼び出しを削除すると、レースディテクタはこのテスト内のデータ 競合を正しく報告します。

時間のテスト

並行処理コードでは、時間を扱うことがよくあります。

時間を扱うコードのテストは難しいことがあります。テストの中で実際の時間を使うと、先ほど 見たように、遅く不安定なテストになってしまいます。かわりに擬似的な時間を使うには、 time パッケージの関数の使用を避け、テスト対象のコードをオプションの擬似クロックで動作する ように設計する必要があります。

testing/synctest パッケージは、時間を使うコードのテストをよりシンプルにします。

Run によって起動されたバブル内のゴルーチンは、擬似クロックを使用します。バブルの中では、 time パッケージの関数は擬似クロックに基づいて動作します。バブル内のすべてのゴルーチンが ブロックされると、バブル内の時間が進みます。

これを実演するために、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()

        // タイムアウトよりわずかに短い時間だけ待つ。
        time.Sleep(timeout - time.Nanosecond)
        synctest.Wait()
        if err := ctx.Err(); err != nil {
            t.Fatalf("before timeout, ctx.Err() = %v; want nil", err)
        }

        // タイムアウトまでの残り時間を待つ。
        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 を 呼び出して、contextパッケージのタイマーの実行が終わるのを待つ点です。

ブロッキングとバブル

testing/synctest における重要な概念のひとつが、バブルが 永続的にブロックされる(durably blocked) 状態になるということです。これは、バブル内のすべてのゴルーチンが ブロックされていて、かつバブル内の他のゴルーチンによってしかブロック解除されえない場合に 発生します。

バブルが永続的にブロックされると、次のいずれかが起こります。

  • 未完了の Wait 呼び出しがあれば、それが戻ります。
  • そうでなければ、ゴルーチンのブロックを解除しうる次の時刻まで時間が進みます(そのような 時刻がある場合)。
  • それでもなければ、バブルはデッドロック状態にあり、Run はパニックします。

バブル内のいずれかのゴルーチンがブロックされていても、バブルの外部からの何らかのイベントに よって起こされる可能性がある場合、そのバブルは永続的にブロックされているとはみなされません。

ゴルーチンを永続的にブロックする操作の完全な一覧は次のとおりです。

  • nilチャネルに対する送信または受信
  • 同じバブル内で作成されたチャネルに対する送信または受信のブロック
  • すべてのcaseが永続的にブロックするようなselect文
  • time.Sleep
  • sync.Cond.Wait
  • sync.WaitGroup.Wait

ミューテックス

sync.Mutex に対する操作は永続的にブロックする操作ではありません。

関数がグローバルなミューテックスを取得することはよくあります。たとえば、reflect パッケージ 内の多くの関数は、ミューテックスで保護されたグローバルなキャッシュを使用しています。synctest のバブル内のゴルーチンが、バブルの外側のゴルーチンが保持しているミューテックスを取得しようと してブロックした場合、それは永続的にブロックされているわけではありません。ブロックはされて いますが、バブルの外側のゴルーチンによってブロックが解除されるからです。

ミューテックスは通常長時間保持されるものではないため、testing/synctest の判定対象からは 単純に除外しています。

チャネル

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

チャネルの操作が永続的にブロックするのは、そのチャネルが バブル化(bubbled) されている (つまりバブル内で作成された)場合に限られます。バブル化されたチャネルに対してバブルの外部 から操作を行うと、パニックが発生します。

これらの規則により、ゴルーチンが永続的にブロックされるのは、自分が属するバブル内のゴルーチン と通信している場合に限られることが保証されます。

I/O

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

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

synctestを使ってネットワークサーバーやクライアントをテストするには、一般的に擬似的な ネットワーク実装を用意する必要があります。たとえば、net.Pipe 関数はインメモリのネットワーク接続を使用する net.Conn のペアを作成し、これはsynctestの テストで利用できます。

バブルの寿命

Run 関数は新しいバブル内でゴルーチンを起動します。この関数は、バブル内のすべてのゴルーチンが 終了すると戻ります。バブルが永続的にブロックされていて、時間を進めてもブロックを解除できない 場合はパニックします。

Run が戻る前にバブル内のすべてのゴルーチンが終了していなければならないという要件は、 テストがバックグラウンドのゴルーチンを完了前に確実に片付けなければならないということを 意味します。

ネットワークを使うコードのテスト

もう一つの例を見てみましょう。今度は testing/synctest パッケージを使って、ネットワークを 使うプログラムをテストします。この例では、net/http パッケージによる100 Continueレスポンス の処理をテストします。

リクエストを送信するHTTPクライアントは、送信すべき追加のデータがあることをサーバーに 伝えるために、「Expect: 100-continue」ヘッダーを含められます。サーバーは、 リクエストの残りの部分を要求するために100 Continueという情報レスポンスを返すか、あるいは コンテンツが不要であることをクライアントに伝えるために別のステータスを返せます。 たとえば、大きなファイルをアップロードするクライアントは、この機能を使って、ファイルを 送信する前にサーバーがそれを受け取る意思があるかどうかを確認できます。

このテストでは、「Expect: 100-continue」ヘッダーを送信した場合に、HTTPクライアントがサーバー から要求されるまでリクエストのコンテンツを送信しないこと、そして100 Continueレスポンスを 受け取った後にはコンテンツを送信することを確認します。

通信するクライアントとサーバーのテストでは、多くの場合ループバックのネットワーク接続を 使用できます。しかし testing/synctest を使う場合は、すべてのゴルーチンがネットワーク上で ブロックされているタイミングを検知できるよう、通常は擬似的なネットワーク接続を使いたく なります。このテストでは、まず 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
            },
            // タイムアウトに0以外の値を設定すると「Expect: 100-continue」の処理が有効になる。
            // 以下のテストではスリープしないので、
            // このテストが低速なマシンでどれだけ時間がかかっても、
            // このタイムアウトに達することはない。
            ExpectContinueTimeout: 5 * time.Second,
        }

このトランスポート上で、「Expect: 100-continue」ヘッダーを設定したリクエストを送信します。 このリクエストはテストが終わるまで完了しないため、新しいゴルーチンの中で送信します。

        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 にコピーする新しいゴルーチンを開始し、バブル 内のすべてのゴルーチンがブロックされるのを待ってから、ボディからまだ何も読み込まれていない ことを検証します。

もし 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」レスポンスを送信してリクエストを完了させます。

このテスト中にいくつかのゴルーチンを起動してきました。synctest.Run の呼び出しは、それら すべてが終了するのを待ってから戻ります。

        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までご報告ください。

By Damien Neil