The Go Blog

テスト時間(およびその他の非同期性)

Damien Neil
2025年8月26日

Go 1.24 では、testing/synctest パッケージを実験的なパッケージとして導入しました。このパッケージは、並行および非同期コードのテスト作成を大幅に簡素化できます。Go 1.25 では、testing/synctest パッケージは実験段階を卒業し、一般的に利用できるようになりました。

以下は、ベルリンで開催された GopherCon Europe 2025 での testing/synctest パッケージに関する私の講演のブログ版です。

非同期関数とは?

同期関数は非常にシンプルです。呼び出すと、何かを実行し、戻ります。

非同期関数は異なります。呼び出すと、戻り、その後、何かを実行します。

具体的な、しかしやや人工的な例として、以下の Cleanup 関数は同期関数です。呼び出すと、キャッシュディレクトリを削除し、戻ります。

func (c *Cache) Cleanup() {
    os.RemoveAll(c.cacheDir)
}

CleanupInBackground は非同期関数です。呼び出すと、戻り、キャッシュディレクトリが…遅かれ早かれ削除されます。

func (c *Cache) CleanupInBackground() {
    go os.RemoveAll(c.cacheDir)
}

非同期関数は、将来のある時点で何かを実行することがあります。たとえば、context パッケージの WithDeadline 関数は、将来キャンセルされるコンテキストを返します。

package context

// WithDeadline returns a derived context
// with a deadline no later than d.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

並行コードのテストについて話すとき、私は、リアルタイムを使用するものと使用しないものの両方で、このような非同期操作のテストを意味しています。

テスト

テストは、システムが期待どおりに動作することを確認します。単体テスト、統合テストなど、テストの種類を記述する多くの用語がありますが、ここでの目的のために、あらゆる種類のテストは次の3つのステップに還元されます。

  1. いくつかの初期条件を設定します。
  2. テスト対象システムに何かを実行するように指示します。
  3. 結果を確認します。

同期関数のテストは簡単です。

  • 関数を呼び出します。
  • 関数は何かを実行して戻ります。
  • 結果を確認します。

しかし、非同期関数のテストは難しいです。

  • 関数を呼び出します。
  • 関数は戻ります。
  • 実行される作業が完了するまで待ちます。
  • 結果を確認します。

適切な時間待たないと、まだ発生していない、または部分的にしか発生していない操作の結果を確認してしまう可能性があります。これは決してうまくいきません。

特に、何かが「発生していない」ことを表明したい場合、非同期関数のテストは非常にトリッキーです。まだ発生していないことは確認できますが、後で発生しないことをどうやって確実に知るのでしょうか?

もう少し具体的にするために、実世界の例で考えてみましょう。context パッケージの WithDeadline 関数をもう一度考えてみましょう。

package context

// WithDeadline returns a derived context
// with a deadline no later than d.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

WithDeadline に対して書くべき明らかなテストは2つあります。

  1. デッドライン「前」にコンテキストは「キャンセルされない」。
  2. デッドライン「後」にコンテキストは「キャンセルされる」。

テストを書いてみましょう。

コード量を少しでも減らすために、2番目のケースのみをテストします。デッドラインが期限切れになった後、コンテキストがキャンセルされることを確認します。

func TestWithDeadlineAfterDeadline(t *testing.T) {
    deadline := time.Now().Add(1 * time.Second)
    ctx, _ := context.WithDeadline(t.Context(), deadline)

    time.Sleep(time.Until(deadline))

    if err := ctx.Err(); err != context.DeadlineExceeded {
        t.Fatalf("context not canceled after deadline")
    }
}

このテストは簡単です。

  1. context.WithDeadline を使用して、1秒後にデッドラインが設定されたコンテキストを作成します。
  2. デッドラインまで待ちます。
  3. コンテキストがキャンセルされたことを確認します。

残念ながら、このテストには明らかに問題があります。デッドラインが期限切れになる正確な瞬間までスリープします。私たちがそれを調べるときには、コンテキストがまだキャンセルされていない可能性が高いです。最善の場合でも、このテストは非常に不安定になるでしょう。

修正しましょう。

time.Sleep(time.Until(deadline) + 100*time.Millisecond)

デッドラインの100ミリ秒後までスリープできます。100ミリ秒はコンピューターの世界では永遠です。これで大丈夫なはずです。

残念ながら、まだ2つの問題があります。

まず、このテストの実行に1.1秒かかります。これは遅いです。これは単純なテストです。せいぜいミリ秒で実行されるべきです。

次に、このテストは不安定です。100ミリ秒はコンピューターの世界では永遠ですが、過負荷の継続的インテグレーション(CI)システムでは、それよりもはるかに長い一時停止が見られることは珍しくありません。このテストは開発者のワークステーションでは常に合格するかもしれませんが、CIシステムでは時折失敗すると予想されます。

遅いか不安定か:どちらかを選択

リアルタイムを使用するテストは常に遅いか不安定です。通常は両方です。テストが必要以上に長く待つ場合、それは遅いです。十分に長く待たない場合、それは不安定です。テストをより遅く、より不安定でないようにすることも、より速く、より不安定にすることもできますが、高速で信頼性の高いテストにすることはできません。

net/http パッケージには、このアプローチを使用するテストが多数あります。それらはすべて遅いか、不安定か、あるいはその両方であり、それが今日のこの道を進むきっかけとなりました。

同期関数を書く?

非同期関数をテストする最も簡単な方法は、テストしないことです。同期関数はテストが簡単です。非同期関数を同期関数に変換できれば、テストはより簡単になります。

たとえば、以前のキャッシュクリーンアップ関数を考えると、同期 Cleanup は明らかに非同期 CleanupInBackground よりも優れています。同期関数はテストが簡単で、呼び出し元は必要に応じて新しいゴルーチンを簡単に開始してバックグラウンドで実行できます。一般的に、並行性を呼び出しスタックの上位に押し上げられるほど、より良いです。

// CleanupInBackground is hard to test.
cache.CleanupInBackground()

// Cleanup is easy to test,
// and easy to run in the background when needed.
go cache.Cleanup()

残念ながら、この種の変換は常に可能であるとは限りません。たとえば、context.WithDeadline は本質的に非同期なAPIです。

テスト容易性のためのコードの計測?

より良いアプローチは、コードをよりテストしやすくすることです。

WithDeadline テストの場合の例を次に示します。

func TestWithDeadlineAfterDeadline(t *testing.T) {
    clock := fakeClock()
    timeout := 1 * time.Second
    deadline := clock.Now().Add(timeout)

    ctx, _ := context.WithDeadlineClock(
        t.Context(), deadline, clock)

    clock.Advance(timeout)
    context.WaitUntilIdle(ctx)
    if err := ctx.Err(); err != context.DeadlineExceeded {
        t.Fatalf("context not canceled after deadline")
    }
}

リアルタイムを使用する代わりに、偽の時間実装を使用します。偽の時間を使用することで、何もせずに不必要に待つことがなくなるため、不必要に遅いテストを回避できます。また、現在の時間はテストが調整する場合にのみ変更されるため、テストの不安定さを回避するのに役立ちます。

さまざまな偽の時間パッケージが存在するか、自分で書くことができます。

偽の時間を使用するには、偽のクロックを受け入れるようにAPIを変更する必要があります。ここでは、追加のクロックパラメーターを取る context.WithDeadlineClock 関数を追加しました。

ctx, _ := context.WithDeadlineClock(
    t.Context(), deadline, clock)

偽のクロックを進めると、問題が発生します。時間の進行は非同期操作です。スリープ中のゴルーチンがウェイクアップしたり、タイマーがチャネルに送信したり、タイマー関数が実行されたりする可能性があります。システムの期待される動作をテストする前に、その作業が完了するまで待つ必要があります。

ここでは、コンテキストに関連するバックグラウンド作業が完了するまで待機する context.WaitUntilIdle 関数を追加しました。

clock.Advance(timeout)
context.WaitUntilIdle(ctx)

これは単純な例ですが、テスト可能な並行コードを書くための2つの基本的な原則を示しています。

  1. 偽の時間を使用する(時間を使用する場合)。
  2. 静止状態を待つ方法があること。これは、「すべてのバックグラウンドアクティビティが停止し、システムが安定している」という洒落た言い方です。

もちろん、興味深い質問は、これをどのように行うかです。このアプローチにはいくつかの大きな欠点があるため、この例では詳細を省略しました。

難しいです。偽のクロックを使用することは難しくありませんが、バックグラウンドの並行作業がいつ完了し、システムの状態を安全に調べられるかを特定することは困難です。

コードが Go らしいコードでなくなります。標準の time パッケージ関数を使用できません。バックグラウンドで発生しているすべてを非常に注意深く追跡する必要があります。

自分のコードだけでなく、使用する他のパッケージも計測する必要があります。サードパーティの並行コードを呼び出す場合、おそらく運が悪いです。

最悪なのは、このアプローチを既存のコードベースに後付けすることはほとんど不可能であるということです。

私はこのアプローチを Go の HTTP 実装に適用しようとしましたが、一部で成功したものの、HTTP/2 サーバーは私を完全に打ち負かしました。特に、大規模な書き換えなしに静止状態を検出するための計測を追加することは実現不可能であるか、少なくとも私のスキルを超えていました。

ひどいランタイムハック?

コードをテスト可能にできない場合、どうすればよいでしょうか?

コードを計測する代わりに、計測されていないシステムの動作を観察する方法があったらどうでしょうか?

Go プログラムは、一連のゴルーチンで構成されます。これらのゴルーチンには状態があります。すべてのゴルーチンが実行を停止するまで待つ必要があります。

残念ながら、Go ランタイムは、これらのゴルーチンが何をしているかを伝える方法を提供していません。それとも提供しているのでしょうか?

runtime パッケージには、実行中のすべてのゴルーチンのスタックトレースと状態を提供する関数が含まれています。これは人間が読むためのテキストですが、その出力を解析できます。これを使用して静止状態を検出できるでしょうか?

もちろん、これはひどいアイデアです。これらのスタックトレースの形式が時間の経過とともに安定しているという保証はありません。これは行うべきではありません。

私はそれをやりました。そしてうまくいきました。実際、驚くほどうまくいきました。

偽のクロックの簡単な実装、どのゴルーチンがテストの一部であるかを追跡するためのわずかな計測、そして runtime.Stack の恐ろしい乱用によって、ついに http パッケージの高速で信頼性の高いテストを書く方法を手に入れました。

これらのテストの基礎となる実装はひどいものでしたが、ここには有用な概念があることを示していました。

より良い方法

Go には組み込みの並行性があるかもしれませんが、その並行性を使用するプログラムのテストは困難です。

私たちは不幸な選択に直面しています。シンプルで Go らしいコードを書くことができますが、迅速かつ確実にテストすることは不可能です。あるいは、テスト可能なコードを書くことができますが、それは複雑で Go らしくありません。

そこで、これを改善するために何ができるかを自問しました。

前述したように、テスト可能な並行コードを書くために必要な2つの基本的な機能は、偽の時間と静止状態を待つ方法です。

静止状態を待つためのより良い方法が必要です。バックグラウンドのゴルーチンが作業を完了したときに、ランタイムに問い合わせることができるはずです。また、このクエリの範囲を単一のテストに限定できるようにしたいので、無関係なテストが互いに干渉しないようにします。

また、偽の時間を使用するプログラムのテストに対するより良いサポートも必要です。

偽の時間実装を作成することは難しくありませんが、このような実装を使用するコードは Go らしいコードではありません。

Go らしいコードは time.Timer を使用しますが、偽の Timer を作成することはできません。テストがタイマーがいつ発火するかを制御する偽の Timer を作成する方法を提供すべきかどうかを自問しました。

時間のテスト実装は、time パッケージのまったく新しいバージョンを定義し、それを時間操作を行うすべての関数に渡す必要があります。net.Conn がネットワーク接続を記述する共通インターフェースであるのと同じように、共通の時間インターフェースを定義すべきかどうかを検討しました。

しかし、私たちが認識したのは、ネットワーク接続とは異なり、偽の時間の実装は1つしか考えられないということです。偽のネットワークは遅延やエラーを導入したいかもしれません。一方、時間は1つのことしか行いません。それは前進することです。テストは時間の進行速度を制御する必要がありますが、将来10秒後に発火するようにスケジュールされたタイマーは、常に将来10(おそらく偽の)秒後に発火するはずです。

さらに、Go エコシステム全体を混乱させたくありません。今日のほとんどのプログラムは time パッケージの関数を使用しています。これらのプログラムが動作し続けるだけでなく、Go らしいコードであるようにしたいと考えています。

このことから、Go playground が偽のクロックを使用するのとほぼ同じ方法で、テストが time パッケージに偽のクロックを使用するように指示する方法が必要であるという結論に至りました。playground とは異なり、その変更の範囲を単一のテストに限定する必要があります。(Go playground が偽のクロックを使用していることは明らかではないかもしれません。フロントエンドでは偽の遅延を実際の遅延に変換しているためですが、実際に使用しています。)

synctest の実験

そこで Go 1.24 では、並行プログラムのテストを簡素化するための新しい実験的パッケージである testing/synctest を導入しました。Go 1.24 のリリース後数ヶ月間、私たちは早期採用者からのフィードバックを収集しました。(試してくれた皆さん、ありがとうございました!)問題点や欠点に対処するためにいくつかの変更を加えました。そして今、Go 1.25 では、testing/synctest パッケージを標準ライブラリの一部としてリリースしました。

これは、私たちが「バブル」と呼んでいるもので関数を実行することを可能にします。バブル内では、time パッケージは偽のクロックを使用し、synctest パッケージはバブルが静止状態になるまで待機する関数を提供します。

synctest パッケージ

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

package synctest

// Test executes f in a new bubble.
// Goroutines in the bubble use a fake clock.
func Test(t *testing.T, f func(*testing.T))

// Wait waits for background activity in the bubble to complete.
func Wait()

Test は、新しいバブルで関数を実行します。

Wait は、バブル内のすべてのゴルーチンがバブル内の他のゴルーチンを待機してブロックされるまでブロックします。この状態を「耐久的にブロックされている」と呼びます。

synctest でのテスト

synctest が動作している例を見てみましょう。

func TestWithDeadlineAfterDeadline(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        deadline := time.Now().Add(1 * time.Second)
        ctx, _ := context.WithDeadline(t.Context(), deadline)

        time.Sleep(time.Until(deadline))
        synctest.Wait()
        if err := ctx.Err(); err != context.DeadlineExceeded {
            t.Fatalf("context not canceled after deadline")
        }
    })
}

これは少し見覚えがあるかもしれません。これは、以前に見た context.WithDeadline の素朴なテストです。唯一の変更点は、テストをバブルで実行するために synctest.Test 呼び出しでラップし、synctest.Wait 呼び出しを追加したことです。

このテストは高速で信頼性が高いです。ほぼ瞬時に実行されます。テスト対象システムの期待される動作を正確にテストします。また、context パッケージの変更も不要です。

synctest パッケージを使用すると、シンプルで Go らしいコードを記述し、それを確実にテストできます。

もちろん、これは非常に単純な例ですが、これは実際のプロダクションコードの実際のテストです。context パッケージが書かれたときに synctest が存在していれば、テストを書くのがはるかに簡単だったでしょう。

時間

バブル内の時間は、Go playground の偽の時間とほぼ同じように動作します。時間は協定世界時2000年1月1日午前0時に始まります。何らかの理由で特定の時間にテストを実行する必要がある場合は、その時までスリープするだけで済みます。

func TestAtSpecificTime(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       // 2000-01-01 00:00:00 +0000 UTC
       t.Log(time.Now().In(time.UTC))

       // This does not take 25 years.
       time.Sleep(time.Until(
           time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)))

       // 2025-01-01 00:00:00 +0000 UTC
       t.Log(time.Now().In(time.UTC))
   })
}

時間は、バブル内のすべてのゴルーチンがブロックされたときにのみ経過します。バブルは無限に高速なコンピューターをシミュレートしていると考えることができます。どのような計算量でも時間はかかりません。

次のテストは、どれだけ実際の時間が経過しても、テスト開始から偽の時間が0秒経過したことを常に表示します。

func TestExpensiveWork(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       start := time.Now()
       for range 1e7 {
           // do expensive work
       }
       t.Log(time.Since(start)) // 0s
   })
}

次のテストでは、time.Sleep 呼び出しは実際の10秒間待機するのではなく、すぐに戻ります。テストは、テスト開始からちょうど10偽秒が経過したことを常に表示します。

func TestSleep(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       start := time.Now()
       time.Sleep(10 * time.Second)
       t.Log(time.Since(start)) // 10s
   })
}

静止状態を待つ

synctest.Wait 関数は、バックグラウンドアクティビティが完了するまで待機することを可能にします。

func TestWait(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       done := false
       go func() {
           done = true
       }()

       // Wait for the above goroutine to finish.
       synctest.Wait()

       t.Log(done) // true
   })
}

上記のテストで Wait 呼び出しがなければ、競合状態が発生します。1つのゴルーチンが done 変数を変更し、別のゴルーチンが同期なしでそれを読み取ります。Wait 呼び出しがその同期を提供します。

データ競合検出器を有効にする -race テストフラグをご存知かもしれません。競合検出器は Wait によって提供される同期を認識し、このテストについて文句を言いません。Wait 呼び出しを忘れた場合、競合検出器は正しく文句を言うでしょう。

synctest.Wait 関数は同期を提供しますが、時間の経過は提供しません。

次の例では、1つのゴルーチンが done 変数に書き込み、別のゴルーチンが1ナノ秒スリープしてからそれを読み取ります。synctest バブルの外部で実際のクロックで実行された場合、このコードには競合状態が含まれていることは明らかです。synctest バブル内では、偽のクロックが time.Sleep が戻る前にゴルーチンが完了することを保証しますが、競合検出器は、このコードが synctest バブルの外部で実行された場合と同様に、データ競合を報告します。

func TestTimeDataRace(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       done := false
       go func() {
           done = true // write
       }()

       time.Sleep(1 * time.Nanosecond)

       t.Log(done)     // read (unsynchronized)
   })
}

Wait 呼び出しを追加すると、明示的な同期が提供され、データ競合が修正されます。

time.Sleep(1 * time.Nanosecond)
synctest.Wait() // synchronize
t.Log(done)     // read

例:io.Copy

synctest.Wait によって提供される同期を利用することで、より少ない明示的な同期でよりシンプルなテストを記述できます。

たとえば、io.Copy のこのテストを考えてみましょう。

func TestIOCopy(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       srcReader, srcWriter := io.Pipe()
       defer srcWriter.Close()

       var dst bytes.Buffer
       go io.Copy(&dst, srcReader)

       data := "1234"
       srcWriter.Write([]byte("1234"))
       synctest.Wait()

       if got, want := dst.String(), data; got != want {
           t.Errorf("Copy wrote %q, want %q", got, want)
       }
   })
}

io.Copy 関数は、io.Reader から io.Writer にデータをコピーします。io.Copy はコピーが完了するまでブロックするため、すぐに並行関数とは思わないかもしれません。しかし、io.Copy のリーダーにデータを提供することは非同期操作です。

  • Copy はリーダーの Read メソッドを呼び出します。
  • Read はデータを返します。
  • そして、データは後でライターに書き込まれます。

このテストでは、io.Copy がバッファを埋めるのを待たずに新しいデータをライターに書き込むことを確認しています。

テストを段階的に見ていくと、まず io.Pipe を作成して、io.Copy が読み取るソースとして機能させます。

srcReader, srcWriter := io.Pipe()
defer srcWriter.Close()

新しいゴルーチンで io.Copy を呼び出し、パイプの読み取り側から bytes.Buffer にコピーします。

var dst bytes.Buffer
go io.Copy(&dst, srcReader)

パイプのもう一方の端に書き込み、io.Copy がデータを処理するのを待ちます。

data := "1234"
srcWriter.Write([]byte("1234"))
synctest.Wait()

最後に、宛先バッファに目的のデータが含まれていることを確認します。

if got, want := dst.String(), data; got != want {
    t.Errorf("Copy wrote %q, want %q", got, want)
}

宛先バッファの周りにミューテックスやその他の同期を追加する必要はありません。synctest.Wait が、それが並行してアクセスされないことを保証するからです。

このテストはいくつかの重要な点を示しています。

io.Copy のような同期関数でも、戻った後に追加のバックグラウンド作業を行わない場合でも、非同期の動作を示すことがあります。

synctest.Wait を使用して、これらの動作をテストできます。

また、このテストは時間とは関係ありません。多くの非同期システムは時間を含みますが、すべてではありません。

バブル終了

synctest.Test 関数は、バブル内のすべてのゴルーチンが終了するまで待機してから戻ります。ルートゴルーチン(Test によって開始されたゴルーチン)が戻った後、時間の進行は停止します。

次の例では、Test はバックグラウンドゴルーチンが実行されて終了するまで待機してから戻ります。

func TestWaitForGoroutine(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        go func() {
            // This runs before synctest.Test returns.
        }()
    })
}

この例では、将来の時間に time.AfterFunc をスケジュールします。バブルのルートゴルーチンはその時間が来る前に戻るため、AfterFunc は実行されません。

func TestDoNotWaitForTimer(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        time.AfterFunc(1 * time.Nanosecond, func() {
            // This never runs.
        })
    })
}

次の例では、スリープするゴルーチンを開始します。ルートゴルーチンが戻り、時間の進行は停止します。バブルはデッドロック状態になります。なぜなら Test はバブル内のすべてのゴルーチンが終了するのを待っていますが、スリープ中のゴルーチンは時間が進むのを待っているからです。

func TestDeadlock(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        go func() {
            // This sleep never returns and the test deadlocks.
            time.Sleep(1 * time.Nanosecond)
        }()
    })
}

デッドロック

synctest パッケージは、バブル内のすべてのゴルーチンがバブル内の他のゴルーチンで耐久的にブロックされたためにバブルがデッドロック状態になったときにパニックを起こします。

--- FAIL: Test (0.00s)
--- FAIL: TestDeadlock (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]

goroutine 7 [running]:
(stacks elided for clarity)

goroutine 10 [sleep (durable), synctest bubble 1]:
time.Sleep(0x1)
    /Users/dneil/src/go/src/runtime/time.go:361 +0x130
_.TestDeadlock.func1.1()
    /tmp/s/main_test.go:13 +0x20
created by _.TestDeadlock.func1 in goroutine 9
    /tmp/s/main_test.go:11 +0x24
FAIL    _   0.173s
FAIL

ランタイムは、デッドロックされたバブル内のすべてのゴルーチンのスタックトレースを出力します。

バブル化されたゴルーチンのステータスを出力するとき、ランタイムはゴルーチンが耐久的にブロックされている時期を示します。このテストのスリープ中のゴルーチンが耐久的にブロックされていることがわかります。

耐久的なブロック

「耐久的なブロック」は synctest の中心的な概念です。

ゴルーチンは、ブロックされているだけでなく、同じバブル内の別のゴルーチンによってのみアンブロックできる場合に、耐久的にブロックされていると言われます。

バブル内のすべてのゴルーチンが耐久的にブロックされている場合

  1. synctest.Wait が戻ります。
  2. synctest.Wait 呼び出しが進行中でない場合、偽の時間は瞬時に次のゴルーチンをウェイクアップするポイントまで進みます。
  3. 時間の進行によってウェイクアップできるゴルーチンがない場合、バブルはデッドロック状態になり、テストは失敗します。

単にブロックされているゴルーチンと「耐久的に」ブロックされているゴルーチンを区別することが重要です。ゴルーチンがバブルの外部で発生する何らかのイベントによって一時的にブロックされているときに、デッドロックを宣言したくありません。

ゴルーチンが耐久的にブロックされないいくつかの方法を見てみましょう。

耐久的にブロックされないもの:I/O(ファイル、パイプ、ネットワーク接続など)

最も重要な制限は、ネットワークI/Oを含むI/Oが耐久的にブロックされないことです。ネットワーク接続から読み取っているゴルーチンはブロックされる可能性がありますが、その接続にデータが到着することでアンブロックされます。

これは、何らかのネットワークサービスへの接続では明らかに当てはまりますが、ループバック接続でも当てはまります。リーダーとライターの両方が同じバブル内にいる場合でもです。

ネットワークソケット、たとえループバックソケットであってもデータを書き込むとき、データは配送のためにカーネルに渡されます。書き込みシステムコールが戻ってから、カーネルが接続のもう一方の側にデータが利用可能であることを通知するまでの期間があります。Go ランタイムは、カーネルのバッファにすでに存在するデータを待機してブロックされているゴルーチンと、到着しないデータを待機してブロックされているゴルーチンを区別できません。

これは、synctest を使用するネットワークプログラムのテストは、通常、実際のネットワーク接続を使用できないことを意味します。代わりに、インメモリの偽物を使用する必要があります。

ここでは偽のネットワークを作成するプロセスについては説明しませんが、synctest パッケージのドキュメントには、偽のネットワークを介して通信する HTTP クライアントとサーバーをテストする完全な実行例が含まれています。

耐久的にブロックされないもの:システムコール、cgo呼び出し、Go 以外のものすべて

システムコールと cgo 呼び出しは耐久的にブロックされません。Go コードを実行しているゴルーチンの状態についてのみ推論できます。

耐久的にブロックされないもの:ミューテックス

おそらく驚くべきことですが、ミューテックスは耐久的にブロックされません。これは実用性から生まれた決定です。ミューテックスはグローバルな状態を保護するためによく使用されるため、バブル化されたゴルーチンは、そのバブルの外部で保持されているミューテックスを取得する必要があることがよくあります。ミューテックスはパフォーマンスに非常に敏感であるため、それらに追加の計測を追加すると、テスト以外のプログラムの速度が低下するリスクがあります。

ミューテックスを使用するプログラムを synctest でテストできますが、ゴルーチンがミューテックスの取得でブロックされている間は、偽のクロックは進みません。これまでに遭遇したケースでは問題にはなっていませんが、認識しておくべきことです。

耐久的にブロックされるもの:time.Sleep

では、耐久的にブロックされるとは何でしょうか?

time.Sleep は明らかに耐久性があります。なぜなら、バブル内のすべてのゴルーチンが耐久的にブロックされている場合にのみ時間が進むことができるからです。

耐久的にブロックされるもの:同じバブル内で作成されたチャネルでの送受信

同じバブル内で作成されたチャネルでのチャネル操作は耐久性があります。

私たちは、バブル化されたチャネル(バブル内で作成されたチャネル)と非バブル化されたチャネル(バブルの外部で作成されたチャネル)を区別しています。これは、グローバルチャネルを同期に使用する関数、たとえばグローバルにキャッシュされたリソースへのアクセスを制御する関数が、バブル内から安全に呼び出せることを意味します。

バブルの外部からバブル化されたチャネルを操作しようとするとエラーになります。

耐久的にブロックされるもの:同じバブルに属する sync.WaitGroup

sync.WaitGroup もバブルに関連付けられています。

WaitGroup にはコンストラクタがないため、Go または Add の最初の呼び出しでバブルとの関連付けを暗黙的に行います。

チャネルと同様に、同じバブルに属する WaitGroup で待機することは耐久的にブロックされ、バブルの外部からの待機はそうではありません。異なるバブルに属する WaitGroupGo または Add を呼び出すとエラーになります。

耐久的にブロックされるもの:sync.Cond.Wait

sync.Cond で待機することは常に耐久的にブロックされます。異なるバブル内の Cond で待機しているゴルーチンをウェイクアップするとエラーになります。

耐久的にブロックされるもの:select{}

最後に、空の select は耐久的にブロックされます。(ケースを持つ select は、その中のすべての操作がそうである場合、耐久的にブロックされます。)

これが耐久的にブロックされる操作の完全なリストです。それほど長くはありませんが、ほとんどすべての実世界のプログラムを処理するのに十分です。

ルールは、ゴルーチンがブロックされており、そのバブル内の別のゴルーチンによってのみアンブロックできることが保証できる場合に、ゴルーチンは耐久的にブロックされているということです。

バブルの外部からバブル化されたゴルーチンをウェイクアップしようとすることが可能な場合は、パニックを起こします。たとえば、バブルの外部からバブル化されたチャネルを操作することはエラーです。

1.24 から 1.25 への変更点

Go 1.24 で synctest パッケージの実験版をリリースしました。早期採用者がパッケージの実験的ステータスを認識できるように、パッケージを可視化するために GOEXPERIMENT フラグを設定する必要がありました。

これらの早期採用者から受け取ったフィードバックは、パッケージが有用であることを示すだけでなく、API の改善が必要な領域を発見する上でも非常に貴重でした。

以下は、実験版と Go 1.25 でリリースされたバージョンとの間で行われた変更の一部です。

Run を Test に置き換え

API の元のバージョンでは、Run 関数でバブルが作成されていました。

// Run executes f in a new bubble.
func Run(f func())

バブルにスコープされた *testing.T を作成する方法が必要であることが明らかになりました。たとえば、t.Cleanup は、バブルが終了した後ではなく、登録された同じバブル内でクリーンアップ関数を実行する必要があります。RunTest に名前変更し、新しいバブルのライフタイムにスコープされた T を作成するようにしました。

バブルのルートゴルーチンが戻ると時間が停止

元々は、バブルに将来のイベントを待機しているゴルーチンが含まれている限り、バブル内で時間を進め続けていました。しかし、time.Ticker から永遠に読み取るゴルーチンのように、長時間実行されるゴルーチンが戻らない場合に、これは非常に混乱することが判明しました。現在は、バブルのルートゴルーチンが戻ると時間の進行を停止します。バブルが時間の進行を待機してブロックされている場合、これはデッドロックと分析可能なパニックにつながります。

「耐久的」でなかったケースを削除

「耐久的にブロックされている」の定義を整理しました。元の実装には、耐久的にブロックされたゴルーチンがバブルの外部からアンブロックされるケースがありました。たとえば、チャネルはバブル内で作成されたかどうかを記録していましたが、どのバブルで作成されたかは記録していなかったため、1つのバブルが別のバブル内のチャネルをアンブロックできました。現在の実装には、耐久的にブロックされたゴルーチンがそのバブルの外部からアンブロックされる既知のケースはありません。

より良いスタックトレース

スタックトレースに出力される情報の改善を行いました。バブルがデッドロック状態になった場合、デフォルトでそのバブル内のゴルーチンのスタックのみが出力されるようになりました。スタックトレースは、バブル内のどのゴルーチンが耐久的にブロックされているかも明確に示します。

同時に発生するイベントのランダム化

同時に発生するイベントのランダム化の改善を行いました。元々、同じ瞬間に発火するようにスケジュールされたタイマーは、常に作成された順序で実行されていました。この順序は現在ランダム化されています。

今後の作業

現時点では、synctest パッケージにかなり満足しています。

避けられないバグ修正を除けば、現時点では将来的に大きな変更は予想していません。もちろん、より広範な採用によって、何かをする必要があると発見される可能性は常にあります。

考えられる作業領域の1つは、耐久的にブロックされたゴルーチンの検出を改善することです。ミューテックス操作を耐久的にブロックできるようにし、バブル内で取得されたミューテックスは同じバブル内から解放されなければならないという制限を設けることができれば素晴らしいでしょう。

synctest を使用してネットワークコードをテストするには、偽のネットワークが必要です。net.Pipe 関数は偽の net.Conn を作成できますが、現在、偽の net.Listenernet.PacketConn を作成する標準ライブラリ関数はありません。さらに、net.Pipe が返す net.Conn は同期です。すべての書き込みは読み取りがデータを消費するまでブロックされます。これは実際のネットワークの動作を代表するものではありません。おそらく、一般的なネットワークインターフェースの優れた偽の実装を標準ライブラリに追加すべきでしょう。

まとめ

以上が synctest パッケージです。

並行コードのテストを単純にするとは言えません。なぜなら、並行性は決して単純ではないからです。このパッケージができることは、Go らしいコードと標準の time パッケージを使用して、可能な限り単純な並行コードを記述し、そのための高速で信頼性の高いテストを記述することです。

お役に立てれば幸いです。

前の記事: コンテナ対応 GOMAXPROCS
ブログインデックス