4-chan Goプログラマ
(dolthub.com)- DoltHubは、Goの並行処理でよく使われるチャネルをわざと過剰に入れ子にし、チャネルをチャネルで送るジョーク的な例を作成
- 実際に引き継いだコードには
chan chan struct{}があり、ワーカー goroutine に新しいチャネルを渡す ファンアウト (fan-out) パターン に使われていたが、推論と管理が難しく書き直された - この例は、C系の
int****という「4-star programmer」のジョークを Go のchanに拡張したもので、_4chan:= make(chan chan chan chan int)を最上位チャネルとして使う factor = 3では各チャネル階層ごとに producer と consumer を分岐させ、最後のint値を合計して 3の5乗である243 を出力する- 実運用では、実装・デバッグの難しさ、チャネル終了処理、
sync.WaitGroupの必要性、goroutine リーク のため不向きで、この例は終了ロジックの代わりにtime.Sleep()に依存している
Doltで登場したチャネル入れ子の事例
- DoltHubは、世界初のバージョン管理 SQL データベースである Dolt を Go で開発している
- 一般的な Go コードベースと同様に、並行実行の実装に channels と goroutines を使っている
- 並行プログラミングはそれ自体が難しいため、通常はチャネルと goroutine を シンプルで直感的な方法 で扱う
- かつて別のオープンソースプロジェクトから持ち込まれたコードには、次のような「チャネルを送るチャネル」があった
var c chan chan struct{}
- この構造は、goroutine 間でチャネルを受け渡してワーカー goroutine の ファンアウトパターン を実装する方式だった
- 中間チャネルは、新しく生成されたチャネルを実際の作業を行うワーカーへ渡す仲介役を果たす
- 動作はしていたが、特に goroutine リーク まで考えると推論も取り扱いも難しかった
- そのコードは書き直され、
chan chan struct{}は姿を消した
「4-star programmer」ジョークのGo版
- Cとその派生言語が広く使われていた時代には、ポインタの理解に苦しむ初心者を指して「4-star programmer」というジョークがあった
- 代表例は
int****のように、多段のポインタ間接参照を使うコードである - Go も C から大きく派生しているため、ポインタで同じようなコードを書くことができる
*int,**int,***int,****intを順に渡す- 最後の関数で
****i = 100を実行すると、プログラムはi is now 100を出力する
- Go には C にはない
chanがあるため、このジョークを チャネル間接参照 に拡張できる
4段階チャネルで5乗を計算する
- 最上位チャネルは次のように宣言される
_4chan := make(chan chan chan chan int)
- Go の識別子は数字で始められないため、この例では
_4chanという名前を使っている _4chanに送る値は3段階チャネルである_3chan := make(chan chan chan int)
- 同じように階層を下っていくと、最後は値チャネルである
chan intに到達する - 各間接参照の階層では、定数
factorに応じて producer を生成する- 例では
const factor = 3 sendChanChanChanは 3チャネル producer を goroutine として起動する
- 例では
- consumer 側も各階層で受け取ったチャネルから、次段の consumer を
factor個ずつ起動するreceiveChanChanChanは_4chanから_3chanを受け取り、3チャネル consumer を起動する
最下層での値送信と合計
- 最下層では、もはやチャネルではなく実際の
int値を送信する send関数は_2chanに_1chanを送った後、factor個の int producer を起動する- 各 int producer はさらに
factor個の goroutine を作って_1chan <- 1を実行する - consumer は受け取った整数をグローバルな
sumに加算するsumはatomic.Int32として宣言されているreceive(c chan int)はチャネルから値を受け取り、sum.Add(int32(s))を実行する
実行結果と分岐数
- プログラム全体では
_4chanを作り、送信側の階層と受信側の階層をそれぞれ goroutine として起動した後、500 * time.Millisecond待機する - この例の出力は次のとおり
3 ^ 5: 243
- このプログラムは、数値の 5乗 を可能な限り分散した方法で計算する一般化された例である
- 実行可能な例は Go Playground で見られ、シンタックスハイライト付きの版は GitHub Gist にある
- より大きな
factorを使う場合は、実行が終わるようにSleep時間を延ばす必要があるかもしれない - ログを有効にすると、各チャネル生成・消費階層の分岐数を確認できる
starting 3chan producer: 3回starting 2chan producer: 9回starting 3chan consumer: 9回starting 2chan consumer: 27回starting chan producer: 27回starting 1chan consumer: 81回starting int producer: 81回sending int: 243回received int: 243回
実運用コードで避けるべき理由
- この方式は、実際のコードで使うには実装もデバッグも厄介である
- チャネルをチャネルで送ると、各チャネルをいつ閉じるべきか判断しにくくなる
- 実際のユースケースならチャネルを閉じる必要があるが、終了ロジックを入れるにはすべてのチャネル送信が終わったかを追跡しなければならない
- 終了処理を実装するには各所に
sync.WaitGroupを追加する必要があり、そうするとジョーク的な例として読みづらくなる - 最終的な例は、終了ロジックの代わりに
time.Sleep()を使い、多くの goroutine リーク を残す形で単純化されている
1件のコメント
Hacker News の意見
実際のプロのソフトウェアエンジニアと近くで仕事をする科学者の立場から見ると、彼らのやっていることの多くがこんなふうに見えるので、なぜそうするのかどうしても理解しにくい
ある1行のコードが実際に呼び出される前に、インターフェース関数を4つ順番に通り、その関数群が別々のフォルダの別々のファイルに散らばっているのを見たことがある
そのためコードが何をしているのか読み解くのに疲れてしまい、何段階か潜っていくと、自分が正しい場所を見ているのか、いつか実際の計算が出てくる場所にたどり着くのか疑わしくなってくる
過剰で紛らわしく見えるという感覚は間違っておらず、初めて「面白い」コードを書くときには技術的に複雑で、さらにはエレガントに見えることもあるが、実際に大きくならなければならないソフトウェアの中では技術的な悪夢になる
Go のコードでチャネルの誤用を2年近く整理したことがあるが、チャネルは実際に必要な場合がまれなのに、初期段階ではいろいろな用途に簡単に使えてしまうので問題になる
チャネルを正しく使っているかを見る基準は、「直接の関数呼び出しではできないのか?」「WaitGroup やミューテックスではできないのか?」にノーと答えられ、「並行性/並列性のメリットは、並行コードをデバッグする複雑さを正当化できるほど大きいか?」にイエスと答えられるかどうかだ
理解と能力があるべきところで、恐ろしいほど不足している場合がある
学部時代にコンピュータサイエンスの博士と30分ほどペアプログラミングをしたことがあるが、かなり目からうろこだった
彼はソフトウェアをまったく理解しておらず、標準ライブラリのデータ構造のサイズが負でないことを確認していないと指摘するほどだった
ただし、こうした構造にも理由がある場合はある。実際に合理的なこともあるし、先人たちが作った狂気のコードベースを扱うための方法であることもある
研究論文に使われたコードを読んだことがあるが、理論数学はたいてい理解を超えているので、論理を見ようとコードに入っていくと、むしろずっとひどく、判読不能なことが多かった
結局、私たちは自分のやり方に慣れていて、別のやり方は見慣れないものに感じる
とはいえ、追加の間接レイヤーはたいてい全体のアーキテクチャの中で正当化されるもので、合理的な場合には局所的に見ただけでは価値が分かりにくいことがある
「初期のプログラミング言語の軽い手触りと静けさは、いつも喜ばしいものだ。テキストは多くないのに、多くのことが行われる。昔のプログラムはコンパイラとの口論ではなく、雄弁な研究者とよく訓練された機械の同僚との静かな対話のように読める。洗練がこんな騒音を買うことになると、誰が知っていただろうか?」 — Dick Gabriel
両極端を見ているということだ。抽象化が多すぎて過度に分散したコードベースも、抽象化がまったくなく目的達成用スクリプトに近いコードベースも、どちらも作業しにくい
Python、JS、PHP のスクリプトには「いいから欲しい結果だけ出してくれ」という態度で書かれたものが多く、日々コードの中で働く人たちには、協業と回復力を助ける抽象化が必要だ
冒頭のミームは、回復中の C プログラマーとして実際に笑えた
こういうふうに言語をねじ曲げて使う例を見るのは面白いし、C にはそういう機会があふれているが、Go でも見られるのは興味深い
「C とその派生言語が支配していた時代の古いプログラミングジョーク」と言うが、私たちはいまだにその時代に生きている
このスレッドでこれを抽象化過剰の典型だと批判しているのは皮肉だ
C で星3つの変数をめったに見ない理由は、ポインタチェーンが珍しいからではなく、同時に2段階を超えて操作する必要があることが少ないからだ
Python、Java、JavaScript のように、大半が基本的にポインタに近い言語では、長さが4を大きく超えるポインタチェーンも間違いなく存在する
深い部分はたいてい、内部を気にしなくてよい構造体の中に隠されている。つまり抽象化されている
チャネルも、逐次コードにおけるポインタの役割を並行コードでおおむね果たすので、ここに致命的な欠陥があるとすれば、それは抽象化過剰ではなく、むしろ抽象化不足かもしれない
それでもコードベースを知らないので、もしかすると
chan chanは彼らが書こうとしていたものに正確に合った抽象化だったのかもしれない。Erlang のように並行性の強い言語では、応答を受け取るプロセスを識別するために、PID を別の PID に送ることはごく一般的だだがそれはそういうものではなく、文字列配列のアドレスだった。C だからそうなったのであり、約20年たった今でも、最も直感的な解法だったと思う
同僚を弁護するなら、おそらくコメントがなかったのだろう。その点は私の責任だった
Buena Vista Social Club の時代を超えたクラシックを思い出す https://www.youtube.com/watch?v=o5cELP06Mik
chan chan Valueやchan struct{resp chan Value}は、ごく特定の状況で実際に使ったことのあるパターンですメッセージバスを使うこともできたでしょうが、そうすると今度はメッセージバスを扱わなければなりません
chan chanはかなりよく使います。内部サーバーやアクターにメッセージを送りつつ、応答を受け取るチャネルもメッセージに同梱して送る場合です実際には、grep で見つかる文字どおりの
chan chanというより、chan struct { ... チャネルが入った何か ... }の形になりますが、原理は同じです非常に便利なパターンで、Go の基本の一つだと思います
ただし
chan chan chanは、知る限り使ったことがありませんこうしたものをすべてメッセージバスに置き換えるのはやりすぎです。性能特性も大きく異なりますし、Go のチャネルは OS プロセス内の構成要素に近いので、プロセス内通信であればメッセージバスへ「アップグレード」する価値はありません
[0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
チャネルのチャネルは正常なパターンですが、通常は構造体値のチャネルという形で現れることのほうが多く、その構造体型にチャネルフィールドが入ります
完了するリクエストを送り、それらの値を順序に関係なく待てるようにして、ヘッドオブラインブロッキングを避けるために使えます
たとえば
type request struct { params, reply chan response }のようにリクエストをチャネルで送り、ワーカーが処理したあと結果をreplyチャネルに入れる方式ですただし有用だったのは 2 段階までで、
chan chan chanが必要になるユースケースは見たことがありませんチャネルを送るチャネルで動的ディスパッチを実装した反例のブログがあります。Go ではなく Limbo ですが、概念は同じです。複雑さがむしろ論点を証明しているのかもしれません https://ipn.caerwyn.com/2007/07/lab-78-dynamic-dispatch.html...
Joe Armstrong の “My favorite Erlang Program” を思い出します
https://joearms.github.io/published/2013-11-21-My-favorite-e...