ぶれすとつーる

だいたいjavascript

setTimeoutとUIスレッドを学ぶよ JS Advent Calendar, オレ標準コース

JS Advent Calendar, オレ標準コース 20日目, id:nazomikan です

jsで素人から玄人までみんな愛するsetTimeoutについて色々見直してみる


基本的な話

まずは定義

timeoutID = window.setTimeout(func, delay);

timeoutID は、window.clearTimeout で使うことのできるタイムアウトの ID です。
func は delay ミリ秒後に実行したい関数です。
delay はミリ秒(1/1000 秒)で、関数呼び出しはこれにより遅延します。
MDN

とまぁ、こうなっている。

色んな語弊を含んでるのを理解の上であえてまるくいうと、setTimeoutはタイマーメソッドで第一引数に与えられたfunc(関数)をdelay ミリ秒後に実行するというもの。


ためしに簡単な例

(function(win, doc){
    var button = doc.getElementById('button1');
    button.addEventListener('click', function (evt) {
        setTimeout(function () {
            alert('1s delay');
        }, 1000);
    }, false);
}(window, document));


button1とIDのふられた要素を取得して、クリックされてから1秒後(1000ms後)にアラートを出すというもの

こんな感じで遅延実行が可能になります。


もうすこし厳密な話

先ほど語弊があるのを承知でdelayミリ秒後にfuncを実行と記述していたけど、何が語弊なのか?

delayミリ秒後にfuncを実行というところで2つほどの要因で厳密に見ると時間通りには実行されない。


一つはブラウザUIスレッドによるもの


ブラウザUIスレッドとはjsとUIの更新が行われるプロセスのことである。

UIスレッドはただのキューイングシステムでプロセスがアイドル状態になるまでタスクを保持する。

プロセスがアイドル状態になったら次のタスク(UI更新)をキューから取り出して逐次的に実行していくものである。


実際には(深く突っ込みすぎない限り)特に難しい話じゃないのでUIスレッドの動きを実際のプログラムでみてみる

コード(jsfiddle)

(function(win, doc){
    var button = doc.getElementById('button1');
    button.addEventListener('click', function(){
        var box = doc.createElement('div');
        box.innerHTML = "新しく要素を作りました";
        doc.body.appendChild(box);
    }, false);
}(window, document));

このプログラムは見ての通りだけどボタンがクリックされたらbodyタグにdivタグ追加しているだけの処理。

これをUIスレッドの考え方でみていくと次のように処理されていることがわかる

ボタンがクリックされる

次の二つのタスクをキューに追加する

  • UI更新(ボタンがクリックされた時に見せるブラウザデフォルトの動き *少し色がかわったりするやつ)
  • イベントリスナに登録されてる関数の実行


(もし今何も処理が走っておらず、アイドル状態だったら)

最初のタスクが実行されてボタンがクリックされたようにみせるための部分描画(見た目が一瞬かわる)

アイドル状態

関数の実行

新規divを作成してbodyに追加

UI更新(DOMが書きかわって新たにdivが作られたのでそれを画面に反映させるために再描画が走る)

アイドル状態


setTimeoutでの処理も例外ではなく、このUIスレッドのもと、処理される。


setTimeoutで設定するdelayの値は、確実にdelayミリ秒後に実行されるものではなく、setTimeoutメソッドにより、タイマーがセットされてからdelayミリ秒後にキューに追加されるだけの話なので、そもそもタイマーで追加された処理の前にdelayミリ秒以上かかる処理が実行されていれば、「その処理の実行時間 - delay」ミリ秒予定より実行が遅れるのである。

以下の検証で確認できるので見てみよう。

コード(jsfiddle)

(function(win, doc){
    var button = doc.getElementById('button1');
    button.addEventListener('click', function (evt) {
        var timerEnd = +new Date() + 2000;
        
        setTimeout(function () {
            alert('2s delay');
        }, 1000);
        
        while (+new Date() < timerEnd) {
           //2s delay
        }
    }, false);
}(window, document));

作成者はクリックされて1秒後にアラートされることを期待したコードとする。


クリックしてからアラートが表示されるまでどのくらい時間がかかるか心の中で数えてみよう。


setTimeoutでのタイマー登録の方がwhile文での遅延処理前にあるのでwhileに関係なく1秒後に実行されるか、2秒遅延してからさらに1秒遅延して合計3秒後にでるか、そんな感じに見えるかもしれないがやってみたら分かる通り押してから2秒後にアラートは発生する。


setTimeoutの動きはあくまでdelayミリ秒後にキューに追加するだけのことなので処理は以下のようになる


ボタンがクリックされる

次の二つのタスクをキューに追加する

  • UI更新(ボタンがクリックされた時に見せるブラウザデフォルトの動き *少し色がかわったりするやつ)
  • イベントリスナに登録されてる関数の実行


(もし今何も処理が走っておらず、アイドル状態だったら)

最初のタスクが実行されてボタンがクリックされたようにみせるための部分描画(見た目が一瞬かわる)

関数の実行

setTimeout自身を処理する(引数で与えられてる関数はまだキューには入ってない)

whileブロックで遅延発生

                                    • -

遅延中
1s経過 => setTimeoutの処理がキューに追加される
2s経過

                                    • -


アイドル状態

setTimeoutで登録されてる処理の実行(アラート発生)


こんな感じで直前にいれられたタスクが終わるまでの間にdelayミリ秒の時間が経過してしまうと直前のタスク終了と同時に実行されてしまう。
これによって意図した遅延時間とずれたタイミングで実行される。



またUIスレッド以外にもそもそもタイマーの精度によってもある程度ずれる


どのブラウザもがんばって正確なdelayミリ秒後に処理をキューに追加しようとするがポーリングOSのシステムそのものの影響により実際の追加時刻は前後してしまう。


ここでのポーリングはブラウザのもってるポーリングのことでブラウザ上でのマウスの動きやタイマー付き処理の実行可否等を監視するループのことである。

ループの一周の速度はブラウザによってまちまちだがこの時間により厳密なタイマーの実行時刻から少しずれる。


この辺に関しては@sandai先生が細かくまとめられてるのでここを参照


もしポーリングが監視するイベントやタイマーが多ければこのポーリングはもっと遅くなる。


これをふまえると5ms後に実行とsetTimeoutで設定したところで厳密にみると結構ずれる。


そもそも、Windowsで実行する場合だと、Windowsシステム自体のタイマーの分解能が15msなのでこれより細かい時間を設定しても無駄な訳である。


まぁこれらの影響をうけるので厳密には遅延時間は設定値より多少ずれることがある。


ならこんなsetTimeoutなんて不要じゃないのか?


いやいやそんなことはないです。

そもそも誤差の話なんてUIで考えれば、100msを上回らなければユーザーにはまったく分からないレベルなので正確な時間感覚を必要としない場合なら普通に使えるし、そもそもsetTimeoutって遅延実行というよりキューへの追加をasyncに行えるところに大きなメリットがある。


ここからちょっと応用な話


このキューへの追加のasync化はとても処理に時間のかかるようなものを分割して行うことにつかえる。

誰しも一度は経験したことのあろう応答のないスクリプト警告


あれはFFで10s、safariで5s、IE500万行、jsが実行され続けたときにでるアラートだが、非常にデータ量の多いものをえいえん処理してたりするとでるものである。


例えば以下のような処理

FFでfirebugたちあげながらやれば10秒くらいした後に「応答のないスクリプト」とエラーがでるはずだ。
(出ない場合はmakeRandomArrの引数をふやすといいよ)


コード(jsfiddle)

(function (win, doc) {
    var i, l, arr, data, button;
   
    button = doc.getElementById('button1');
    button.addEventListener('click', function (evt) {        
        arr = makeRandomArr(300000);
        for (i=0, l=arr.length; i<l; i++) {
            data = arr.pop();
            console.log(data);
        }
    }, false);
    
    function makeRandomArr(len){
        var result = [], sign=1;
        while(len){
            sign = sign*-1;
            result.push(Math.floor(Math.random()*len)*sign);
            len--;
        }
        return result;
    }
}(window, document));

これがもし以下の条件を満たしてる場合、このアラートがでることを回避することができる

  • 処理は一度に行う必要がない
  • データを取り出す順番はきまってない


以下のように書いてやればいい

コード(jsfiddle)

(function (win, doc) {
    var arr,data,button;
   
    button = doc.getElementById('button1');
    button.addEventListener('click', function (evt) {        
        arr = makeRandomArr(300000);
 
        (function output() {
            data = arr.pop();
            console.log(data);
            
            if (arr.length) {
                setTimeout(function () {
                    output();
                }, 50);
            }
        }());
    }, false);

    function makeRandomArr(len){
        var result = [], sign=1;
        while(len){
            sign = sign*-1;
            result.push(Math.floor(Math.random()*len)*sign);
            len--;
        }
        return result;
    }

}(window, document));

こうすることでタスクとタスクの間に約50msのアイドル状態が発生して処理がUIスレッドをブロッキングすることを回避できます。


さらにこの細かいアイドル状態のときに他のタスクを割り込ませれば並列処理だってできるわけです。


Nodeとかで開発する際にこんな感じの大量のデータを扱うときに処理を分割しないとずっとイベントループに処理が帰らずに後の処理に大幅な遅延が発生してしまう。


大きな処理は非同期にごとごとと処理をしてやれば定期的にイベントループに処理が戻るので他の処理に遅延を発生させなくてすむのでシングルスレッドなjsでは大活躍します。



というわけで今回はこんな感じでした。


タスク分割はおっと色んなテクニックがあっておもしろいのでいつかまたやる気のあるときにとりあげてみたいと思います。


日をまたいじゃいましたが次はid:regepan さんです。

よろしくおねがいしまーす!