はじめに
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 でも問題なかったのでは?というのは秘密だよ。)
参考
重い処理をどうにかしよう系の記事
-
長いタスクを最適化する(web.dev)
- 今回の内容とほぼ同じ内容を対象としている記事。この記事を書いてたら見つかった(Codexが見つけた)。実際のコードはこっちを参考にしたほうがいいかもね?
- setTimeout(…, 0) って何の意味があるの?
- フロントエンドでCPU負荷の高い計算を描画を止めずに実行する
- 長いタスクを分割するscheduler.yieldという提案
イベントループ
- イベントループを裏側から腹落ちさせる:JavaScriptエンジンとブラウザの役割、TaskとMicrotaskの正体
- JavaScriptのイベントループ
- JavaScript のイベントループ:microtask と macrotask
MDN