コルーチンが美味しいかどうか考えた


続きみたいなの、書いた

【保存版】制御構造別非同期プログラミング完全制覇(サーバサイドJavascript・CoffeeScript)という記事を書いた。このコルーチンの記事をストックしてくれた人は、本当はこういうまとめが欲しかったのかも知れない、と思う。リンク先記事はコルーチンの話の文脈に囚われず、非同期プログラミングと基本的な制御構造について書いたので、forとか例外処理相当の制御構造とかについても言及している。そういうのが欲しければ、リンク先へどうぞ。

何となく眺めていたら

プログラムを書かない仕事の合間に、携帯で何となくNode.js関連のウェブページを眺めていたら、そこここにコルーチンの話を見かけました。自分の知っているコルーチンはboost::coroutineが生まれた頃なので、だいぶ昔になります。自分でも、Cでスタック挿げ替えてコルーチンを実装したこともありますよ。懐かしく、そして微妙に意味のない技術ではありました。デバッグしにくいとか、可読性落ちるとか、ちゃんと設計されたコードなら、もっとまともな正攻法でできることばかり、とかね。

そもそもなぜ今コルーチンなのかと思ったら、nodeの新バージョンにyieldとかが実装されたんですね。もう何ヶ月も経っていますが、最近と言えなくもないくらいのタイミング。yieldと言えばコルーチンを想像するのはまあそりゃそうでしょうが、今さらコルーチンもないだろう、と思う自分もいて、何となく頭を整理しようとしていました。

なんかyieldって、もっと面白くて有用な使い方がある気がするんですよね。だって、覚悟完了したコンテクストをいくつも持っておける訳なので、なんか、むしろ高速化のための技術としてなんか活かしどころがある気がしています。まあいずれにせよ、コルーチンは、直近で使う予定がないので、まだしばらくは node 0.10 で遊ぼうと思っています。

というわけで、頭を整理しながら、コルーチン(およびコルーチン的なもの)の理解をしてみます。

1-1【同期】
小文字同期関数・大文字非同期関数
a
b
c
d
e

この1-1が、みんな大好き、昔ながらの同期的な書き方ですね。附番がちょっと異様ですがご勘弁。そして次が継続チェーン

2-1【非同期・継続】
小文字同期関数・大文字非同期関数
A
  B
    C
      D
        E

nodeっ子の誕生により、昔は想像できないくらいの人たちがこの書き方に慣れてきました。では、コルーチン。

3-1【なつかしいコルーチン】
小文字同期関数・大文字非同期関数
A
  y
x
B
  y
x
C
  y
x
D
  y
x
E
  z

この場合、xは他のコンテクストに処理を譲る魔法が使える必要がある

ここでの「魔法」が「yieldと愉快な仲間達」だという、こういうお話だと理解しています。yがxと裏交渉してどうのこうのというのもコミで魔法です。
ちなみに、とは言えこの形ではちょっと管理しにくいので、こんな実装もあるでしょう。あちこちで話題になっている「co」がどっちの実装なのか、私は知りませんが、後者ではないでしょうか(いつもどおりいいかげん)

4-1【非同期っぽいコルーチン】
小文字同期関数・大文字非同期関数
X
  A
    y
X
  B
    y
X
  C
    y
X
  D
    y
X
  E
    z
これをやるときにも、魔法は必要です。だって、本質的には3-1と同じだもんね。

この場合、X同士の裏交渉でなんとでもできるので、まあ、やりたい放題ですな。
あとは、コルーチンっぽく見えるものとして、deferredなんかはこういう仕組みだと理解しています。

5-1【コルーチンっぽく見えるメソッドチェーン】
小文字同期関数・大文字非同期関数
X
  A
    y
  X
    B
      y
    X
      C
        y
      X
        D
          y
        X
          E
            z
コルーチンとは別に、これはこれで使い途はあると思う。

まあでも、さすがに5-1は今回の話にあんまり絡まないし、そもそもこれには異存はないので、このあとでは触れません。これってどう見ても2-1の仲間で、継続の使い方をひと味ひねるための定石みたいなもんですからね。まあ、参考まで。

で、継続チェーンをいやがる人が何をいやがるかってーと、まあざっくり
* 制御文が入ったら扱いづらくね?
* 同時実行の回収とか、やりにくくね?
* コードが右に!右に!(クトゥルー風に)
みたいな感じかな、と思うわけです。でも、それって、ちょっとしたコーディングスタイルで解決できそうな気がするなー(ただし、CoffeeScriptを使うものとする)と思うのです。

制御文が入ったら扱いづらくね?

制御文が入った場合、同期ならこんな感じ

1-2【ありがちフロー:同期】
小文字同期関数・大文字非同期関数
a
if foo
  b
else
  c
d
e

で、それを狂った人が非同期に直すとこうなります。

2-2a【ありがちフロー:非同期・継続(狂気)】
小文字同期関数・大文字非同期関数
A
  if foo
    B
      D
        E
  else
    C
      D
        E

うわあああ。でも、そんなこと、普通しませんよね。まともな神経なら、複雑すぎる処理は、簡単な処理の組み合わせに切り出します。

2-2b【ありがちフロー:非同期・継続(冷静)】
小文字同期関数・大文字非同期関数
P(Q)
  if foo
    B
      Q
  else
    C
      Q

A
  P
    D
      E
普通にプログラミングしていたら、書き捨てコードでない限り、制御文の有無にかかわらず、「ん?なんか機能がでけえな」と思ったらこういうことをするはずですね。

コルーチンだとこう書けるのが普通、かな?

3-2【ありがちフロー:なつかしいコルーチン】
小文字同期関数・大文字非同期関数
A
  y
x
if foo
  B
    y
else
  C
    y
x
D
  y
x
E
  z

まあ、すっきりとはしますが、この調子で複雑になっていくのなら、いつか関数に切り出すでしょうし、そのとき、直観的なやり方でやってほんとに大丈夫かな、と言うのが少し慣れていないせいで怖いです。まあ、大丈夫なんでしょう。

4-2a【ありがちフロー:非同期っぽいコルーチン】
小文字同期関数・大文字非同期関数
X
  A
    y
X
  if foo
    B
      y
  else
    C
      y
X
  D
    y
X
  E
    z

このへんは、Xがどんな風に実装されているかによって微妙にバリエーションがありそうですね。

4-2b【ありがちフロー:非同期っぽいコルーチン】
小文字同期関数・大文字非同期関数
X
  A
    y
if foo
  X
    B
      y
else
  X
    C
      y
X
  D
    y
X
  E
    z

まあ、こんな感じ。まだ処理を切り分けていない状態であれば、いずれの形でもアリかな、でも、必要かって言うと、シュガーの域を出ないかな、って感じ。

同時実行の回収とか、やりにくくね?

これは、同期の場合にむしろ困難が伴う感じです。てゆーか並行して動きうるとしたら、マルチスレッド? でも、このb,c,dってほんとにスレッドセーフなんだろーな、おい、って感じですね。

1-3【同時処理:同期】
小文字同期関数・大文字非同期関数
bb
  b

cc
  c

dd
  d

a
createthread
  bb
createthread
  cc
createthread
  dd
wait nanchara
e

こんな感じでしょうか。懐かしくも焦げ臭い感じ。

一方、非同期関数だとしたら、続けて呼べばいいわけですね。Eが正常に動作しない例が色々思い浮かぶものの、これまでも一応正常に動くパターンだけを示しているので、まともに動くけどアレな例からはじめましょうか。

2-3a【同時処理:非同期・継続(狂気)】
小文字同期関数・大文字非同期関数
p(start)
  [arg,counter] = [[],start]
  return (result)
    arg.push result
    if 0==(--counter)
      E arg

A
  q = p 3
  B
    q result_b
  C
    q result_c
  D
    q result_d

ここでの問題って、結局このカウンタとかを手動で管理するとアレゲなのでやめれ、ってことですね。タイプ量少ないんで、書き捨てならこーゆークロージャ作ってやっちまうけど、早速Eが上の方に持ってかれて、フローもずたずたになる予感だし。

で、も少しまともな書き方をするなら、

2-3b【同時処理:非同期・継続(冷静)】
小文字同期関数・大文字非同期関数
class p
  p(obj)
    [counter,lock,ROOM,arg] = [1,yes,obj,[]]
  _
    ++counter
  unlock
    lock = no
    knock()
  knock(result=null)
    arg.push result?
    if 0==(--counter) and not lock
      ROOM arg

A
  r = (s)
    s._
    B
      s.knock result_b
    s._
    C
      s.knock result_c
    s._
    D
      s.knock result_d
    s.unlock
  r new p
    E
#counterの初期化が1なのは、unlockの分
#そのunlockがknockを呼び出しているのは、
#B,C,D共に、キャッシュヒットやエラー等の事情で即時復帰した場合、
#unlock時にEを呼ばなくてはならないパターンがあり得るから。
#そして、p,r,sは本質的には非同期だが、敢えて小文字。

まあ、こうすりゃいいっちゃあいいんだけど、待ち合わせの行き先の引数タイプ毎にクラス作んのかよ、って感じなのかな。ちょっとタイプ量多めな割に、あんまりクロージャと違いはないし。でも、処理のフローはなんだかんだで見通しが付くので、私はこうやります。

では、コルーチンの場合はというとこんな感じ。

3-3【同時処理:なつかしいコルーチン】
小文字同期関数・大文字非同期関数
A
  y
x parallel
B
  y
x parallel
C
  y
x parallel
D
  y
x join
  E
    z

C/C++の場合、裏で待機スレッドにうまいこと割り振ってくれると感動もするのだが、元々上記のように非同期で書けるものがこうなったところで感動はないですねえ。まあ、手軽っちゃあ手軽。結果の受け渡しは、yが巧いことやるようにできてんだろうね。Eがどうやって結果を受け取るのか、ここだけはxにコールバックとして渡して呼んでもらうしかない気がするよ。

4-3【同時処理:非同期っぽいコルーチン】
小文字同期関数・大文字非同期関数
X
  A
    y
X parallel
  B
    y
X parallel
  C
    y
X parallel
  D
    y
X join
  E
    z

同上ですね。結論として、巧くやれているような、やれていないような感じ。mpiみたいに、ループまでかっさばいて全自動で最適化してくれるというわけでもないので、なんかこう、やっぱりシュガーな感じがします。

コードが右に!右に!(クトゥルー風に)

いやまあそれは、と思わないでもないものの、割と「これってイタイよね!」的な記事を見かけるので、分かりやすいデメリットではあります。

非同期な関数を継続で繋いだ
プログラムの開発風景

カタカタ・・・
A
  B
    C
      D
        E
          F
            G
              H
ヤヴァイ、肥大化した
P
  A
    B
      C
Q
  D
    E
R
  F
    G
      H

P
  Q
    R
おけおけ

CoffeeScriptはこんな時、
タイプ量が大して増えなくて良いよね。

というまあ、こんな感じなんじゃないかな、と思うわけです。同期の頃と同じだ!

で、どうせ継続チェーンの部分部分を関数に切り出すなら、コルーチンのメリットってそんなに大きくない気がするんだよな。

というか単に表現形式の問題に留まるんじゃないか。

だってC/C++みたいにコンテクストに縛られた環境だったら、コルーチンって単なる表現形式を超えたものになるけれど、JavaScript(CoffeeScript)って元々非同期関数があって、一旦ペンディングしたコンテクストを継続で復帰してくれる言語じゃないですか。そこでのコルーチンのメリットは、
「継続で書くと(巧く要素に分割しないと)読みにくい」
とか
「継続で書くと、継続向きでないロジックは(巧く要素に分割しないと)書きにくい」
という表現形式の問題を何とかする話なのだと思う。yieldそのものの応用には超期待してるけど、その結果がコルーチンです、と言われたら、ふーんほじほじ、になる気がするんだ。ってか、今のところなってる。それでも、劇的に記述量が減るなら表現形式を超えたものになるんだろうけど、記述量の削減はCoffeeScriptの方が圧倒的に有効。つまり、表現形式の問題を一番適切に処理できるのは、プリプロセッサだということではないかな。コルーチンとCoffeeScriptを比べるのは、全然apple to appleじゃないように見えるかも知れないけど、表現形式の問題とプリプロセッサ、という関係は、少なくともapplepie to appleだ。だとしたら、表現形式の問題とコルーチンという関係の方が、applepie to appleじゃないことの表れかも知れないよ。分かりにくい書き方だけど、それくらいしか思いつかない。

「超期待してる」の裏返しで言うと、yieldはコンテクストの扱い方についての言語仕様レベルの改革なのに、表現形式の問題を解決するために使うのがメイントピックになっちゃったらもったいないよなあ、とも思うのだ。

そして、C/C++の時のコルーチンでさえ、結局処理単位を細かめに関数に割って、関数ポインタ/関数オブジェクトを引き回したほうがシンプルで扱いやすいものになったというトラウマがあるので、(だって、コルーチンって、できることの割に、ブラックボックスとしては大きすぎるんですもの)やっぱりどうもコルーチンに食指は動かないのです。

食わず嫌いは成長を止めるので、koa.jsはそのうち触ってみようと思うし、もしかしたらその中に、yieldをすげえ効果的に使っている例、が見つかるかも知れない。でも、あんまりこるこるしないよ。

とりあえず、導入する気がないまま書いているので、コルーチンの仕組みを仮想コードで示した部分には、もろもろ嘘がある気もする。そのあたり、嘘を指摘してもらえると、理解が進むのでありがたい。