11
3

More than 1 year has passed since last update.

clearTimeoutで止まらない話

Last updated at Posted at 2021-12-23

アドベントカレンダーの季節ももうすぐ終わりですね。

私は「setTimeoutのコールバックにasync関数を渡して再帰呼び出しを行う場合、clearTimeoutで再帰呼び出しが止まらない話」を書きたいと思います。
※モチベーション元はrequestAnimationFrameの再帰呼び出しですが、止まらなかった原因は同じです

モチベーション

ある時、下記のような再帰処理を、画面の描画更新用に書いていたのですが、ちょっと描画がカクツクなーと思っていました。

let id = 0;

const startRender = () => {
  const render = async() => {
    ...
    const hoge = await heavyFunc(someCanvasElement);
    ...
    id = requestAnimationFrame(render);
  }
  cancelAnimationFrame(id); // 以前に実行したstartRenderで設定されたコールバックは解除したい
  id = requestAnimationFrame(render);
}

// 複数回呼ばれることを想定
startRender();

重い計算(await heavyFunc(someCanvasElement))してるから多少は仕方がないと放置する日々でしたが、ある修正をしたところ描画が滑らかになりました。

描画のカクツキの原因は、適切に古い再帰処理が止まっておらず、ムダなコールバックが実行され続けていたことにあります。
Chrome DevToolsのPerformanceタブと睨めっこしていたわけではないので、状況証拠ですが、、、

本日は放置への反省を込め、原因と対策を記事にしておきたいと思います。

デモ

早速ですが、デモを作ってみたので、前述のコードでピンときていない方はご確認ください。

See the Pen stoppable?-settimeout-loop by haradabox (@haradabox) on CodePen.

startを押すとカウントアップが始まり、stopを押すとカウントアップが止まる、シンプルなカウンターが2つ並んでいます。

両方ともsetTimeoutの再帰呼び出しで実装されていますが、一方は確実に止まり、もう一方は止まることもあれば、止まらないこともあります。

動作が不安定な原因

コードを見てみましょう。

止まる方のカウンター

  1. startボタンクリックでstartメソッドの実行
  2. (最低)100ミリ秒後に実行されるコールバックを設定し、timeoutIDを保持
  3. コールバックの最後でstartメソッドを再帰呼び出しする
  4. stopボタンクリックでclearTimeout
class Counter {
  constructor(renderer) {
    this.count = 0;
    this.timer = null;
    this.renderer = renderer;
  }
  start() {
    this.timer = setTimeout(() => {
      this.count++;
      this.renderer(this.count);
      this.start();
    }, 100);
  }
  stop() {
    clearTimeout(this.timer);
  }
}

const counter = new Counter((count) => {
  document.getElementById("count").innerText = count;
});
const $start = document.getElementById("start");
const $stop = document.getElementById("stop");

$start.addEventListener("click", () => {
  counter.start();
});
$stop.addEventListener("click", () => {
  counter.stop();
});

こちらはsetTimeoutのコールバック関数が同期的に実行されるので、stopボタンクリック時に保持しているtimeoutIDは常に実行前のものとなります。

つまり下記です。

  1. setTimeout実行
  2. (最低)100ミリ秒待つ ★ このタイミングでしかstopが実行できないので、コールバック呼び出しが確実に解除できる
  3. コールバック実行。1へ戻る

止まることもあれば、止まらないこともある方のカウンター

  1. startボタンクリックでstartメソッドの実行
  2. (最低)100ミリ秒後に実行される非同期コールバック(途中でawaitを挟み、(最低)50ミリ秒待つ)を設定し、timeoutIDを保持
  3. 非同期コールバックの最後でstartメソッドを再帰呼び出しする
  4. stopボタンクリックでclearTimeout
class AsyncCounter {
  constructor(renderer) {
    this.count = 0;
    this.timer = null;
    this.renderer = renderer;
  }
  start() {
    this.timer = setTimeout(async() => {
      this.count = await new Promise((resolve) => {
        setTimeout(() => resolve(this.count + 1), 50);
      });
      this.renderer(this.count);
      this.start();
    }, 100);
  }
  stop() {
    clearTimeout(this.timer);
  }
}

const counter2 = new AsyncCounter((count) => {
  document.getElementById("count2").innerText = count;
});
const $start = document.getElementById("start");
const $stop = document.getElementById("stop");

$start.addEventListener("click", () => {
  counter2.start();
});
$stop.addEventListener("click", () => {
  counter2.stop();
});

こちらのコールバック関数は非同期であるため、stopボタンクリック時のtimerIDが常に実行前のものとは言えず、stopボタンをクリックするタイミングで結果が異なります

つまり下記です。

  1. setTimeout実行
  2. 約100ミリ秒待つ ★ ここでstopが実行されると、コールバック呼び出しを解除できる
  3. コールバックのawaitまでを実行
  4. 約50ミリ秒待つ ★ ここでstopが実行されても、すでにコールバックは呼び出されているため、await以後の再帰呼び出しも実行されてしまう
  5. コールバックのawait以後を実行。1へ戻る

これではいけませんね。なんとかしましょう。

動作安定版デモ

修正したものがこちらです。

See the Pen stoppable?-settimeout-loop by haradabox (@haradabox) on CodePen.

止まりましたね。

確実に止まるようになったカウンター

  1. startボタンクリックでstartメソッドの実行 ★startメソッド実行時の時間を保持しておく
  2. renderメソッドで非同期コールバックを設定したsetTimeoutの実行、timeoutIDを保持
  3. ★renderメソッド再帰呼び出し前に、保持しておいたstartメソッド実行時の時間を使って、再帰呼び出しすべきかどうかの判定を行う
  4. 非同期コールバックの最後でrenderメソッドを再帰呼び出しする
class AsyncCounter {
  constructor(renderer) {
    ...
    this.startTime = null;
  }
  start(startTime) {
    this.startTime = startTime;
    this.render(startTime);
  }
  render(startTime) {
    this.timer = setTimeout(async() => {
      this.count = await new Promise((resolve) => {
        setTimeout(() => {
          resolve(this.count + 1);
        }, 50);
      });
      this.renderer(this.count);
      // startやstopが実行されると、
      // 古いrender実行コンテキストのstartTimeと、最新のthis.startTimeで差が出るため、
      // コールバックの再帰呼び出しが抑制できる
      if(this.startTime !== startTime) return;
      this.render(startTime);
    }, 100);
  }
  stop() {
    this.startTime = null;
    ...
  }
}
...
$start.addEventListener("click", () => {
  counter2.start(performance.now());
});
...

つまり、

  1. setTimeout実行
  2. (最低)100ミリ秒待つ ★ ここでstopが実行されると、コールバック呼び出しを解除できる
  3. コールバックのawaitまでを実行
  4. (最低)50ミリ秒待つ ★ この間にstopが実行されていれば、ガード節により以降の再帰呼び出しはされない
  5. コールバックのawait以後を実行。1へ戻る

というわけで、確実に止まるようになりました。

むすび

素朴なミスでしたね。

ただ、モチベーションでも書きましたが、「表示重い気がするけど、やむない気もする」系の実装不備は、時と場合で放置されてしまっている場面もあるのかなと思います。

気づいた時にはぜひ、なんで重いのか探ってみましょう。

案外素朴に解決できるかもしれませんよ。

11
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
3