はじめに
Scratch3 の『1秒待つ』は本当に1秒待つのだろうかと、調べてみた。
※ Scratch3 とは
https://scratch.mit.edu/
計測する
タイマー機能を使う。
『1秒待つ』の前で『タイマーをリセット』、『1秒待つ』の後で『タイマー値』を表示する。
コード
1秒待った結果
1秒よりも23msだけ余計に待っている。意外と誤差が大きい。
今度は『0秒』待ってみる
33ms 待っている。『1秒待つ』よりも誤差が大きい。
いろんな秒数で待ってみる
〇秒待つの秒数 | タイマー値 | 誤差 | 誤差の大きさ |
---|---|---|---|
0 秒 | 33 ms | 33 ms | 大 |
1 秒 | 1023 ms | 23 ms | 大 |
2 秒 | 2013 ms | 13 ms | 大 |
3 秒 | 3003 ms | 3 ms | 小 |
4 秒 | 4026 ms | 26 ms | 大 |
5 秒 | 5016 ms | 16 ms | 大 |
6 秒 | 6006 ms | 6 ms | 小 |
7 秒 | 7028 ms | 28 ms | 大 |
8 秒 | 8018 ms | 18 ms | 大 |
9 秒 | 9009 ms | 9 ms | 小 |
結果
- 0 秒待つのとき、33 ms 待つ
- 3の倍数でないときは 誤差が 大き目
- 3の倍数の秒のときは 誤差が 小さめ (不思議だね)
〇秒待つの仕組みを紐解く
Scratch3 における 33ms の呪縛
『待つ秒数』により誤差が発生するのは『33msの呪縛』のためである。
簡単にいうと、『〇秒待つ』は 33ms ごとに 下記の判定が行われて
現在時刻 - 『待つ』を開始した時刻 < 待つ秒数
上が成立する間『待つ』ことになるのですが、『33 ms』ごとの判定になります。
33 ms × 判定回数(整数) が 待つ時間を超えたときの時間 = 『待つ』時間になります。
〇秒待つの秒数 | 判定の回数 | 待つ時間の式 | 待つ時間 |
---|---|---|---|
0 秒 | 1 回 | 33 ms × 回数(1) | = 33 ms |
1 秒 | 31 回 | 33 ms × 回数(31) | = 1023 ms |
2 秒 | 61 回 | 33 ms × 回数(61) | = 2013 ms |
3 秒 | 91 回 | 33 ms × 回数(91) | = 3003 ms |
以下略 |
33 ms の単位なので 3の倍数の秒数のときは、ちょうどいい感じに誤差が小さくなるようです。
実装(Scratch-vm)を見てみる
runtime.js タイマー処理( 33ms )
- 33ms ごとに タイマー処理が動き _step() を呼び出します。
- intervalには 標準では 1000/30 (= 33 ms )が入ります。
- _step() の中では、sequencer.stepThreads()を呼び出しします。
- sequencer.stepThreads()の中では、runtime.updateCurrentMSecs()を呼び出しします。
- runtime.updateCurrentMSecs() の中では、runtimeのインスタンス変数 currentMSecs へ現在時刻( ms )を入れます。
- つまり runtime.currentMSecs は 33ms ごとに 時刻(ms)が変化するわけです。
start () {
~
let interval = Runtime.THREAD_STEP_INTERVAL;
if (this.compatibilityMode) {
interval = Runtime.THREAD_STEP_INTERVAL_COMPATIBILITY;
}
this.currentStepTime = interval;
this._steppingInterval = setInterval(() => {
this._step();
}, interval);
~
}
~
_step () {
~
const doneThreads = this.sequencer.stepThreads();
~
}
stepThreads () {
~
this.runtime.updateCurrentMSecs();
~
}
updateCurrentMSecs () {
this.currentMSecs = Date.now();
}
wait処理の実装( scratch-vm )
wait処理が呼び出されると
- 最初の呼び出しのときは、(1) 開始時刻を記録し (2) 描画処理を実行し (3) 他スレッドに処理を譲る(=yield)
- 2回目以降で最後の呼び出しでないときは、(1) 何もせずに他スレッドに処理を譲る(=yield)
の動きになります。他スレッドに処理を譲る(=yield)ことにより waitを実現します。何度も繰り返し waitが呼び出されますが、〇秒経過するまでは 他スレッドに処理を譲る(=yield)を続けます。yield()の後に(33ms後に)再度waitが呼ばれます。これが wait の実装です。
(33ms後に)と書きましたが、33ms の間隔をあけずに繰返してwait() を呼び出す場合もあります。このお話はこことは直接関係ないので後日に説明したいと思います。ここでは 33ms の間隔で wait() が呼ばれるものだと思ってください。
wait (args, util) {
if (util.stackTimerNeedsInit()) {
const duration = Math.max(0, 1000 * Cast.toNumber(args.DURATION));
util.startStackTimer(duration);
this.runtime.requestRedraw();
util.yield();
} else if (!util.stackTimerFinished()) {
util.yield();
}
}
util.stackTimerFinished()は下記の実装です
経過時間(timeElapsed) < 待つ時間( duration ) のときは 待つは終了しない(false)です。
stackTimerFinished () {
const timeElapsed = this.stackFrame.timer.timeElapsed();
if (timeElapsed < this.stackFrame.duration) {
return false;
}
return true;
}
timer.timeElapsed()は下記の実装で経過時間を得ることができます。
現在時刻(= this.nowObj.now() ) - 開始時刻( = this.startTime )で経過時間を計算します。
timeElapsed () {
return this.nowObj.now() - this.startTime;
}
ここで注意して欲しいのが 現在時刻(= this.nowObj.now() )は 厳密な現在時刻ではありません。block-utility.js を見たらわかります。
- block-utility.owObj.now() は、runtime.currentMSecs です。
- runtime.currentMSecs は、runtimes.js 内の タイマー処理(33ms)で更新される値ですので 33ms の倍数です。
constructor (sequencer = null, thread = null) {
~
this._nowObj = {
now: () => this.sequencer.runtime.currentMSecs
};
}
get nowObj () {
if (this.runtime) {
return this._nowObj;
}
return null;
}
実装(scratch-vm)を見ると、『〇秒待つ』は 33msの倍数の時間分待つのだ!と理解できると思います。
『〇秒待つ』のときの経過時間がぴったり〇秒になるのは?
33ms の倍数 であるのなら、『3.3 秒待つ』のときは 経過時間= 3300ms になるはずですよね。試してみましょう。
予想どおりでした。