JavaScript
yield
await

awaitの取り入れ方

これまでの記事で、以下のことを示しました。



  • awaitを使う理由は、別の処理に実行権を譲ること


  • awaitを使うと、プログラムの複雑さが増す

  • まずはawaitを使わずに実装し、時間のかかる処理からawaitを取り入れる

今回の記事では、どのようにしてawaitを取り入れるかについて説明していきます。


awaitの取り入れ方

例として、以下のコードにawaitを取り入れることを考えます。

const heavy = () => {

for (let i = 0; i < 1000; i++) {
light(); // 0.01秒
}
};

light()には0.01秒かかり、それを1000回呼ぶheavy()は10秒(0.01秒×1000)かかります。この処理にはawaitがないため、heavy()を呼ぶと10秒間フリーズします。ここにawaitを取り入れます。

取り入れる手順は以下になります。



  1. sleep()を定義する


  2. await sleep(0);を適切な場所に記述する

  3. 関数の定義にasyncを付ける

sleep()の定義にはいくつかバリエーションがありますが、今回はwindow.setTimeout()を使った実装にします。次にawait sleep(0);の記述場所ですが、具体的には「軽い処理と軽い処理の間」が記述に適した場所です。ということで、light();の前もしくは後に記述します。

awaitを取り入れたコードは次のようになります。

const sleep = msec => {

return new Promise(resolve => {
window.setTimeout(() => resolve(), msec);
});
};

const heavy = async () => {
for (let i = 0; i < 1000; i++) {
light();
await sleep(0);
}
};

このコードでは、light()呼び出し後に別の処理に実行権を譲っています。したがって、フリーズを体感することなくheavy()の処理が終わります。


実行権を譲る=遅い

実行権を譲るということは、一度実行権を手放すということです。一度手放した実行権が戻ってくるのには時間がかかるため、手放す回数が多いほど、await sleep(0);にかかる時間の割合は大きくなります。具体的には、await sleep(0);行をコメントアウトするかどうかで実行時間が4秒以上変わります。

実際に動かしたコード

const sleep = msec => {

return new Promise(resolve => {
window.setTimeout(() => resolve(), msec);
});
};

const light = () => {
const start = new Date().getTime();
while (new Date().getTime() - start < 10);
};

const heavy = async () => {
for (let i = 0; i < 1000; i++) {
light();
await sleep(0); // ここをコメントアウト
}
};

(async () => {
const s = performance.now();
await heavy();
console.log(performance.now() - s);
})();


このように、awaitの回数に応じて実行時間は長くなるため、極力awaitの回数を少なくしたいです。そこでheavy()を次のように書き換えます。

const heavy = async () => {

let c = 0;
for (let i = 0; i < 1000; i++) {
light();
c++;
if (c % 3 === 0) {
c = 0;
await sleep(0);
}
}
};

書き換え前のコードではawaitが1000回実行されていましたが、書き換え後のコードでは333回になりました。これは、light()を3回実行してからawaitを1回実行するということです。間に0.03秒のフリーズが発生しているため、少しカクつくかな?と感じるかもしれませんが、その代わりに実行時間が2.6秒以上短縮されます。


このあたりは、実際に動かしながらチューニングする部分です。3回に1回ではカクつきがひどいと感じるかもしれません。そういったときは、c % 3 === 032に書き換えます。逆に、カクつきがひどくてもいいから早く処理を終わらせたい場合は345に書き換えます。このように、実際に動かしながら妥協点を探していきます。



heavy()の変貌

heavy()の修正前と修正後のコードを比較します。

const heavy = () => {

for (let i = 0; i < 1000; i++) {
light();
}
};

const heavy = async () => {

let c = 0;
for (let i = 0; i < 1000; i++) {
light();
c++;
if (c % 3 === 0) {
c = 0;
await sleep(0);
}
}
};

修正前のコードは読みやすいです。heavy()light()を1000回呼び出す関数だということがひと目で分かります。しかし、修正後のコードはごちゃっとしており、ひと目ではheavy()が何をしているかがわかりません。

なぜごちゃっとしているかというと、「light()を1000回呼び出す処理」と「3回に1回スリープする処理」が一つのブロック内に記述されているからです。


yieldを使って処理を分離

この2つの処理を分離する方法として、yieldを使う方法があります。ということで、実際にheavy()yieldを取り入れてみます。

まず、heavy()の適切な場所にyieldを記述します。「適切な場所」というのは、await sleep(0);のときと同じで「軽い処理と軽い処理の間」です。

const heavy = function* () {

for (let i = 0; i < 1000; i++) {
light();
yield;
}
};

次に、heavy()の呼び出し側のコードを次のように書き換えます。

(async () => {

for (var _ of heavy()) {
await sleep(0);
}
})();

これでyieldによる修正は終わりです。

この修正の優れているところは、「light()を1000回呼び出す処理」と「スリープする処理」が完全に分かれていることです。

スリープする回数を3回に1回の頻度に減らしてみます。このとき、heavy()を一切書き換えることなく修正が完了します。

const heavy = function* () {

for (let i = 0; i < 1000; i++) {
light();
yield;
}
};

(async () => {
let c = 0;
for (var _ of heavy()) {
c++;
if (c % 3 === 0) {
c = 0;
await sleep(0);
}
}
})();

まったくスリープしないという選択もできます。もちろん、heavy()を修正する必要はありません。

(() => {

for (var _ of heavy());
})();

yieldawaitを使うことで、2つの異なる処理を綺麗に分離できることがわかりました。


まとめ

この記事では、awaitの取り入れ方について解説しました。そして、yieldawaitを組み合わせることで、2つの異なる処理を綺麗に分離できることを示しました。