0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

重いループ処理でUIを止めないために MessageChannel で yield する

0
Posted at

はじめに

JavaScriptで他の処理を邪魔しないように重いループ処理を実行したーい!

結論

できました!

// 重い処理のループ
async function processItems(items: Item[]) {
  for (const [i, item] of items.entries()) {
    heavyWork(item);

    if ((i + 1) % 100 === 0) {
      await yieldToEvents();
    }
  }
}

const yieldToEvents = (() => {
  const { port1, port2 } = new MessageChannel();
  const callbacks: (() => void)[] = [];
  port1.onmessage = () => callbacks.shift()?.();

  return () =>
    new Promise<void>((resolve) => {
      callbacks.push(resolve);
      port2.postMessage(null);
    });
})();

すべてCodexがやってくれました!

解説

解説というより、結論にたどり着くまでの流れ。

async 使えばええやろ(無理)

最初は単に非同期で呼び出ししてただけだったんですよね。非同期呼び出しなんだからこれでメインの処理をブロックすることはないよね、と思っていました。

async function processItems(items: Item[]) {
  for (const item of items) {
    heavyWork(item);
  }
}

でもそんなことはないようで、なんで?と思っていたら、JavaScriptはあくまでシングルスレッドで動作しているので async を付けただけでは別スレッドで実行してくれるわけではないようです。

このあたりはイベントループの話になるので、以下のような解説記事を読むのがおすすめです。

setTimeout を使うぜ(うまくいく場合もあるけど...)

そこで最初は、setTimeout で一瞬処理を中断してメインの処理に戻してやればいいんだ、と思って以下のようにしました。

async function processItems(items: Item[]) {
  for (const [i, item] of items.entries()) {
    heavyWork(item);

    if ((i + 1) % 100 === 0) {
      // 待ち時間0秒(?)でメインの処理に戻すことで分割して処理をする?
      await new Promise((resolve) => setTimeout(resolve, 0));
    }
  }
}

しかしこうしたらメインの処理には戻ったものの、processItems の処理がいつまで経っても戻ってきませんでした。
なんでー?と思いつつMDNのドキュメントの setTimeout() method を見ると delay=0 を指定しても、処理が積み重なった場合は最小4msの時間がかかるらしいです。

そうすると setTimeout の呼び出し回数 x 4ms がかかることになります。重い処理が数回実行されるケースでは問題なさそうですが、純粋にループ数が多い場合、この4msの積み重ねによって処理が終わらない、という状況になっていたようでした。

実際、返ってこなかった処理ではループ回数が1000万回近くになってたので、それだーってなりました。

MessageChannel で処理を分割する

そういうわけで MessageChannel を使用します。

最初のコードの再掲
async function processItems(items: Item[]) {
  for (const [i, item] of items.entries()) {
    heavyWork(item);

    if ((i + 1) % 100 === 0) {
      await yieldToEvents();
    }
  }
}

const yieldToEvents = (() => {
  const { port1, port2 } = new MessageChannel();
  const callbacks: (() => void)[] = [];
  port1.onmessage = () => callbacks.shift()?.();

  return () =>
    new Promise<void>((resolve) => {
      callbacks.push(resolve);
      port2.postMessage(null);
    });
})();

正直これでなんでうまくいくか正しく理解はできていないのですが、やっていることは

メッセージを受信する処理を別のイベントとして実行させることで、現在実行している処理をいったん終了し、続きを別のタスクとして実行することで、その間にブラウザがほかの処理を行える機会を作っている

ということをしているらしいです。まあこのコードだけで言えば雑に言うと待ち時間なしで setTimeout をしている、くらいに思ってもらえば。

scheduler.yield() について

この記事を書いている途中で知ったのですが scheduler.yield() というAPIもあるようです。

await scheduler.yield();

これが使える環境であれば、これが一番良さそうです。ただ、執筆時点では一部の主要ブラウザで未対応のようでした。

おわりに

MessageChannel でループ処理を分割して非同期に実行する方法でした。
メッセージを送るという機能なのにこういう使い方ができるって面白いなーと思って記事にしました。面白いよね?

ちなみに理解できているかと言われるとそんなこともない。イベントループ難しいね?

(件数分割ではなく、performance.now() で経過時間を見て分割していれば、setTimeout でも問題なかったのでは?というのは秘密だよ。)

参考

重い処理をどうにかしよう系の記事

イベントループ

MDN

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?