1 ポイント 投稿者 GN⁺ 2024-08-29 | 1件のコメント | WhatsAppで共有
  • 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 コードベースと同様に、並行実行の実装に channelsgoroutines を使っている
  • 並行プログラミングはそれ自体が難しいため、通常はチャネルと 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 に加算する
    • sumatomic.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件のコメント

 
GN⁺ 2024-08-29
Hacker News の意見
  • 実際のプロのソフトウェアエンジニアと近くで仕事をする科学者の立場から見ると、彼らのやっていることの多くがこんなふうに見えるので、なぜそうするのかどうしても理解しにくい
    ある1行のコードが実際に呼び出される前に、インターフェース関数を4つ順番に通り、その関数群が別々のフォルダの別々のファイルに散らばっているのを見たことがある
    そのためコードが何をしているのか読み解くのに疲れてしまい、何段階か潜っていくと、自分が正しい場所を見ているのか、いつか実際の計算が出てくる場所にたどり着くのか疑わしくなってくる

    • これは本当に悪い慣行で、やる気が空回りしているジュニアエンジニアがソフトウェアを書くやり方に近い
      過剰で紛らわしく見えるという感覚は間違っておらず、初めて「面白い」コードを書くときには技術的に複雑で、さらにはエレガントに見えることもあるが、実際に大きくならなければならないソフトウェアの中では技術的な悪夢になる
      Go のコードでチャネルの誤用を2年近く整理したことがあるが、チャネルは実際に必要な場合がまれなのに、初期段階ではいろいろな用途に簡単に使えてしまうので問題になる
      チャネルを正しく使っているかを見る基準は、「直接の関数呼び出しではできないのか?」「WaitGroup やミューテックスではできないのか?」にノーと答えられ、「並行性/並列性のメリットは、並行コードをデバッグする複雑さを正当化できるほど大きいか?」にイエスと答えられるかどうかだ
    • もっとひどいものもある。かなり誇張された例というわけではない: https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpris...
      理解と能力があるべきところで、恐ろしいほど不足している場合がある
      学部時代にコンピュータサイエンスの博士と30分ほどペアプログラミングをしたことがあるが、かなり目からうろこだった
      彼はソフトウェアをまったく理解しておらず、標準ライブラリのデータ構造のサイズが負でないことを確認していないと指摘するほどだった
      ただし、こうした構造にも理由がある場合はある。実際に合理的なこともあるし、先人たちが作った狂気のコードベースを扱うための方法であることもある
    • コーダーのスペクトラムの一方の端には科学者がいて、もう一方の端にはソフトウェアエンジニアがいる。私たちを救えるのはバランスだけだ
      研究論文に使われたコードを読んだことがあるが、理論数学はたいてい理解を超えているので、論理を見ようとコードに入っていくと、むしろずっとひどく、判読不能なことが多かった
      結局、私たちは自分のやり方に慣れていて、別のやり方は見慣れないものに感じる
    • 過剰設計がよくある原因だ。単純な解法は、見つけにくいところに隠れていることが多い
      とはいえ、追加の間接レイヤーはたいてい全体のアーキテクチャの中で正当化されるもので、合理的な場合には局所的に見ただけでは価値が分かりにくいことがある
      「初期のプログラミング言語の軽い手触りと静けさは、いつも喜ばしいものだ。テキストは多くないのに、多くのことが行われる。昔のプログラムはコンパイラとの口論ではなく、雄弁な研究者とよく訓練された機械の同僚との静かな対話のように読める。洗練がこんな騒音を買うことになると、誰が知っていただろうか?」 — Dick Gabriel
    • 逆に、ソフトウェアエンジニアがよく分かっていないときに抽象化を遠くまで持って行きすぎる、という点には同意するが、職業的にソフトウェアエンジニアではない人たちが書いたコードも、特に高く評価しているわけではない
      両極端を見ているということだ。抽象化が多すぎて過度に分散したコードベースも、抽象化がまったくなく目的達成用スクリプトに近いコードベースも、どちらも作業しにくい
      Python、JS、PHP のスクリプトには「いいから欲しい結果だけ出してくれ」という態度で書かれたものが多く、日々コードの中で働く人たちには、協業と回復力を助ける抽象化が必要だ
  • 冒頭のミームは、回復中の C プログラマーとして実際に笑えた
    こういうふうに言語をねじ曲げて使う例を見るのは面白いし、C にはそういう機会があふれているが、Go でも見られるのは興味深い

  • 「C とその派生言語が支配していた時代の古いプログラミングジョーク」と言うが、私たちはいまだにその時代に生きている

  • このスレッドでこれを抽象化過剰の典型だと批判しているのは皮肉だ
    C で星3つの変数をめったに見ない理由は、ポインタチェーンが珍しいからではなく、同時に2段階を超えて操作する必要があることが少ないからだ
    Python、Java、JavaScript のように、大半が基本的にポインタに近い言語では、長さが4を大きく超えるポインタチェーンも間違いなく存在する
    深い部分はたいてい、内部を気にしなくてよい構造体の中に隠されている。つまり抽象化されている
    チャネルも、逐次コードにおけるポインタの役割を並行コードでおおむね果たすので、ここに致命的な欠陥があるとすれば、それは抽象化過剰ではなく、むしろ抽象化不足かもしれない
    それでもコードベースを知らないので、もしかすると chan chan は彼らが書こうとしていたものに正確に合った抽象化だったのかもしれない。Erlang のように並行性の強い言語では、応答を受け取るプロセスを識別するために、PID を別の PID に送ることはごく一般的だ

    • キャリア初期に、星3つのコミットのせいで怒られた記憶がある。誰がポインタのポインタのポインタなんて必要とするんだ、という感じだった
      だがそれはそういうものではなく、文字列配列のアドレスだった。C だからそうなったのであり、約20年たった今でも、最も直感的な解法だったと思う
      同僚を弁護するなら、おそらくコメントがなかったのだろう。その点は私の責任だった
  • Buena Vista Social Club の時代を超えたクラシックを思い出す https://www.youtube.com/watch?v=o5cELP06Mik

  • chan chan Valuechan struct{resp chan Value} は、ごく特定の状況で実際に使ったことのあるパターンです
    メッセージバスを使うこともできたでしょうが、そうすると今度はメッセージバスを扱わなければなりません

    • chan chan はかなりよく使います。内部サーバーやアクターにメッセージを送りつつ、応答を受け取るチャネルもメッセージに同梱して送る場合です
      実際には、grep で見つかる文字どおりの chan chan というより、chan struct { ... チャネルが入った何か ... } の形になりますが、原理は同じです
      非常に便利なパターンで、Go の基本の一つだと思います
      ただし chan chan chan は、知る限り使ったことがありません
      こうしたものをすべてメッセージバスに置き換えるのはやりすぎです。性能特性も大きく異なりますし、Go のチャネルは OS プロセス内の構成要素に近いので、プロセス内通信であればメッセージバスへ「アップグレード」する価値はありません
    • おそらく gopl の並行チャットサーバーに似たパターンだと思います。最初は少し驚きますが、それでもかなり読みやすいです
      [0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
    • 私も使ったことがあります。Go で Promise に近いセマンティクスを実装するのに役立ちます
  • チャネルのチャネルは正常なパターンですが、通常は構造体値のチャネルという形で現れることのほうが多く、その構造体型にチャネルフィールドが入ります
    完了するリクエストを送り、それらの値を順序に関係なく待てるようにして、ヘッドオブラインブロッキングを避けるために使えます
    たとえば 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...

    • 興味のある人のために補足すると、Limbo は Go の先駆けにあたる言語で、Plan 9 の後継である Inferno オペレーティングシステムで使われていました
  • Joe Armstrong の “My favorite Erlang Program” を思い出します
    https://joearms.github.io/published/2013-11-21-My-favorite-e...