socket.io を使ってデッドロックした話

node.js と express を使ったサーバーサイド JavaScript の socket.io を使ったリアルタイムウェブのコラボレーションツールを試作してみた。

家に帰ってからの趣味プログラミングなので時間が無い中で、しかも要素技術をきちんと理解しないままのトライやったので、どうにかこうにか動くレベルになったんで Heroku にあげてみた。基本的には自分は作りながら理解を進めるタイプ。
ローカル環境でなんの問題も無かったんで、そのまま Heroku で動くと思い込んでたら現実は無情。全くダメ。socket.io なのに毎回別セッションに繋がって、自分が見てる画面を他の人が書き換えるリアムタイムコラボレーションはローカルマシンでだけ実現した幻となった。まあ、長い人生そういうこともある。

転んでいつまでも泣いててもしょーがないんで、google に質問を書いてみる。そしたら StackOverflow に答えが書いてあった。結論から言えば Heroku は WebSocket に対応していない。マジでかー(なんか理由あんだろうけどさ)
なので Heroku で socket.io したかったら Ajax のロングポーリングを明示的に指定しないとダメだったのな。

socket.io not working on heroku
http://stackoverflow.com/questions/10156372/socket-io-not-working-on-heroku

で、じゃあそんで OK かと思って検証プログラムを書いて Heroku に上げたらデッドロック(イベント連鎖による無限ループ)しちゃいましたって話。

実はローカルで実験してる最中からデッドロックすることがあるのはわかってた。だけどローカルだとブロードキャストするタイミングを throttle とか使って間引いとけばなんとか回避できてたんで、勉強会でお披露目するには問題無いレベルと判断してた。同時接続100人のアプリと3人のアプリで同じレベルの設計品質がいるわけじゃないのな。まあ、Heroku に対する調査が甘かったんでそれ以前の問題としてアカンかったわけやけどさ。

一般的なシングルスレッドの Windows アプリだったり、行ってこいするだけのウェブアプリケーションしか書いて無ければセマフォミューテックスも volatile も意識しないでも十分に大きなアプリケーションが書ける。排他制御ってのは、複数人が同時に何かする場合とか複数の対象に対してアトミックな変更をしたいような場合に必要になるのな。複数人って書いたけど、複数スレッドでも複数プロセスでも同じやけど。

今回書いたコラボレーションツールの処理シーケンスは

  1. ブラウザ上で文字入力する(クライアント側)
  2. それを AngularJS のコントローラが自動で検知(クライアント側)
  3. AngularJS がデータモデルを変更(クライアント側)
  4. 変更内容をサーバーに socket.io 経由で通知(クライアント側)
  5. サーバー上のデータモデルを更新(サーバー側)
  6. サーバー上のデータモデルを JSON 化して、全クライアントにブロードキャスト(サーバー側)
  7. socket.io でサーバーのデータモデルを受け取ってクライアントのデータモデルを全とっかえ(クライアント側)
  8. AngularJS で強制的に表示更新(クライアント側)

こんな感じになる。
要するに、入力した内容がサーバーに行って、その後で全とっかえで帰ってくるまでのタイムラグの間にクライアントを書き換えちゃうと、今書き換えた内容を過去の自分が書き換えた内容で書き換えることになって、そんな感じのが延々繰り返されて無限ループになってデッドロックすることになる。これを英語で infinite loop deadlock と言う。(←どうでもエエわ!)

heroku 上でデバッグとかどうやったら良いのか分からないんで、ローカルで↑この状態を再現する最小コードを書いて再現させてみた。

<!doctype html>
<html ng-app>
    <head>
        <script src="jquery-1.8.2.js"></script>
        <script src="angular.js"></script>
        <script>
            function controller($scope) {
                $scope.foo = function(msg) {
                    $scope.v = msg;
                    log('delay['+$scope.v +'], ['+ msg +']');
                    if(!$scope.$$phase) {
                        log('digest2');
                        $scope.$apply();
                    }
                }
                $scope.v ='abc';
                $scope.$watch('v', function() {
                    log($scope.v);
                    if(!$scope.$$phase) {
                        log('digest');
                        $scope.$apply();
                    } else {
                        setTimeout('var scope = angular.element("#sample").scope(); scope.foo("'+$scope.v +'")', 50);
                    }
                });

                function log(msg) {
                    var p =$('<span/>');
                    p.text(msg);
                    $('#panel').append(p).append('<br/>');
                }
            }
        </script>
    </head>
    <body>
        <div ng-controller="controller" id="sample">
            <textarea id="msg" rows="50" ng-model="v"></textarea>
            <div id="panel" style="position: absolute; display: inline-block;top: 0px; width: 250ex; height: 1000px; border: 1px solid gainsboro;"></div>
        </div>
    </body>
</html>

再現させてみて解ったけど heroku も socket.io も関係無かった。AngularJS でイベント連鎖が起きてるだけやったのな。
自分の過去の更新が今の自分を上書きにくるのが問題なので、キューイングして変更をサマリーして通知する(debounce)か、サーバーからのデータモデルの更新依頼を自分がトリガーの奴は無視するようにするとかで回避出来るような気がする。

同期的なイベントであればズルしてフラグでブロックしたりも出来るけど、サーバーを経由しての行ってこいでやる場合、変更のトリガーが誰なのかって情報を持つ必要があるような気がする。ついでに言えばこういうのを良い感じに解決する仕組みを AngularJS が持ってるかも知らん。

今回のは node.js / express / AngularJS でのオール JavaScript やけど ASP.NET MVC 4 + KnockOut.js の MVVM パターンでも同じなんで今回の失敗は良い経験になった。
JavaRDB で業務アプリケーションを作る現場じゃなかなかこういうの経験出来ないんで、いざやってみろってなったら大変やろと思うのな。同じウェブアプリケーションとひとくくりに出来ない程度には。

こういう思考フレームワークに対するコラボレーションツールの場合、思考を止めたり集中を切れさせないために応答性の良さや違和感の無い動作が重要になる。こういうところって人間のほうが道具に無意識的に合わせちゃうことが多いかもやけど、やっぱり道具としてきちんと使ってもらおうと思ったらユーザビリティは最高にしとく方が良い。さらに使って楽しい感性的品質を高める(UX)のが理想なのな。

やっぱりせっかく作ったアプリは「ふーん」で終わるより、「うわっ!」ってびっくりして欲しいやんね。相手が友達やってもお客さんやっても。