Flavor Wheel Engineering

珈琲とソフトウェアエンジニアリング

Goによる独自スクリプトでテストケースを記述するテスト手法紹介

本記事は、はてなエンジニア - Qiita Advent Calendar 2024 - Qiitaの11日目の記事です。昨日は、 id:Windymelt さんの esbuildでScala.jsをビルドして呼べるようにするプラグインを作った話 - Lambdaカクテル でした。

Goでユニットテストを書くときは、Table Driven Tests が頻繁に使われています。Table Driven Test によって一定程度読みやすいテストコードを書くことができますが、入力の数が多かったり比較項目が複雑になるとアサーション部分で条件分岐が起きてしまい書きにくくなることがあります。 そこで柔軟にテストケースを記述できる script-based test cases を導入したテスト手法を紹介したいです。 script-based test cases とは、 research!rsc: Go Testing By Example で紹介された文字通りスクリプトによってテストケースを書くことです。

実際の例をみたほうが理解しやすいでしょう。次のスクリプトは、go.dev / golang.org のウェブサーバの script-based test cases の例です。

GET https://go.dev/blog/
body contains The Go Blog
header Content-Type == text/html; charset=utf-8

GET https://go.dev/blog/
body contains The Go Blog
header Content-Type == text/html; charset=utf-8

GET https://golang.google.cn/blog/
body contains The Go Blog
header Content-Type == text/html; charset=utf-8

GET https://go.dev/blog/2010/08/defer-panic-and-recover.html
redirect == /blog/defer-panic-and-recover

website/cmd/golangorg/testdata/blog.txt at 622d04828be7fdec816d529fe994d9021ebf907d · golang/website · GitHub より引用

各行にスペース区切りでコマンドと引数が書かれています。 字面から推測できる通り1行目は、HTTPメソッドの種類とリクエストURLを指定してます。つまり、GETメソッドで https://go.dev/blog/ へのリクエストをテストせよという意味です。 続く2~3行目がアサーション部分に当たります。レスポンスボディに The Go Blog の文字列が含まれ、かつヘッダーに Content-Type == text/html; charset=utf-8 があることをチェックせよという意味です。 テストケースは、空行で分けられます。

go test によってテストを実行すると、内部的には、前述のスクリプトに従ってリクエストを組み立て httptest.NewRecorder()ServeHTTP() を組み合わせてシミュレートします。そして、アサーション部分に沿ってレスポンスのチェックを行います。1 スクリプトを使ってリクエストの組み立てとアサーションを書いていること以外は、一般的なHTTPサーバーのテスト手法と同じです。 リダイレクトのチェックやレスポンスボディの内容チェックなど、テスト内容がケースごとに異なるので Table-Driven Tests では書きにくいテストですが、スクリプトでテストケースを宣言することでシンプルに記述できていると思います。

そろそろ script-based test cases を書きたくなったころではないでしょうか? 実は、script-based test cases を簡単に書けるようにするためのパッケージが rsc.io/script として公開されてます。 github.com

rsc.io/script を使った script-based test cases の例

script-based test cases を試すためサンプルとして1から引数で渡した値まで fizzbuzz を出力するだけのCLIツールを作りました。

github.com

$ ./fizzbuzz 5
1
2
Fizz
4
Buzz

このfizzbuzzのコマンドの引数とそれに対する標準出力の結果が期待どおりかを確認するテストをrsc.io/script を使って書きました。 rsc.io/script によるテストは、 scripttest.Test() メソッドに *script.Engine オブジェクトとテストケースの保存場所を指定して呼ぶことで行われます。script Engine オブジェクトがコマンドに応じた処理をする本体です。

func TestAll(t *testing.T) {
    ctx := context.Background()
    engine := script.NewEngine()
    env := os.Environ()
    scripttest.Test(t, ctx, engine, env, "testdata/*.txt")
}

script.NewEngine() は、デフォルト値を埋めた*script.Engineを返すパッケージ関数です。 script.Engine 構造体は、ログ出力の有無を表すQuiet フラグとマップ型のCmdsフィールドとCondsフィールドを持ちます。Cmds はコマンド名、Conds は条件名を文字列型のキーとし、値には、そのキーに対応する処理を実行する関数が入ります。 条件は、[Cond] の形で書かれたもののことです。例えば [!GOOS:windows] stop と書くことで GOOS=Windows 以外のときに、そのファイルに書かれた以降のテストケースの読み込みを中断してテストをスキップできます。

自前のコマンドや条件を書けるようにしたいときは、マップ型のCmdsフィールドやCondsフィールドに要素を追加するだけです。 コードを読んだところ script.Command()script.Condition() 経由で作るのが想定されているようでした。 デフォルトで定義されるコマンドは DefaultCmds() を、条件は DefaultConds() からコードを追えば理解できます。

rsc.io/script には、デフォルトで Linux コマンドの cat と同等のコマンドやPATHに含まれるバイナリを実行する exec コマンドは定義されていますが、自作の fizzbuzz をテストするためのコマンドは当然提供されていません。そのため、デフォルトのコマンドに加えて独自の run コマンドを定義しました。run コマンドの実態を fizzbuzz/fizzbuzz_test.go at 324dd888c4d7f85b9871f1419675c831d3f6b185 · tomato3713/fizzbuzz · GitHub で定義して、それを engine.Cmds["run"] に代入することで実装しました。

run コマンドは、os.Args に引数をそのまま渡して fizzbuzz のmain関数を実行します。つまり、例えば run 3 と書いたら fizzbuzz 3 を実行した場合と同等の処理が実行されます。 標準出力のテストは、rsc.io/script が cmp コマンドを提供しているので、そのまま利用しました。cmp コマンドはファイルの比較を行います。1番目の引数がテスト対象の処理によって実際の結果、2番目の引数に期待する結果の意味です。1番目の引数に stdout または stderr と書くことで標準出力、標準エラー出力を指定できます。

定義した run コマンドと cmp コマンドを使った script-based test cases の1例を以下に示します。 case.txt は 末尾に txtar 形式で宣言したファイルを表します。

run 3
cmp stdout case.txt 

-- case.txt --
1
2
Fizz

テストケース全体は fizzbuzz/testdata/a.txt at main · tomato3713/fizzbuzz · GitHub を見てください。

単純な引数だけだとあまり面白みはありませんが、オプションが増えてくると script-based test cases の柔軟さが活きてくるでしょう。 script-based test cases は滅多に使うことがないかもしれませんが、柔軟性が高くテストケースを記述できる強力な手法です。 頭の片隅に覚えておいても損はないと思います。

はてなエンジニア - Qiita Advent Calendar 2024 - Qiita の明日の担当は、 id:maiyama4 さんです。


  1. website/internal/webtest/webtest.go at 335437c0d7e4044d277e91edda5208c1d9df4977 · golang/website · GitHub がリクエストをシミュレートしている部分の起点なようでした。

1on1で話すことの変化

tomato3713.hatenablog.com

を書いてから気がつくと1年ほど経っていました。 このエントリーに同僚から返信エントリーが即あったのも良い思い出です。

blog.stenyan.jp

taxintt.hatenablog.com

どちらも良いエントリーなので、未読の方は一度読むことをお勧めします。 僕はたまに読み返しています。

丁度一年で良いタイミングなので、最近の1on1の様子を書きます。 当時は、目の前の課題やアウトプットをどう行うかなど自分に閉じた話題を話していました。 今は少しだけ課題の範囲が広がって、チームやエンジニアとして関わっているプロジェクト、サービスについて考えている課題を言葉にすることを1on1の時間にしています。 「課題を言葉にして説明する」というと純粋な技術なようにも思えますが、なかなか難しいです。 ぼんやりと生きてるなという気分になります。

この話題の変化は、目の前のタスクをこなすことで精一杯だった当時から1年経って周りを見ることができる余裕が生まれるようになったということでしょう。

以上、着地点がないスナップショット的な文章でした。

Goで引数に無いtestingオブジェクトを参照してないことを保証するリンター

テストヘルパーで testing.TB のオブジェクトを渡していることを保証するためのリンターを作りました。 なお、テストケースを詳細に準備していないので検出漏れがある可能性があります。 github.com

具体的には、次のように一時的に定義されたテストヘルパー内で t.Error()t.Log() 呼んでいるが、サブテストに紐づくtを参照してない箇所を見つけます。 この間違いによって失敗したサブテストを正しく報告できなくなることに加えて、人によるレビュー以外ではテストが失敗するまでは間違いに気が付きにくいのでリンターで気がつけるようにしてみました。

func Test_a(t *testing.T) {
    assert := func(a, b int) {
        if a != b {
            t.Error("no match")
        }
    }

    t.Run("abcd", func(t *testing.T) {
        assert(1, 2)
                // テスト失敗しているが、t を適切に渡せていないので PASS: Test_a/abcd と出力されてしまう
    })

}

パッケージ直下に作られる関数については特にチェックしていません。 testing.TB オブジェクトをパッケージグローバルに定義して使うようなコードを書くことは、ほぼ無いと考えているためです。

面白かったところ

任意 interface を満たすかどうかをチェックする処理

Go は静的解析を実装するためのツールは揃っていますが、静的解析ツールを実装した経験が少ないと細かな実装方法が直ぐにわからないことがあります。 例えば、インターフェイスを満たしているかのチェック方法をGoogle検索すると Error インターフェイスを満たすことを調べるコード例はでてきますが、任意のパッケージのインターフェイスを満たすかどうかをチェックする方法は簡単には見つけられませんでした。 そのため、GitHub のコード検索でヒットしたコードを参考にして実装しました。

testing.TB interfaceを表す *types.Interface を検索するコード exttlinter/exttlinter.go at 3f784141436e19f0a007f3853ae0a290695f4cf8 · tomato3713/exttlinter · GitHub

ある識別子が testing.TB interfaceを満たすかのチェックを行うコード exttlinter/exttlinter.go at 3f784141436e19f0a007f3853ae0a290695f4cf8 · tomato3713/exttlinter · GitHub

Goの標準パッケージに暗号的にランダムな文字列生成関数を追加する提案の議論まとめ

github.com

パスワードやBearer token、2FAコードに利用できるような暗号論的にもランダムな文字列を返す関数を追加するという提案です。 承認済み(Accepted)の提案であり具体的な追加時期は決まっていませんが将来的に追加される予定です。

承認された仕様は https://github.com/golang/go/issues/67057#issuecomment-2261204789 にある Russ Cox氏のコメントで確認できます。 生成される文字列に含む文字種別が RFC 4648 base32 固定になったこと以外は、ほぼ当初の提案から変わっていません。

// Text returns a cryptographically random string using the standard RFC 4648 base32 alphabet
// for use when a secret string, token, password, or other text is needed.
// The result contains at least 128 bits of randomness, enough to prevent brute force
// guessing attacks and to make the likelihood of collisions vanishingly small.
// A future version may return longer texts as needed to maintain those properties.
func Text() string

この提案の議論を読んで面白いと思った点は、次のことです。

  1. メソッド名がTextと一般的であること
  2. 返されるランダムな文字列の長さが固定長(128 bit)であること
  3. 文字種別にRFC 4648で定義されているbase32のアルファベットが採用されたこと

1. メソッド名がTextと一般的であること

承認された仕様を見たときの感想は、Base32のアルファベットを使ったランダムな文字列を返すという実装にしては名前が一般的過ぎないかでした。 実際、Issue コメント中でも一般的すぎる名前なので、rand.Base32()rand.Token128() のような名前のほうが良いのではという意見もありましたが、rand.Text() が良い名前として受け入れられています。

この理由について同僚と催しているProposalを読む会 1の会話で rand.Text() はランダムな文字列を生成することが役割なのであって文字種別は本質的には関係がないからだろうという推測をしていました。 もし、将来的にコンピュータの計算性能が向上するなどして現在の仕様よりもエントロピーを増やさないと暗号論的に安全でないとなった場合に、文字長や文字の種類などを変えて対応できる余地を残しているということです。

メソッドの役割を中心にしていて、かつ将来の変更にも耐えられるように備えた良い命名だと思います。 普段の実装でも参考にしたいです。

2. 返されるランダムな文字列の長さが固定長(128 bit)であること

普通なら bit 数を引数で渡すような実装にしてしまうだろうところを固定にして、それ以外の長さの場合は自分で対応しましょうと決断した点に感心します。 128 bit 以外の長さを利用したい場合は、次のような数行程度のメソッドを定義すれば実現できます。

func Text256bit() string {
    return rand.Text() + rand.Text()
}
func Text64bit() string {
    return rand.Text()[:64]
}

これを利用者側でやらせて、ライブラリ側をシンプルに保ちデフォルトを安全に倒すのは難しい判断です。 このバランスのとり方は、Goの設計の好きなところです。

3. 文字種別にRFC 4648で定義されているbase32のアルファベットが採用されたこと

文字種別については Issue コメント中で色々な意見がかわされていました。 生成される文字列に使われる文字種別を引数として渡す案が議論さていましたが、文字種を固定にした場合に得られる実装のシンプルさやユーザーが間違った利用をして脆弱性を生む可能性が検討された結果、文字種別が固定になりました。 文字の種類は、大文字と小文字や読み間違えやすい文字が混在してないこと、セキュリティ的に十分な128bitを表すのに26文字になり適度に短いことからBase 32が選ばれました。 表示時の視認性に対する考慮は、2FA/MFAでの利用などでユーザーが視認して入力するときのことを考慮してのことのようです2。 Base 32以外の文字を含むランダム文字列が求められるケースでは、専用のメソッドを自分で定義するので標準パッケージにrand.Text() が追加されても便利にならないだけで悪影響はないことも理由の1つです。

必要最小限の仕様で利用可能なものを初めに追加して使われ方を確認して、一般化していくのはGoで良く行われる仕様追加の手順だと思います。

まとめ

今までGoで暗号的にランダムな文字列を得るには crypt/rand パッケージを使って自分で実装するしかありませんでしたが、標準パッケージにあるものを使えば良いとなれば安心して使えるようになるので嬉しい提案でした。 もしBase32 以外の文字種別でランダムな文字列を生成するときがあれば実装の参考にもしたいです。


  1. 同僚と行っているGo本体や標準パッケージに対する提案を読む会のことです。
  2. https://github.com/golang/go/issues/67057#issuecomment-2161221119 前後のコメント

Coroutines for Go を読んだメモ

Russ Cox氏が書かれたresearch!rsc: Coroutines for Goを読んだときのメモです。 range over func の提案 spec: add range over int, range over func · Issue #61405 · golang/go · GitHub のdescriptionから参照されている記事であり、Coroutine for Goを読むことでイテレータの使いどころや求められるシチュエーションの理解が深められるだろうとの予想で読みました。

概要

  • Goにコルーチンがなぜ必要なのか、そもそもコルーチンとは何か
  • 関数Fが関数Gを呼ぶということを考えたときのサブルーチンとコルーチンの違い
    • サブルーチンは、サブルーチン呼び出しごとにスタックに積まれて1つずつ関数が実行される。
    • 一方、コルーチンは、それぞれのコルーチンが異なるスタック上で実行されるが、同時に実行されるのは常に1つのコルーチンのみである。各コルーチンは、実行状態と待機状態を切り替えつつ実行される。

LuaPythonイテレータを使ったコードでの比較

  • 2つの二分木で作られた値を木の構造に関わらず比較するコード
  • Lua の例はコルーチンを使って比較してる
  • Python の例は厳密にはコルーチンではないので単純な移植ではいけない。CLUの方式と同じらしい。

コルーチン、スレッド、ジェネレータ

  • これら3つは何らかの形で Concurreyncy 平行性を提供する
    • コルーチン
      • 並列処理なしで平行性を提供する
      • 1つのコルーチンAを実行中は、Aを再開したコルーチンやAに実行を譲ったコルーチンは実行されない。一度に1つのコルーチンが実行される。プログラム内の特定のタイミングでコルーチンが切り替わるので、競合することなくデータを共有できる。
        • スケジューリングは明示的(プリエンプションなし)でOSなしで実行されるので起動、終了がスレッドよりはるかに軽量
        • コルーチンの切り替えには、約10ナノ秒かかる。
      • 並列処理ではなく、プログラムの構造に平行性が必要なときに便利な構成要素
    • スレッド
      • 並列処理ができるが、コンテキストスイッチと何らかの形で行うプリエンプションの追加にコストがかかる。
      • 通常、OSがスレッドを提供する。スレッド切り替えには数マイクロ秒かかる。
      • Goの goroutine は安価なスレッド。
        • Goランタイムがスケジュール作業の一部を行うので切り替えは数百ナノ秒近くになるが、スレッドの完全な並列処理とプリエンプションを提供するためスレッドと分類できる。
    • ジェネレータ
      • コルーチンの最上位のフレームのみが制御を譲ることができるので、コルーチンよりパワーが低い。

Goでコルーチンを使用する理由

  • goroutine とは違う平行性パターンで、goroutineではやりすぎな場合にコルーチンを利用したくなる
    • 例えば、text/template パッケージのlexer と parserの実装
      • はじめは goroutine とチャンネルで実装したが、並列性によって競合が発生してしまうので、コルーチンのシミュレータオブジェクトを実装した
      • 適切なコルーチンなら goroutine よりも効率的で競合も回避できたとのこと
        • 一番に導入されるのここだろうか。
  • Goにおけるコルーチンの将来的な使用例
    • ジェネリックコレクションの反復処理
      • https://github.com/golang/go/discussions/56413 によってコレクションやその他の抽象化の作成者がCLUのような反復関数を提供するように促さられる
      • 二分木の比較のような単一のループでは行えないようなコレクションの反復処理では、複数の繰り返し処理をイテレータがあれば低いコストで行える

Goでコルーチンを実装する方法

  • ランタイムで実装するとしても、その形はGoコードでも実現できる必要がある
  • 実際に並列な実行はされないが、コルーチンと同様に呼び出しをかける例

      package coro
      func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) Out) {
    
          cin := make(chan In)
          cout := make(chan Out)
          resume = func(in In) Out {
              cin <- in
              return <-cout
          }
          yield := func(out Out) In {
              cout <- out
              return <-cin
          }
          go func() { cout <- f(<-cin, yield) }()
          return resume
      }
    
      ---
    
      package main
    
      func main() {
          resume := coro.New(strings.ToUpper)
          fmt.Println(resume("hello world"))
      }
    
    • この例では、 coro.New() でコルーチンを準備し、戻り値の resume() を呼び出すと準備したコルーチンが再開され、コルーチンから yield() が呼ばれて結果が返される

例: 文字列パーサー、素数

コルーチンを使ったコード例の紹介をしている。

コルーチンとゴルーチン

  • 上記のコルーチンのシミュレーションは、goroutine であってコルーチンとは完全には言えない。
    • より大きな範囲をカバーできるスレッドを使って実装しているんだから当然だよね
    • yield()resume() で他のコルーチンを再開、譲渡をしている
    • コルーチンは新しい並列制御フローを作るが、どのgoroutineが非並列であるかはプログラム実行中に変わりうる。どのごルーチンがチャネルから受信 or 送信するかがプログラム実行中に変わる可能性があるから

Robust Resumes

  • コルーチン再開処理を実世界で利用できるようにする
  • 1つめは resume() を実行すると処理が終わるまでdeadlockしてしまうので、コルーチンを再開して1つ進んだら戻ってくるようにする
    • resume() に bool 戻り値を追加して、実行中かどうかを返す
    • goroutine が完了したかどうかがわかるようになる
    • 完全なコード例
      • https://go.dev/play/p/Y2tcF-MHeYS
        • L33 で定義、実行されている無名関数がコルーチンを並列実行している
          • だけど、実際にはバッファなしのチャンネルで値をやり取りしているから、resume() を呼び出すごとにyield() でチャネル cin / cout で値を受信、送信するタイミングで待機、実行が切り替えつつ処理が進む。
            • resume() に引数を渡しているけど、この引数は反復を続けるかどうかを表す。このサンプルコードでは利用してないが、次のイテレータ変換では登場する。
              • Go 1.23 の range over func ではこのあたりがおそらくラップされている
          • yield() が全部呼び出し終わったら fが実行終了して、running = false になって全処理が終わることを resume() から返すようになる。
      • ここまでくると、Go 1.23 で導入されたイテレータ、range over func とほぼ同じ形になってる

例: イテレータ変換

Propagating Panics

  • panic の伝搬といったところ
  • 一般的なgoroutineの場合は、パニックの伝搬は困難な問題である。
    • 理由は2つ
      • どのgoroutineにパニックの情報を伝えたら良いのか決められない
      • そのgoroutineがパニックを伝搬される準備ができているのか判断できない
  • ただし、コルーチンの場合は、呼び出し元はブロックされて再開を待機している状態なので、パニックを伝えることは論理が通っている
    • どのgoroutineにパニックを伝えたら良いのか決められるということ
    • そして、仕様としてあるならパニック伝搬を受け取る準備ができているとも考えてよいということだろう
  • サンプルコード
    • https://go.dev/play/p/Sihm8KVlTIB
      • 実際にはmainと異なるgoroutineで実行されているのにコルーチン内のpanicがmainに伝搬されて、mainでパニックしたように出力される

Cancellation

  • propagating panics はコルーチンが早期に終了したことを呼び出し元に通知する
  • では反対に、呼び出し元からコルーチンに対して早期に呼び出し終了されたことを伝えるにはどうしたらよいか
  • cancel() を coro.New() から返すようにする
    • https://research.swtch.com/coro#cancel
    • cancel() はキャンセルされたことを示すエラーをcinに入れてyieldがパニックする。
    • つまり、mainでキャンセルするとコルーチンは実行できないのでコルーチンがパニックして終了する。
    • サンプルコードでは色々とやっているが、それはpropagating panics とcancel を両方扱うためにやっている
      • cancel を呼び出されたらコルーチンは早期呼び出し終了となって呼び出せなくなる
        • なので、キャンセル後に resume() を呼んだら値ではなくパニックする
          • このパニックはコルーチンがキャンセル済みであるという情報をもったパニックとして呼び出し元に伝搬させたい
      • キャンセル中にコルーチンが別の理由でパニックを起こしたときも呼び出し元にパニックを伝搬させたい

Example: Prime Sieve Revisited

前節のコルーチンの中断、つまりキャンセルを素数計算で使った例の紹介

API

  • ここまで改善をしてきたコルーチンのAPIの纏め
    • Newは、関数fの実行を待機するコルーチンを新規に作る
      • 新規コルーチンは、自分では実行しないgoroutineである。
        • 他のgoroutineが resume や cancel を呼び出すことによって実行を待機、復帰する
      • ある goroutine は、resume(in) を呼ぶことによって自身を一時停止し、新しいコルーチンにスイッチできる。
        • resumeの最初の呼び出しは、f(in, yield) を開始する。
        • resume は、fの実行中ブロックする。
          • ブロックされる期間は、fが yield(out) を呼び出す、または、out を返却するまでである。
          • fが yield を呼んだら、yieldは(コルーチンを)ブロックしoutとtrueを返して再開する。
            • yield によって戻り再開したなら、次のresume(in)呼び出しで yield が in を返すとともに、fに切り替わる。
          • fが return すると、outとfalse を返して再開する。
    • Cancel はfの実行を止め、コルーチンを終了する。resume が呼ばれていなければfは完全に実行されない。そうでなければ、cancel によってブロック中の yield 呼び出しが errors.Is(err, ErrCanceled) を満たすエラーとともにパニックするようになる。
      • fがパニックを回復しなければfのコルーチンを停止しfを待つgoroutineで再開される。
        • cancelは、fのパニックがcancel自身が引き起こしたものであるなら、再パニックを起こさない。
    • 一度でもfがreturnするかパニックしたなら、そのコルーチンはもう存在しない。
      • その後のresumeの呼び出しはzeroとfalseを返す。その後のキャンセルの呼び出しは、単純にreturnするだけ。
    • resume, cancel, yield は異なるgoroutine間で渡され、使われることができる。
      • 実用上、どのgoroutineがコルーチンであるかを動的に変更できる。
      • Newは新しいgoroutineを作ることができるが、resume、cancel、yield、またはNewの直後にfを呼び出すresumeを待つために1つのgoroutineが常にブロックされるという普遍性を確立する。
        • この普遍性は、fがreturnするまでか新しいgoroutineが終了するまで続く。
        • その最終結果として、coro.New は新たな並列性 parallelism を持つことなくプログラムに平行性を生み出す。
    • 複数のgoroutineが resumeやcancelを呼び出すのなら、それらは単純に連続して呼び出される。複数のgoroutineがyieldを呼ぶなら、それらの呼び出しは直列化される。

Efficiency

  • 理解可能な純粋なGoによるコルーチンのリファレンス実装を持っておくことは重要だが、ランタイム実装で効率化すべきだと信じている。
    • 自分の MacBook Pro 2019上で、チャネルによるコルーチン実装(ここまで解説されてきたもののこと)は値をやり取りするとスイッチごとに約190ナノ秒、coro.Pullで値ごとに380ナノ秒かかる。
      • coro.Pullはイテレータを利用する標準的な方法ではないことに注意
      • coro.Pullは繰り返す値を段階的に処理する場合にのみ必要で単一のforループでは使わないため
      • それでもcoro.Pullをできるだけ高速化したい
  • 送信、受信を1つの処理に統合するようにした
    • コンパイラに送信受信のペア(チャネルのことだろう)をマークして、ランタイムにヒントを残す
    • チャネルランタイムはスケジューラをバイパスして直接他のコルーチンにスイッチできるようになる
      • この実装ならスイッチのために約118ナノ秒、pullされた値ごとに236ナノ秒(38%高速化)になる
      • 著者は、汎用的なチャネルを利用しているためにオーバーヘッドが生じていて、より早くできると考えている
  • 次にチャネルをやめてランタイムに直接コルーチンスイッチを追加した
    • コルーチンの切り替えが3つのアトミックな比較とスワップ(コルーチンデータ構造に1つ、ブロッキングコルーチンのスケジューラステータスに1つ、再開コルーチンのスケジューラステータスに1つ)に削減される
    • 保持しないといけない安全性の普遍条件を考えて最適であると考えている
    • スイッチごとに20ナノ秒、pullされた値ごとに40ナノ秒かかる
      • 元のチャネルによる実装より10倍高速

感想

メモリ効率性みたいな話もあるけど、第一にコルーチンが求められるのは、実際の実行において並列性は不用だが、制御フローとしては並列性があると助かる場面でスレッドに比較してスイッチングコストがかなり低いので高速になることなんだろうな。

Efficiency でコルーチンのために最適化した実装をした結果10倍も早くなってるのランタイムの支援ってすごいというのと、これを実装してしまうのがすごい。

コルーチンが複数あっても瞬間瞬間で実行されているのは1つだけというのがわかった。 イテレータがどう処理されていくのかイメージできるようになった気がする。

Go 1.23 で導入予定のイテレータでLINQ的な検索の実装を試した

最近は Go 1.23 で導入予定のイテレータを試して使い方を探っています。1 そのなかでイテレータを使ってLINQ的なことができないかを試したところ、良さそうな形になったので紹介します。

LINQは、C#Visual Basic、F#などの.NET系の言語でサポートされている様々なデータソースに対するクエリ機能のことを指します。 LINQを使うと LINQ の概要 - .NET | Microsoft Learnの冒頭にある例のような単純なクエリ構文を使ってデータの変換や検索などが記述できます。

Go 1.23で導入予定のイテレータLINQと同様に連続したデータに対して統一的で簡潔なインターフェイスを与える仕組みなので、イテレータを使うとLINQ的な実装がうまく記述できそうという見込みがありました。

試すために最小限のメソッドしか準備しておらず、実装途中ですがライブラリ形式に纏めてみました。 github.com

使い方はサンプルコードを見るとわかりやすいかと思います。 go-linq/example/select/main.go at main · tomato3713/go-linq · GitHub

Go 1.23 はまだリリースされていないので実行する際はリリース前の機能を簡単に試せる gotip を使います。 結果は次のようになります。宣言時の要素から Where() による要素のフィルタや OrderBy() によるソート、Select() による各要素の加工がされた結果が出力されています。

$ cat example/select/main.go 
package main

import (
        "fmt"
        "github.com/tomato3713/linq"
        "slices"
)

func main() {
        list := []int{5, 3, 10, 1, 33, 4, 12, 11, 15}

        cond1 := func(v int) bool { return v > 5 }
        cond2 := func(v int) bool { return v < 30 }

        cmp := func(a, b int) int { return a - b }

        selector := func(item int) int { return item * 2 }

        q := linq.New(slices.Values(list))

        for v := range q.
                Where(cond1).      // -> []int{10, 33, 12, 11, 15}
                Where(cond2).      // -> []int{10, 12, 11, 15}
                OrderBy(cmp).      // -> []int{10, 11, 12, 15}
                Select(selector) { // -> []int{20, 22, 24, 30}
                // output
                // 20
                // 22
                // 24
                // 30
                fmt.Println(v)
        }
}
$ gotip run example/select/main.go 
20
22
24
30

やっていることは単純で linq.New()で作った iter.Seq[T]と同等の型である query 型のオブジェクトを作り、その query 型をレシーバー及び戻り値に持つメソッドをメソッドチェーンのように連続して呼び出しています。

iter.Seq[T] の形をしていれば汎用的に扱えることやメソッドチェーン的な呼び出しができることでコードの見通しは良いと考えています。 一方、パフォーマンス的な観点では、例えば OrderBy() は処理内容的に仕方がないですが一度全ての要素をスライスに落としてからソートしているのでイテレーターにしたことによるパフォーマンス的な恩恵は受けきれていないかもしれません。

q := linq.New(slices.Values(list))
for v := range q.Where(cond1).Where(cond2).OrderBy(cmp).Select(selector) {
    fmt.Println(v)
}

Go 1.23 がリリースされてイテレータが一般的に利用されるようになるのは、まだ先のことですが色々なイテレータの利用アイディアが見られるのを楽しみにしています。

Goのtestingパッケージに対する一時的なディレクトリ移動のためのテストヘルパー関数の提案まとめ

testing: add TB.Chdir #62516

github.com

一時的にカレントディレクトリを移動してテスト終了と共に元のディレクトリに移動するヘルパー関数 Chdir(dir string) を testing package に追加する提案を読んだまとめです。 ちょうど3日前の 2024/06/25 に Accepted されました。

具体的な利用シーンには、プログラムの実行箇所に依存するカレントディレクトリの設定ファイルを読み込む関数のテストなどが挙げられます。

ディレクトリを移動したテストを書きたい時はあまり多くはなさそうですが、ディレクトリを移動したのにテスト終了時に元のディレクトリに戻り忘れる、ディレクトリを移動するテストを並列で実行したなどでテストがFlakyになってしまうなんてことはやりそうです。 提案によって追加される testing.Chdir を使わない場合、ディレクトリの戻り忘れについては提案のdescriptionにあるような t.Cleanup() で元のディレクトリに戻る処理を行う関数を自分で定義して使うようにすれば防げますが、テストが並列で実行されていることについては並列で実行されているかを知るためのインターフェイスがないので良い対処方法はなさそうです。

並列テストで起こる問題については提案のdescriptionでも言及されており、Accept された仕様1では対策がなされています。 その対策とは、並列テスト内でtesting.Chdir(dir string) が呼ばれた場合には内部で t/b.Fatal() を呼び出すことです。 つまり、並列テスト内で使った場合にはテストが必ず落ちるようになるため気がつけます。 そのため testing.Chdir() を利用したテストを気がつかずに並列にしてしまうことは起こりません。 testing package 内部であればテストが並列であるかを t.isParallel boolフィールドを参照することで判定できます。

この提案の議論で面白いことは、Russ Cox による標準のtesting packageに追加する価値があるだけの利用頻度の高さや重要性があるかの調査結果をまとめたコメントです 2。 調査内容は次の通りです。全部で8,756,664件の _test.go ファイルから os.Chdir の利用箇所 (全8,756,664ファイルのうち0.27%の23,963ファイル)を検索し、その中から更にランダムに選んだ20ファイルについて提案が役立つかを調べています。

結果は、ランダムに抜き出した20ファイルのうち15ファイルでは os.Chdir を使うことが必要であり、15ファイルのうち5ファイルではos.Chdirでディレクトリ移動後に元のディレクトリに戻る復帰処理が漏れているため他のテストを壊す恐れがあったと述べています。結論では、os.Chdir をテストで利用する頻度は高くはないが問題が起こった場合に修正が困難であるため testing package に一時的にディレクトリを移動するためのヘルパー関数を追加する価値があるとしていました。

ちなみにコードの検索には google/codesearch で公開されている grep と似たコマンドが用いられていました。