jquery: bluebird を使って sleep

sleep なんて使うかしらと思ってたんだけれど、やってみたら有効だった、てはなし。

ワタシが今やってるこのシリーズはまぁ「お遊び」として「雑に」やってる上に、「お仕事で WEB プログラミングに取り組んだ経験がほとんどない」ので未だに javascript そのものや jquery のような「すんげーお便利ライブラリ」のちゃんとした基礎を「きちんと学習したことがない」ので、「自分でもどこが同期的に、どこが非同期的に振舞っているのかちゃんと把握できてない」状態で作ってるのよね。

そもそも「javascript ってシングルスレッドじゃなかったっけか」(Adobe FLEX の経験と混同してる可能性あり)。なので「sleep して意味があるケースってあるのかいな」と思ってて、これまでは全然興味なかったんだけれど、そのワタシの今やってるヤツが、「もしかしたら」というケースだったのよね。

一つ前のヤツのそのまま続きの話なのよ。一つ前では省略した部分が結構説明しずらい構造になっているんだけれど今回はこっちが問題だったので、ちょっと長いが貼り付けてみる:

 1 $('#do-import').on("click", function(e) {
 2     // ...
 3     var fr = new FileReader();
 4     // ...
 5     function _update_with_imported(imported) {
 6         Object.keys(imported["a"]).forEach(function(e, i) {
 7             // when all characters were expanded.
 8             var all_expanded = true;
 9             imported["a"][e]["chs"].forEach(function(ch, _) {
10                 if (!(ch["c"]["#"] in imported["c"])) {
11                     all_expanded = false;
12                     return;
13                 }
14                 });
15             if (all_expanded) {
16                 // これを立て続けに (8000ノードとか) 呼びだすハメになる
17                 construct_cy_tree({"a": imported["a"][e]});
18             }
19         });
20         // ...
21     }
22     fr.onload = function () {
23         var imported = JSON.parse(fr.result);
24         _update_with_imported(imported);
25     };
26     fr.readAsText(file);
27 });
28 // ...
29 function construct_cy_tree(anime, target_cv) {
30     // AJAX でデータを非同期に取得しに行くのが基本だが、_update_with_imported からは
31     // 取得済みデータに基いて動くので、「同期」的に呼び出す「つもり」で呼ばれる「ハメに」なる。
32     // AJAX での取得完了時に呼ぶつもりで書いた reset_cy_elements() が、「取得済みならば
33     // リクエスト発行しない」の迂回で「同期的に立て続けに」呼び出すことになる。擬似コードでは
34     // if (データ in キャッシュ) {
35     //     reset_cy_elements();
36     // } else {
37     //     AJAX.取得({success: reset_cy_elements()});
38     // }
39     // という感じ。
40 }
41 // ...
42 var __reset_cy_elements_timeout = null;
43 function reset_cy_elements() {
44     // 立て続けに呼び出された際に「落ち着くまで待つ」ということをしている
45     if (__reset_cy_elements_timeout) {
46         clearTimeout(__reset_cy_elements_timeout);
47         __reset_cy_elements_timeout = null;
48     }
49     __reset_cy_elements_timeout = setTimeout(function() {
50         _reset_cy_elements();
51     }, 500);
52 }
53 function _reset_cy_elements() {
54     // ここで cytoscape.js のセットアップ(更新)について全て行っている
55 }

コメントに「8000ノード」と書いてる通りで、ちょっとでも「本格的に」使おうとすると、平気でこんくらいのノードを扱うことになる。これでもまだ少ないほう。これだけの量になると色んなところの「バカさ」が影響するわけだけど、一番大きいのはやはり上に構造そのもの。ここをどうにか御さないと「使って快適なもの」にならない。

「AJAX でデータを非同期に取得しに行くのが基本だが」とコメントに書いた通りで、元々はフレッシュなデータを AJAX で取りに行くのがベースなのですよ。それを「AJAX で取得済みのキャッシュ」をエキスポート出来るようにして、その「インポート」で今問題を起こしていた、というわけ。非同期で裏でデータを取りに行く前提なら問題なかったけれど、「同期的に立て続けに」呼び出されるとブラウザがハングアップしているようにしか見えない振る舞いをしちゃう。

なんとか「動いてるように見せられないかなぁ」と色々試行錯誤していた中で、「うーん、そういえば sleep 出来てみたらどうなるんだろうか?」と思い、検索したらすぐに StackOverflow: What is the JavaScript version of sleep()? がヒットしてくれた。あぁ、ここでも Promise か。さっき「日常使いにしよう」と思ったばっかりだ、やったね、と:

1 // sleep:
2 //   usage: first, encupsule your function with "async", then "await sleep(1000)"
3 function sleep(ms) {
4     // Promise: required bluebird.
5     //   CDN: https://cdn.jsdelivr.net/bluebird/3.5.0/bluebird.min.js
6     //   HOME: http://bluebirdjs.com/docs/getting-started.html
7     return new Promise(resolve => setTimeout(resolve, ms));
8 }

注意点はコメントの通りで「async で包めやヲラ」と、「await sleep(1000)」のように await とともに使うこと、だけ。async とか await って昔からあったのかなぁ? これは javascript に組み込みの機能だよね。へぇ。てことはあれだ、今みたいな問題に直面している場合、そもそも「ちゃんと asyncawait の設計をすべし」というのが清く正しい解なんじゃないのかな、と思う、sleep 以前の問題として。

ともあれ「雑なプロジェクト」としては、なんとなくだが(どうせタイムスライスなんだろうし)sleep するだけでなんとかなりそうな気がする、と思ってみた。その前に、まず「// これを立て続けに (8000ノードとか) 呼びだすハメになる」が本当に「連続で切れ目なく」だと reset_cy_elements() がちっともタイムアウトしないのが今の場合とてもマズイのよ。つまり 8000 ノード分、1ノードあたり reset_cy_elements() 前の処理が 300ms で処理が終わってしまうとすると、8000 * 300 = 2400000 (40秒) 何も動かない。なので、sleep を適宜挟み込む区切りを作りたいので、「まとまった単位ごとに「立て続け」」というふうに「ちぎる」:

 1 $('#do-import').on("click", function(e) {
 2     // ...
 3     async function _update_with_imported(imported) {
 4         // ...
 5         var sched = [];
 6         Object.keys(imported["a"]).forEach(function(e, i) {
 7             // when all characters were expanded.
 8             var all_expanded = true;
 9             imported["a"][e]["chs"].forEach(function(ch, _) {
10                 if (!(ch["c"]["#"] in imported["c"])) {
11                     all_expanded = false;
12                     return;
13                 }
14                 });
15             if (all_expanded) {
16                 // 直接呼び出すんではなく、スケジュールに突っ込む
17                 sched.push(function() {
18                     construct_cy_tree({"a": imported["a"][e]});
19                 });
20             }
21         });
22         // ...
23         //
24         const step = 20;  // 20ノードずつ
25         for (var i = 0; i < sched.length; i += step) {
26             var pos = i;  // これ地味に大事
27             sched.slice(pos, pos + step).forEach(
28                 function(task, _) {
29                     task();
30                 });
31             // 願いとしては、ここに sleep 入れたらステキになって欲しい
32         }
33         // ...
34     }
35     fr.onload = function () {
36         var imported = JSON.parse(fr.result);
37         _update_with_imported(imported);
38     };
39     fr.readAsText(file);
40 });

で、結果としては、「願いとしては、ここに sleep 入れたらステキになって欲しい」が「await sleep を突っ込むだけ」というわけにはいかなくて、「どうせタイムスライスだよな、誰かが処理機会を得たり失ったりすることで「動いてるように見え」たりそうでなかったりするんろ」という発想に基き、「もっと細切れ sleep」してやっと「期待にかなり近いもの」になった:

 1 // sleep:
 2 //   usage: first, encupsule your function with "async", then "await sleep(1000)"
 3 function sleep(ms) {
 4     // Promise: required bluebird.
 5     //   CDN: https://cdn.jsdelivr.net/bluebird/3.5.0/bluebird.min.js
 6     //   HOME: http://bluebirdjs.com/docs/getting-started.html
 7     return new Promise(resolve => setTimeout(resolve, ms));
 8 }
 9 // sliced_sleep:
10 //   usage: first, encupsule your function with "async", then "await sliced_sleep(5, 100)"
11 async function sliced_sleep(num_iters, ms_in_each_iter) {
12     for (var i = 0; i < num_iters; ++i) {
13         await sleep(ms_in_each_iter);
14     }
15 }
16 
17 // ...
18 $('#do-import').on("click", function(e) {
19     // ...
20     async function _update_with_imported(imported) {
21         // ...
22         var sched = [];
23         Object.keys(imported["a"]).forEach(function(e, i) {
24             // when all characters were expanded.
25             var all_expanded = true;
26             imported["a"][e]["chs"].forEach(function(ch, _) {
27                 if (!(ch["c"]["#"] in imported["c"])) {
28                     all_expanded = false;
29                     return;
30                 }
31                 });
32             if (all_expanded) {
33                 // 直接呼び出すんではなく、スケジュールに突っ込む
34                 sched.push(function() {
35                     construct_cy_tree({"a": imported["a"][e]});
36                 });
37             }
38         });
39         // ...
40         //
41         const step = 20;  // 20ノードずつ
42         for (var i = 0; i < sched.length; i += step) {
43             var pos = i;  // これ地味に大事
44             var pos = i;
45             setTimeout(function() {
46                 sched.slice(pos, pos + step).forEach(
47                     async function(task, _) {
48                         task();
49                     });  // こっちは「裏で動けや」しないと sleep の意味ないじゃん、と後で気付く
50             }, 10);
51             await sliced_sleep(7, 80);  // 細切れ sleep
52         }
53         // ...
54     }
55     fr.onload = function () {
56         var imported = JSON.parse(fr.result);
57         _update_with_imported(imported);
58     };
59     fr.readAsText(file);
60 });

一応これでワタシが望んだ「まぁまぁ」の振る舞いをしてくれるようになった。ただ多分「適切に asyncawait をバラまく」ということを真剣にやり直したほうが、キレイに振舞うんじゃないかな、という気はする。

あと今回の主題とは関係ないが、jquery-ui の progressbar、val に「false」を与えることが出来るのね:

静止画だと伝わらないが、床屋のアレみたいにくるくる動く。進捗率を計算するのがちょっと面倒だったので、アタシの上の実際のヤツではこれを使って「動いてる気配」を感じてみていた。


話を終わろうと思ったらアホさに気付いた。sliced_sleep なんかいらんわ:

 1         const step = 20;
 2         for (var i = 0; i < sched.length; i += step) {
 3             var pos = i;
 4             // async 付ける場所間違えてた。
 5             setTimeout(async function() {
 6                 sched.slice(pos, pos + step).forEach(
 7                     function(task, _) {
 8                         task();
 9                     });
10             }, 10);
11             await sleep(560);
12             //await sliced_sleep(7, 80);  // いらんよこんなん
13         }

さらに追記。

上で async 場所間違えてたので「sliced_sleep はなくてもいい」のは確かだが、「あってもいい」というかあったほうが少し気持ちいいみたい。ので無駄ではなかった。よかったのかわるかったのかわからんがとにかくそんな感じ。