- Go には goroutine とチャネルだけでは不自然になる並行性パターンがあり、コルーチンは並列性なしに実行フローを明示的に受け渡してプログラムの構造化を助ける提案である
- コルーチンは
resume と yield で実行権を受け渡しし、一度に 1 つだけが実行されるため、共有データのレースを避けつつ、切り替え地点が 同期地点 になる
- Python generator と CLU iterator はコルーチンに似ているが、
yield の位置が制限されているため、Lua 流の入れ子になった木の走査をそのまま移すと一部の値が失われる違いがある
- Go の
coro.New はチャネルと goroutine でも表現可能で、coro.Pull は push iterator を呼び出すたびに値を 1 つ取り出す pull iterator に変換する
- チャネルベースの実装は切り替えごとに約 190ns であり、ランタイム直接切り替えでは切り替えごとに約 20ns、
coro.Pull は値ごとに約 40ns まで削減され、実運用でのボトルネックを避ける水準を目指している
コルーチンの実行モデル
- コルーチンは関数呼び出しのように見えるが、互いに異なるスタックで実行され、同時には実行されない
F が G を開始しても G はすぐには実行されず、F が明示的に resume して初めて実行される
G は実行中いつでも yield によって F に実行権を返せる
G が return すると後始末され、F はもはや G を resume してはならないという合図を受け取る
- このモデルでは 一度に 1 つのコルーチン だけが実行され、呼び出し元は別のスタックで待機する
- 実行の切り替えはプログラム中の特定の地点でしか起こらないため、複数の流れが調整された形で交互に実行される
Lua の例で見るコルーチン
- Lua 5 の例は、構造の異なる 2 つの二分木が同じ値のシーケンスを持つかどうかを比較する
t1 と t2 は 1、2、3、4、5 を含む
t3 は 1、2、3、4、6 を含む
visit(t) は木を中間順で走査し、各値を coroutine.yield(t.value) で出力する
- 比較関数は 2 つの
visit コルーチンを作成し、交互に coroutine.resume して次の値を読む
- 2 つのコルーチンの終了状態または値が異なれば
false
- 両方とも終了すれば
true
- より慣用的な Lua コードでは
coroutine.wrap によってコルーチンオブジェクトを隠した next 関数を得る
- コルーチンが終了すると
next 関数は nil を返す
- 全体のコードは Gist にある
Python generator と CLU iterator の限界
- Python generator は Lua のコルーチンのように見えるが、同じモデルではない
- Lua の例を Python にそのまま移すと、
visit(t['left']) は実際の走査を実行せず、generator オブジェクト を作って捨てるだけになる
yield が関数本体にあると、def visit は通常の関数ではなく generator を定義する
- 単純翻訳の例では木から 4 しか出力されず、1、2、3、5 は失われる
- 正しい Python コードでは、入れ子の generator を明示的に反復しながら再度
yield しなければならない
- Python 3.3 の
yield from はこのパターンを簡潔にしてくれる
- Python generator オブジェクトは 1 回の
visit 呼び出しの状態しか保持しない
- ローカル変数の値と実行中の行が generator オブジェクトに保存される
- 再開されるとその状態が呼び出しスタックに載り、
yield で再び generator オブジェクトへ戻る
yield は最上位の呼び出しフレームでしか使えない
- CLU はこの抽象化を iterator と呼び、静的に
iter と proc を区別していた
- 型情報のおかげで、iterator を通常の関数のように誤って呼び出す使い方をコンパイラが診断できた
- Barbara Liskov らの 1977 年の論文 “Abstraction Mechanisms in CLU” は、iterator は制限された形のコルーチンであり、プログラムスタックだけで実装されると説明している
コルーチン、スレッド、generator の違い
- この 3 つの概念はいずれも何らかの形の並行性を提供するが、その能力とコストは異なる
-
コルーチン
- 並列性のない並行性を提供する
- あるコルーチンが実行中であれば、そのコルーチンを再開したコルーチンや、そのコルーチンに譲ったコルーチンは実行されない
- 切り替え地点が明示的であるため、データ共有でレースが発生しない
coroutine.resume や next 呼び出しのような切り替えが同期地点となり、happens-before edge を形成する
- OS を介さず明示的にスケジューリングされるため、切り替えはおおむね 10ns 以下まで可能である
-
スレッド
- コルーチンより強力で、その追加の力は並列性である
- その代償は、スケジューリングのオーバーヘッド、より高価なコンテキストスイッチ、そして何らかの形のプリエンプションの必要性である
- 一般的なスレッド切り替えは数マイクロ秒のオーダーである
-
Go goroutine
- この分類では、安価なスレッドに近い
- Go ランタイムがスケジューリングの一部を担うため、切り替えは数百 ns に近い
- スレッドのように並列性とプリエンプションを提供する
- Java の新しい lightweight thread も本質的には goroutine と同じである
-
Generator
Go でコルーチンが必要なケース
- Go の既存の並行性ライブラリは、コルーチンパターンを直接提供していない
- goroutine はしばしば十分に近いが、並列性とプリエンプションがあるため、コルーチンとは異なる結果を生むことがある
- Rob Pike による 2011 年の発表 “Lexical Scanning in Go” は、
text/template パッケージの初期の lexer と parser の設計を扱っている
- lexer と parser は別々の goroutine で実行され、チャネルで接続されていた
- これはコルーチンのペアを不完全に模倣した構造だった
- lexer は parser が直前のトークンを処理している間に次のトークンを先読みしていた
- generator は、複数の関数から値を
yield しなければならない lexer には十分ではなかった
- goroutine の並列性はレースを生み、最終的には lexer の状態をオブジェクトに保存する設計へ変更された
- 適切なコルーチンがあれば、レースを避けつつ goroutine より効率的だったはずである
- 将来のユースケースとして generic collection の走査 がある
- Go では 関数に対する range サポート が議論されたことがある
- これにより、コレクションや抽象化の作者が CLU 風の iterator 関数を提供するよう促せる可能性がある
- Go では現在でも関数値を使って push iterator を実装できる
- 例:
func (t *Tree[V]) All(yield func(v V))
- 現在は
t.All(func(v V) { fmt.Println(v) }) のように呼び出せる
- 将来的には
for v := range t.All という形が可能になるかもしれない
- 単一の
for ループに収まらない走査が問題になる
- 二分木の比較のように、2 つの走査を互いに差し挟まなければならない場合がある
- コルーチンは
(*Tree).All のような push iterator を、呼び出しごとに値を 1 つ返す pull iterator に変換できる
純粋なGoで表現した coro.New
- Goにコルーチンを追加するなら、言語変更なしで可能であるべきで、通常のGoコードとして理解・実装できる必要がある
- 単純な
coro.New はチャネルとgoroutineで表現できる
cin は入力値を渡す
cout は出力値を返す
resume は cin に値を送り、cout から結果を待つ
- 新しいgoroutineは最初は
<-cin でブロックしているため、並列実行の機会はない
yield を追加すると、f は実行中に値を出力でき、呼び出し側は次の resume で再び値を入れられる
yield(out) は cout に値を送り、cin で次の入力を待つ
- これも send-receive の組なので、並列性はない
- この通信パターンは goroutine が コルーチンのように動作 するよう制約する
- 実際にはgoroutineだが、
resume と yield が切り替え演算の役割を果たす
文字列パーサーの例
- “Storing Data in Control Flow” の問題は、
func parseQuoted(read func() byte) bool を別の制御フローで実行し、バイトを Write メソッドで1つずつ供給すること
coro.New を使えば、前の記事の一時的なチャネルベース実装より高水準に書ける
Init は coparse 関数を定義する
read は NeedMoreInput を yield したあと、呼び出し側が送ったバイトを返す
parseQuoted(read) の boolean 結果は BadInput または Success に変わる
p.resume(0) は parseQuoted の最初の read まで進める
Write(c byte) は p.resume(c) を呼び出す薄いラッパーになる
- 全体のコードは Go Playground にある
素数ふるいの例
- Doug McIlroy の 並行素数ふるい は、各素数
p ごとに1つのコルーチンを置くパイプライン
- 各フィルターは左隣から数を受け取り、
p で割り切れなければ右隣へ渡す
- 左端の counter は 2, 3, 4, ... を供給する
- 右端の出力コルーチンは素数を読んで出力し、新しいフィルターコルーチンを作る
counter は値を yield するループを coro.New で包んだ関数
more bool が生成を続けるかどうかを伝える
yield(i) は値を出力し、次に続行するかどうかを受け取る
filter(p, next) は左側コルーチンの next(true) から値を取り、n%p != 0 のときだけ yield(n) する
main は現在のパイプライン出力を next に保持する
- 素数
p を読む
p を出力する
p の倍数を除去する新しいフィルターをパイプライン右側に追加する
- コルーチン間の呼び出し関係は実行中に変わりうる
- counter の最初の
yield は main に行くが、その後の yield は 2-filter に行く
- 各
p-filter の最初の出力は次の素数として main に行き、その後の出力は次のフィルターに行く
- 全体のコードは Go Playground にある
goroutineとコルーチンの関係
- ここで作った制御フローは、厳密にはgoroutine
- mutex、channel、system call 待ちなど、通常のgoroutineができることはすべてできる
coro.New は、yield と resume の中でコルーチン切り替え演算を使えるgoroutineを作る
go 文は新しい同時・並列な制御フローを作るが、coro.New は新しい同時・非並列な制御フローを作る
go 文を10個実行すると、mainを含めて11個のgoroutineが同時に実行できる
coro.New を10回呼ぶと制御フローは11個になるが、プログラムの並列性はそのままで、一度に1つしか実行されない
- どのgoroutineが「非並列」コルーチンの役割を担うかは、実行中に変わりうる
- これは、実行中にどのgoroutineがchannel送信・受信中かが変わりうるのと同じ
より堅牢な resume
- 初期の
coro.New は、関数が終わったあとに resume を呼ぶとデッドロックが発生する
- これを修正するため、
resume は結果とともに bool を返す
true は結果が yield から来たことを意味する
- 関数が return すると、
resume は戻り値と false を返す
- コルーチン終了後に
resume を呼ぶと、zero value と false を返す
running 変数は f が実行中かどうかを追跡する
resume とコルーチンは交互に実行されるため、running の共有はレースではない
- 例では
"hello" true, "world" true, "done" false, "" false を出力する
coro.Pull によるイテレーター変換
coro.Pull は push iterator を pull iterator に変換する
- 入力 push iterator の形は次のとおり
push func(yield func(V) bool)
yield の boolean 戻り値は継続するかどうかを表す
- 目標の pull iterator の形は次のとおり
pull func() (V, bool)
- channel receive や map lookup のように、値と反復終了の有無を返す
- 早期中断のため、
Pull は pull だけでなく stop も返す
- 実装は、push iterator を実行する小さな wrapper を
coro.New で作れば十分
pull は resume(true) を呼ぶ
stop は resume(false) を呼ぶ
- ツリーの
All メソッドは、yield の bool 結果を使うように変更される
- 左の走査、現在値の yield、右の走査を
&& でつなぎ、早期中断を伝播する
- ツリー比較関数は2つの
coro.Pull を作り、値を1つずつ比較する
defer stop1() と defer stop2() で早期終了時にコルーチンを停止する
- 値または終了状態が異なれば
false
- 両方とも終われば
true
- 全体のコードは Go Playground にある
panic の伝播とキャンセル
- コルーチンで発生した panic は、そのコルーチンを直近で
resume した呼び出し元へ返せる
- 通常の goroutine では、どの goroutine に知らせるべきか、その goroutine が受け取る準備ができているかを把握しにくい
- コルーチンでは呼び出し元が
resume でブロックして待っているため、panic を渡す相手が明確になる
- 実装では
cout によって、値または panic を含むメッセージを渡す
- 新しいコルーチンの
defer が panic を捕捉する
- 待機中の
resume は同じ panic 値で再び panic する
- 例ではコルーチンが
"hello" を yield した後、"world" で panic する
- panic は main goroutine へ伝播し、スタック上では
resume 呼び出しで発生したように見える
- 全コードは Go Playground にある
- 呼び出し元が早期終了するときにコルーチンへ知らせるため、
cancel 関数が追加される
cancel は resume に似ているが、yield が値を返す代わりに panic するようにする
- キャンセル panic は
ErrCanceled を満たす固有の error ラッパーを使う
cancel が引き起こした panic は再伝播しないが、キャンセル中にコルーチンが別の panic を起こした場合は伝播する
resume がまだ呼ばれていなければ、cancel は f がまったく実行されないようにする
- iterator の中断には panic より明示的な
bool の方が分かりやすいため、Pull は bool ベースの中断を維持する
ふたたび素数のふるい: 後始末とエラー伝播
- 新しい API では
counter と filter が resume 関数と cancel 関数を一緒に返す
primes(n int) は counter を作り、defer cancel() を登録する
- 各素数を読み出して表示する
- 新しい filter を追加するたびに、その filter の
cancel も defer で登録する
- 関数が
n 個の素数を得て返ると、遅延された cancel 呼び出しが生成済みのコルーチンを片付ける
- どれかのコルーチンが panic すると、待機していたコルーチンへ伝播する
primes が next で直接再開したコルーチンなら、panic は primes に戻る
- filter が
next で再開したコルーチンなら、panic は filter チェーンに沿って primes の p := next(true) まで上がってくる
- その後、
primes の遅延された cancel が残りのコルーチンを片付ける
- 全コードは Go Playground にある
最終的な API の形
New は新しく一時停止されたコルーチンを作り、関数 f を実行する準備をする
- 新しいコルーチンは goroutine だが、自分からは実行されない
- 別の goroutine が
resume または cancel を呼んで待っている間だけ実行される
resume(in) は呼び出し goroutine を停止し、新しいコルーチンへ切り替える
- 最初の呼び出しは
f(in, yield) を開始する
f が yield(out) を呼ぶか out を返すまで、resume はブロックされる
yield が呼ばれると、resume は out, true を返す
f が返ると、resume は out, false を返す
- 次の
resume(in) によって、ブロックされていた yield は in を返すようになる
cancel は f の実行を止め、コルーチンを終了させる
resume が一度も呼ばれていなければ、f は実行されない
- そうでなければ、ブロックされていた
yield は ErrCanceled を満たす error で panic する
f が recover されない panic を起こすと、その panic は resume または cancel で待っている goroutine へ移り、同じ値で再び panic する
- ただし
cancel は、自分が引き起こしたキャンセル panic を再 panic しない
f が返るか panic すると、そのコルーチンはもう存在しない
- 以後の
resume 呼び出しは zero value と false を返す
- 以後の
cancel 呼び出しはそのまま返る
resume、cancel、yield は別の goroutine に渡して使える
- その結果、どの goroutine が「コルーチン」なのかは動的に変わりうる
New は新しい goroutine を作るが、常に 1 つの goroutine が resume、cancel、yield、または初期待機状態でブロックしているという不変条件を置く
- この不変条件は
f が返るまで保たれる
- 結果として
coro.New は新しい並行性を作るが、新しい並列性は作らない
- 最終シグネチャは次のとおり
func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) (Out, bool), cancel func())
効率性
- 純粋な Go 実装でコルーチンを定義できるべきだが、実運用には最適化されたランタイム実装が必要になる
- 2019 年製 MacBook Pro では、チャネルベースの
coro.New は値の往復で切り替え 1 回あたり約 190ns かかる
coro.Pull では値 1 つあたり約 380ns になる
coro.Pull は iterator の標準的な使い方ではない
- 標準的な方法は iterator を直接呼び出すことで、この場合コルーチンのオーバーヘッドはない
coro.Pull は、単一の for ループではなく値を段階的に処理する必要があるときに必要になる
- 最初の最適化の試みは、コンパイラが send-receive の組を目印付きで出力し、ランタイムがそれを 1 つの演算にまとめられるようヒントを残す方式である
- チャネルランタイムがスケジューラを迂回し、別のコルーチンへ直接ジャンプできる
- 切り替え 1 回あたり約 118ns、pull された値 1 つあたり約 236ns かかる
- 元のチャネル実装より 38% 高速
- 2 番目の実装はチャネルを完全に避け、ランタイムに直接コルーチン切り替えを追加する
- コルーチン切り替えは atomic compare-and-swap 3 回まで減る
- 1 つはコルーチンのデータ構造、1 つはブロックされるコルーチンの scheduler status、もう 1 つは再開されるコルーチンの scheduler status に使われる
- 切り替え 1 回あたり約 20ns、pull された値 1 つあたり約 40ns かかる
- 元のチャネル実装より約 10 倍高速
- 値 1 つあたり 40ns は、
coro.Pull が必要なコードにおいてボトルネックにならないほど小さい絶対コストと見なされる
1件のコメント
Hacker News のコメント
ここで肝心な点を見落としている人が多いように思う。コルーチンライブラリが
goキーワードよりも並行性を扱ううえで、より悪く面倒な方法だというのはその通り。この複雑さを持ち込む実際の用途は 関数イテレータ で、
func() (T, bool)型の関数にrangeを使えるようにすること。Go コミュニティでは長く議論されており、ほとんどの Go プログラマにとって意味は直感的だろう。この記事はその次の問題、つまり関数イテレータが言語に追加されるとして、
forループで使うイテレータをどう書くかを扱っている。プッシュイテレータは書きやすいことが多いという点から始めて、プッシュ・プルアダプタ へと拡張し、そのアダプタがコルーチンの上に構築される。もし全部入るなら、反復以外の用途でコルーチンを使うのは、ミューテックスで十分なところにチャネル/ゴルーチンを使うのと同じような悪い慣行になりそうだ。
2つの作業が論理的に同期的に協調する場合、たとえばイテレータなら、同じ CPU 上で全部実行するほうがはるかに効率的だ。カーネルが何かを再スケジューリングしたり、CPU コアを止めたり起こしたりする必要もなく、データも CPU キャッシュに収まり続けるので、キャッシュレイテンシ とヒット率が改善する。
ゴルーチンでもたまたまそうなることはあるが保証はなく、少なくとも Go ランタイムのゴルーチンスケジューラを経由するコストがある。高速ではあっても、同じゴルーチン内で別のコードコンテキストを実行するほど速くはない。
コルーチンでは、タスク A がタスク B に直接切り替わると分かるので、スケジューリング動作がより予測しやすい。記事の最後で Russ は、最適化されたランタイムのコルーチン実装が、ゴルーチンでまねたものより 10倍高速 だと示している。
Google には内部的にこうした協調的マルチスレッディングを実装したカーネルパッチがあり、内部では fibers と呼ばれている。より良いレイテンシと予測可能なスケジューリングのために存在する。Paul Turner が約 10 年前の LPC でこの動機を説明した発表もある: https://www.youtube.com/watch?v=KXuZi9aeGTw
for { next := getNext(); ... }と書けば何が問題なのか分からない。for next := range getNext { ... }と書く利点が何なのか気になる。rangeやswitchを使い、値をチャネルに流し込むゴルーチンを 1 つ走らせればよいのではないかと思う。やはりコルーチンがなぜ必要なのか納得できない。今回は慎重な思考と優れた 80/20 の解決策ではなく、「これをきちんとやるにはコルーチンが必要そうだから、そのまま入れよう」というふうに見える。
ジェネリクスを入れたときは本当に長く深く考え抜かれていて、バランスの取れた革新的な妥協案が出てきた。
ここでも、「特定の状況で制御できるようにゴルーチンへ機能を 1 つ追加する」といったアプローチを期待していた。「この問題に対して Rust のように全面的に進めてそのまま追加する」より良かっただろう。
Go を何年も職業として使ってきたが、Python の Twisted / Tornado / その他のフレームワークのような姿になってほしくはない。
goキーワードはかなり厄介な 関数の色付け問題 をうまく防いでくれている。高性能が求められる文脈では CPU コアごとのデータシャーディングのようなことをしたくなるが、この提案はそういうかゆい所には手が届いていない。
async/awaitのようなものを持ち込もうという話は含まれていない。コルーチンは Python の ジェネレータ に近い別の領域を埋めるはずだ。組み合わせ可能なコンポーネントのパイプラインをつなぎたい場面では、メモリ使用量とコードの複雑さを大きく減らせることが多い。設計は完全に同期的なものに見える。
Contextを使わなければならないなら、それこそ文字通り関数の色付けだ。もちろん、世界のどこかの賢い開発者がこれでばかなものを作り、それが大流行する可能性はほぼ確実にある。私の予想では ゴルーチンとコルーチンを 1 つに抽象化 することになる。
たいていゴルーチン関連のコードはチャネルのせいで散らかった感じになり、どうすればもっと「きれい」にできるのか分からない。保守可能だと実証されたパターンのヒントがあれば本当にありがたい。
マルチタスクシステムは私たちにプロセスをもたらした。
しかしそれは重すぎた。
そこで、アドレス空間やファイルテーブルなどを共有するプロセス、つまりスレッドが登場した。スケジューラはプロセス間よりもスレッド間のほうが容易に切り替えられ、スレッド間でのデータ共有にはシリアライズも不要だった。
しかしそれでも重すぎた。
そこでユーザー空間スレッドが登場した。ランタイムが完全にユーザー空間で駆動する論理的な実行スレッドである。ランタイムは標準ライブラリのすべての入出力関数にスケジューリング用フックを入れたり、UnixシグナルのようなシステムAPIで論理スレッドをプリエンプトしたりする。システムレベルのコンテキストスイッチが不要で、非常に小さくできる。
しかしそれでも重すぎた。
そこでコルーチンが登場した。プログラマが、互いに協調的に相互作用する論理的な「スレッド」を定義できるようにする。スケジューラの存在は前提としない。プログラマが自分でイベントループを書くか、ライブラリのイベントループを「本物の」論理スレッドから呼び出す。
次に何が来るのか気になる。[通信順次プロセス][1]の観点では、協調的コルーチンが到達しうる最下層なのかもしれない。
[1]: https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf
並行性を支える意味論が必要になる。たとえば走査はデフォルトで順序が定まっていないべきだし、プログラム全体のフロー解析にも依存するが、原理的な障害は見当たらない。
Microsoftが数年前にそういう言語を研究していなかっただろうか。Adaコミュニティの人が作ったParaSailもある。こうしたプロジェクトはどうなったのか、誰にも使われていないのか気になる。
グリーンスレッドの本質は、Pythonの
yieldのようなキーワードを使わずに、うまい協調的スケジューリングを得ることだと思っていた。Goの「呼び出し地点と特定位置に再開地点を挿入する」という設計判断は、とても良い妥協だと思った。
ますますハードウェア寄りの制御を露出させている。ある時点から、ただZigを作り直しているだけなのではないかと思えてくる。次はオプションのガベージコレクタだろうか?
yieldとresumeはキーワードではなく普通の変数だ。教育目的でそう名付けられた、ただのクロージャ参照にすぎない。興味深いのは、キャンセルコールバックを作って使っている部分だ。Goはあまりやっていないので、それがドロップされたイテレータ状態を早めに回収したいという性能改善なのか、それともチャネルで待機しているゴルーチンがそのチャネルへの唯一の参照を持っているときにGoがそのゴルーチンをガベージコレクトしないため必要なのかは分からない。
Luaならこういうものは不要だ。コルーチン/スレッドも他のオブジェクトと同じようにガベージコレクトされるので、すべての参照が消えれば、最後の操作がエントリ関数の return ではなく
yieldだったとしても回収される。GOGC=offを設定すればガベージコレクションを無効化できる。GOGCの詳細: https://dave.cheney.net/tag/gogc
コルーチンはライブラリよりも言語サポートとして入るほうが望ましいと思う。
x := co func(){ var z int; for { z++; yield z } }のような形や、それに準ずるものを思い浮かべる。純粋なGoだけでも可能だというのはすばらしいし、言語仕様を複雑にするより、最適化されたランタイムを伴う標準ライブラリパッケージとして提供したいという魅力も理解できる。結局、純粋なGoで可能なら他の実装も素早くブートストラップできる。
毎日
$workでGoを使っている者としては、どちらでも歓迎だが、より言語組み込みの方向を好む。Goの並行性プリミティブは常に強みだったのだから、その方向を押し進めればよい。ライブラリによる解決策なら、そのコルーチンを殺すかプログラムを落とすしかない。スタックが透過的に伸びてほしいなら、それは生成されたコードにしかできない。スタック使用量を監視し、必要なときに伸ばさなければならないからだ。ゴルーチンにはそういう仕組みがあると理解している。
もしかするとライブラリ解決策でも、スタックの末尾にガードページを置くことはできるかもしれない。そこに達したらエラーハンドラでスタック拡張を試みられる。しかし、スタック変数へのポインタを保持している場合は、おそらく動かないだろう。
yieldパラメータ**を追加するほうが好みだ。関数が値を yield するには、いわゆる
yieldパラメータを持たなければならず、同じ yield シグネチャを持つ関数か、シグネチャを持たない関数の中でしか yield できないようにする方式だ。例は
x := func(:z int) { for { z++; :- z } }のような形に変えられるかもしれない。:は yield シグネチャを追加し、:-は値を yield する。値
Xだけを yield する関数は、: Xまたは: name Xだけを持てばよい。再開時に型Yの値を受け取るなら、シグネチャは:[Y] Xまたは:[Y] name Xに変わる。受容は緩くするべきだ。
Yで再開しXを yield する関数を期待する場所では、再開なしでXだけを yield する関数も受け入れるべきだ。coパッケージの特別な関数群がresumeとNewの機能を提供すれば、Goらしさを保てる。range構文は-:で再開値を渡す形に拡張でき、値を渡さない場合はデフォルトのゼロ値で再開すればよい。あまり気に入らない。例を見ると、言語を読んで追いかけるのがずっと難しくなるように思える。もちろん、自分の頭と偏見のせいかもしれないが
しかも、現在のブロッキングチャネルや状態でできないことを可能にしてくれるようにも見えない
コアなコードパスで割り当てを避けるために、この記事に出てきたものに近いイテレータを使ったことがある。この方式なら、そういうコードはずっと不自然さが減るだろう。特に、もうすぐ入る
rangeイテレータの言語変更と組み合わさればなおさらだチャネルは、ブロッキング操作を包むのでない限り、特に理由もなく遅い
コメントを読んでいて苦々しい気分になる
多くの人がコルーチンとグリーンスレッドをほぼ同じものとして扱っているが、どちらにも長所と短所がある
Goコミュニティでイテレータの欠如が受け入れられうるという事実が悲しい。単純さを名目に、言語を少しでも複雑にしうる機能は意図的に拒否しているように見える。それでも、少なくともジェネリクスに対する立場は撤回した
やはりGoは自分の言語ではないのだと改めて思う
今は少し使っているが、呼び出し側での構文上の利便性が多少よくなるからだ。定義側では予想通り相変わらず不格好で、ただGoの文法は他の言語で見たものの中ではほぼ最もましな部類だ
結局、「ジェネリクスがない」という不満を抑えるために単純さをかなり失ったと思う。いい取引ではなかった
記事で提案された
coroパッケージがそのまま追加されるだろうか。ありうるが、おそらく正確にそのままではないだろう。似たような何かが追加されるだろうか。賭けてもいいなら、そうだと言うどれくらいかかるだろうか。少なくとも1年、つまり2024年8月のGo 1.23リリースごろだと思う。もう少しかかるかもしれない。それよりずっと短いのは難しいと思う
ジェネリクスがコミュニティで受け入れられたのは、既存コードと完全に後方互換で、不要なら安全に無視できるからだ
予想通り、Goのコードの大半はいまだにそうしている。さまざまな種類の「コレクション」を除けば、ジェネリクスの実用的な用途はそれほど見つけやすくない。そもそも大半のコードは複数の型を扱うことがなく、2種類を超えればすでに珍しい
むしろジェネリクスの追加は、人々が「必須」だと主張する称賛された機能が、優れた言語において実際にはどれほど不要でありうるかを、より大きなコミュニティに示した
今ごろになってCLUのようなプログラミング言語に関心を向けるのは良いことだと思う
一方で、.NETとC++のコルーチン、それにSymbian C++とActive OberonのActive Objectを使った経験からすると、これを本当にGoに追加する価値があるのかは確信が持てない
.NETチームも今年のBUILDで認めていたように、時間を巻き戻せるなら、ランタイムがGoスタイルで処理するほうがよかっただろう。多くの開発者が今でもasync/awaitの理解に苦労しているからだ
これが本当に必要なのかよくわからない。Goのほとんどのケースはゴルーチンで十分処理できるし、
yield/resumeのセマンティクスにはブロッキングチャネル2つで足りる複雑さのための複雑さを加えているように見えるし、すでにGoになかった新しい能力を本当に追加しているのかもはっきりしない
比較対象として、最近の発表でElixir(BEAM VM)スレッド100万個を起動し、全員に
"Hello!"メッセージを送り、その後それぞれのスレッドが0〜2秒のランダムな時間だけ待ってから"Process received message !"を返すデモがあった同時にErlang observerを横に表示し、CPUとメモリ消費、そしてガベージコレクション後にどれだけ早く回復するかを観察していた
ここで最大のボトルネックはターミナルが追従する能力だが、observerは実際の状況をかなり正確に反映しているようだ
https://www.youtube.com/watch?v=yxyYKnashR0
使用したコード: https://gist.github.com/pmarreck/4cc8f2f55a561ebce2012085a3a...
こうした機能は1980年代からErlangに、したがってElixirにも内蔵されていた。アクターモデルやErlangの「伝説的な」実装についてはよく耳にするが、監視ツールを並べて実際に見たことのある人がどれほどいるのかはわからない
Goがこうした言語レベルのサポートを提供するとよいのだが、BEAM VMのスレッド実装は、生成時にも実行時の消費の面でも極めて資源効率が高く、不変値だけを許すことから来る並行性の扱いやすさまで組み合わさっているので、到底追いつくのは難しそうだ