ちょっと大げさなタイトルだけど単にJavaScriptで時間を計測するような処理を実装しようとしたら上手く行かなかった話。
最初は普通に1秒おきにカウントしていけばいいだけなんじゃないの?と思って安直にこんな感じで書いてみた。
//ゼロ埋め const paddingZero = (number) => { if(number < 10) { return ('00' + number).slice(-2) } return number } //1秒おきに経過した秒を時分秒に変換して出力 const showTimer = (second) => { const h = second / 3600 | 0 const m = second % 3600 / 60 | 0 const s = second % 60 process.stdout.write(paddingZero(h) + ":" + paddingZero(m) + ":" + paddingZero(s) + '\r') } let i = 0 showTimer(0) setInterval(()=>{ i++ showTimer(i) },1000)
だがしかし、実際に動かしてみてもらうとおわかりだと思うがこれだと早々に遅延が発生するのだ。
数分動かせば数秒単位でズレが出るのが簡単にわかるでしょう。
setTimeout() / setInterval()の罠
罠というか常識的なものみたいだけど、これまであまり気にしてこなかった。恥ずかしい。
JavaScriptにおけるsetInterval()は指定したミリ秒の間隔で定期的に処理を実行してくれるというものではあるが、その精度は正直あまりよくないのだ。
試してみるとわかるが実際には数ミリ秒の遅れが発生する。特にこの遅れはブラウザで顕著。
この理由としては一説にはHTML5の仕様という話もあるがどうなのか。以下はHTML5におけるTimer関連のスペックについて記載されているページ。
ここにこう記載されている。
Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.
つまり、5つのネストされたタイマーのあとはインターバルが強制的に最小で4ミリ秒になると書かれている。そして、そもそもこうも書かれている。
This API does not guarantee that timers will fire exactly on schedule. Delays due to CPU load, other tasks, etc, are to be expected.
つまり、本来正確性は保証されていないのである。CPU負荷や他タスクによって遅延すると言われているのである。
まず前者について。
雑にこんな感じのコードをブラウザ上で実行してみる。もし試すならChromeのDeveloper ToolsのConsole上にコピペなりするといい。無限ループするのでタブを閉じないと止められなくなるけど。
let start = Date.now(); const run = () => { console.log(Date.now() - start) setTimeout(run, 0) } run()
結果はこんな感じになる。表示されているのは開始時間からの経過時間(ミリ秒)だ。
1 2 3 4 6 10 15 20 24
見ての通り5回目以降が4msから5ms遅れているのがわかる。多少のブレはCPU負荷等によるものだと思われる。
ここではsetTimeout()で試したけど、setInterval()は以下のページ曰く実行の間の遅延を保証しないってことなのでさらに精度が怪しくなる。
スケジューリング: setTimeout と setInterval
つまり、冒頭で示した僕のコードはダメダメなのです。
ちなみにこの問題、ブラウザにおけるJSエンジンの歴史的な経緯なんかもあるらしい。したがってサーバーサイドJS、つまりNode.jsではこれらの制限はない模様。
一方でReact Nativeはこの影響を受けてる模様。恐らくReact Nativeで利用されるJSエンジンがJavaScriptCoreっていうSafari由来のものだからだと思われる。
解決策
というわけで、setInterval()を使って1秒おきにカウントするって方式をやめて、スタートしてからの経過時間(秒)を取得してそれをsetTimeout()を使って再帰的に表示する方式にするだけでいい。
const paddingZero = (number) => { if(number < 10) { return ('00' + number).slice(-2) } return number } const startTime = new Date().getTime() const showTimer = () => { const currentTime = new Date().getTime() const elapsedTime = new Date(currentTime - startTime) const h = elapsedTime.getHours() const m = elapsedTime.getMinutes() const s = elapsedTime.getSeconds() process.stdout.write(h + ":" + m + ":" + s + '\r') const timerId = setTimeout(showTimer,10) } showTimer()