🪄

Goのiterの知らなくても良いこと

2024/08/23に公開

本記事ではGoのiter(イテレーター)についての「知らなくても良いけど知っていたらイザという時に助かるかもしれないこと」を紹介します。iterの基本的な使い方等は説明しませんので、以下のmattnさんによる記事を参照してください。

https://zenn.dev/mattn/articles/641f1d86fffdc9

Goのiterの実現方法

では本題。Goのiterとはコルーチンです。Goにおけるコルーチンの導入はRuss CoxのCoroutines for Goという記事にその分析と設計が書かれています。同記事の日本語による拙作のメモがありますので気になる方はそちらもご参照ください。

https://zenn.dev/koron/articles/38affd1a0f00cc

Russ Coxはこの記事において、コルーチンとはコールスタックの分離と任意の場所への付け替えだ、と分析しました。またgoroutineとチャンネルを用いてコルーチンを実装し、満たすべき機能的要件を明らかにし、また性能的要件からGo言語本体に必要な変更を示しています。この機能と性能の要件は最新のGo 1.23において実現され、利用できるようになりました。

このコルーチンの実装には特殊なgoroutineが用いられました。何が特殊かというと、通常の実行スケジューリングがされない点が特殊です。あくまでもコルーチンを実行する必要がある時だけ、つまりコルーチンから値を受け取る時にだけ実行されます。これは裏を返すと今までには存在しなかったタイプのgoroutineが新たに追加されたということでもあります。良くないことに、このタイプの違いは外からは区別が付きにくく、普通は遭遇することのない問題に遭遇する場合があります。

以下ではそういった問題を2つ紹介します。

t.Helperが正しく機能しないことがある

コルーチンから値を取る際にはコルーチン用の特殊goroutineが実行されます。するとそのgoroutineが持っている(分離された)コールスタックが既存のスタックに追加・接続されます。mattnさんの記事で説明されている通り、この接続されるコールスタックとはiter.Seq[V]でありfunc(func(V)bool)というれっきとした関数のものです。そのため以下のコードで示すcaller関数内の、for rangeを実行している最中はcallee関数のスタックが挟まるのです。

func callee() iter.Seq[int] {
	return func(yield func(int) bool) {
		for i := range 10 {
			if !yield(i) {
				return
			}
		}
	}
}

func caller() {
	for i := range callee() {
		// ここではcalleeのスタックが挟まっている
		println(i)
		if i == 5 {
			panic("aborted by panic!")
		}
	}
}

参照: スタックが挟まることを確認できるコード全体

意図せぬ関数のスタックが挟まることで問題になるのがtesting.T.Helper()(以下t.Helper)です。test.T.Error()はテスト失敗のログを出力する関数ですが、それを呼び出した関数の名前と位置をメッセージと一緒に出力します。これはテスト用のヘルパー関数を用いた場合は都合が悪く、そのヘルパー関数を呼び出した箇所を出力してもらった方が、エラーの位置が分かりやすくて助かります。t.Helperはそれを実現する関数です。

t.Helper関数の実際の動作は、自身が呼び出された関数のスタックを覚えておき、ログ出力時にコールスタックのトップがそれらの覚えておいたスタックに一致した場合に、スキップして1つ下のスタックをエラー表示に利用するというものです。ヘルパー関数の先頭でt.Helperを呼び出しておけば、ヘルパー関数内で発生したエラーログに付与されるコードの位置は、t.Helperを呼び出したものに置き換えられるわけです。

しかしfor ... range iter.Seq[V]内に、コルーチン提供側の関数のスタックが挟まると、このt.Helperの仕組みが機能しなくなり、ヘルパー関数の位置がログに付与されます。以下のコード例でいうと、testIterHelperヘルパー関数のt.Fatalfの呼び出しがそのまま示されます。

// イテレーターの実装: 0からn-1までの数字をイテレートする
func newIter(n int) iter.Seq[int] {
	return func(yield func(int) bool) {
		for i := range n {
			if !yield(i) {
				return
			}
		}
	}
}

// 渡されたイテレーターが0からlast-1までの数字を返すことをテストする
func testIterHelper(t *testing.T, it iter.Seq[int], last int) {
	t.Helper()
	want := 0
	for got := range it {
		if want != got {
			t.Errorf("unexpected want=%d got=%d", want, got)
		}
		want++
		if want >= last {
			t.Fatalf("over the last: last=%d", last)
		}
	}
}

func TestRangeFunc(t *testing.T) {
	testIterHelper(t, newIter(10), 10) // OK
	testIterHelper(t, newIter(5), 6)   // わざと "over the last" で失敗させるためのテスト
}

この現象は正確にはコルーチン提供側の関数のスタックだけではなく、for ... range iter.Seq[V]{}内が暗黙の関数として実現されており、その暗黙の関数のスタックもコールスタックに挟まることで発生しています。ですからループ内でもt.Helperを呼び出したとしても足りません。なぜならコルーチン提供側の関数のスタックがあるからnewIter関数内のyield関数の呼び出しの位置がエラーメッセージに付与されることになります。コルーチン側でt.Helperを呼び出すことはできませんから、t.Helperをfor ... range iter ...と組み合わせた上で正しく機能させる方法はGo 1.23時点では存在しません。

ヘルパー関数内でiterをテストしたい場合はfor ... rangeではなく、slices.Collect()を使って配列として取り出してから比較する、などの工夫が要ります。

参照: t.Helperが機能しないことを確認できるコード

コルーチンを呼ぶとpanicすることがある

iter=コルーチンは特殊なgoroutineで実行されると説明しました。つまりiterの呼び出し側とは異なる2つのgoroutineが関わることになります。これによりGoにおいて唯一といって良い「goroutineを識別する必要のある」runtime.LockOSThread()(以下LockOSThread)が問題を起こす可能性が生じてきます。

LockOSThreadは現在のgoroutineを実行するOSのスレッドを固定します。ということはコルーチン実装側と呼び出し側の2つのgoroutineが、それぞれ別々のスレッドに対して固定されていたとしたら…どうなるのでしょう?

正解はpanicします。以下はその2つのgoroutineの実行条件を判定してpanicしているGoのランタイムのコードです。コルーチン側と呼び出し側とでスレッドの固定条件が少しでも食い違っていた場合にはpanicするという、相当に厳しい条件になっています。ですからiterとLockOSThreadを組み合わせて使うのは、Go 1.23時点では諦めたほうが良いでしょう。

// Track and validate thread-lock interactions.
//
// The rules with thread-lock interactions are simple. When a coro goroutine is switched to,
// the same thread must be used, and the locked state must match with the thread-lock state of
// the goroutine which called newcoro. Thread-lock state consists of the thread and the number
// of internal (cgo callback, etc.) and external (LockOSThread) thread locks.
locked := gp.lockedm != 0
if c.mp != nil || locked {
	if mp != c.mp || mp.lockedInt != c.lockedInt || mp.lockedExt != c.lockedExt {
		print("coro: got thread ", unsafe.Pointer(mp), ", want ", unsafe.Pointer(c.mp), "\n")
		print("coro: got lock internal ", mp.lockedInt, ", want ", c.lockedInt, "\n")
		print("coro: got lock external ", mp.lockedExt, ", want ", c.lockedExt, "\n")
		throw("coro: OS thread locking must match locking at coroutine creation")
	}
}

以下に示すコードはコルーチン開始後にLockOSThreadを呼ぶだけでpanicする例です。iter.Pullはプッシュ型のコルーチンをプル型にするための関数です。このiter.Pullを呼び出した時点でコルーチン用の特殊goroutineが作成され、その実行条件が「スレッドを指定しない」と定まります。その後LockOSThreadを呼び出すことで、現在実行中の通常のgoroutineの実行条件が「スレッドを固定する」に変更されます。こうなったあとにnext()を読んでコルーチン用の特殊goroutineへ実行を切り替えようとすると、実行条件が現在のものとコルーチン作成時のものとは異なってしまっているために、上で示したGoのランタイムコードに書かれた通りにpanicしてしまうというわけです。ややこしいですね。

func main() {
	// プッシュ型のイテレーターをプル型に変更する
	// この時点でコルーチン用の特殊goroutineができる
	next, stop := iter.Pull[int](newIter(10))
	defer stop()
	// 現在のmのスレッドロック状況が特殊goroutine作成時とは変わったため
	// コルーチン呼び出し時のチェックにひっかかりpanicする
	runtime.LockOSThread()
	for {
		v, ok := next() // 実際にはココでpanicする
		if !ok {
			break
		}
		println(v)
	}
}

参照: runtime.LockOSThreadによりコルーチンがpanicする実行可能なコード

まとめ

本記事で取り上げた、iter=コルーチンの特殊な実現方法による問題点は、普通に使っている分には遭遇確率は低いと考えられます。しかし遭遇することは充分ありえますので、そんなときに本記事を思い出していただけたら幸いです。

Discussion