Web ワーカーを使用してブラウザのメインスレッド以外で JavaScript を実行する

メインスレッド以外のアーキテクチャは、アプリの信頼性とユーザー エクスペリエンスを大幅に向上させることができます。

この 20 年間で、ウェブはいくつかのスタイルと画像を含む静的なドキュメントから、複雑で動的なアプリケーションへと劇的に進化しました。ただし、1 つだけ大きく変わっていないことがあります。それは、サイトのレンダリングと JavaScript の実行を行うスレッドがブラウザタブごとに 1 つしかないことです(例外はあります)。

その結果、メインスレッドが過負荷状態になっています。ウェブアプリの複雑さが増すにつれて、メインスレッドがパフォーマンスの大きなボトルネックになります。さらに、特定のユーザーのメインスレッドでコードを実行するのにかかる時間は、デバイスの性能がパフォーマンスに大きな影響を与えるため、ほぼ完全に予測不可能です。ユーザーがウェブにアクセスするデバイスは、制約の厳しいフィーチャー フォンから高性能で高リフレッシュ レートのフラッグシップ マシンまで、ますます多様化しており、予測不可能性は増すばかりです。

ウェブに関する主な指標(人間の知覚と心理学に関する実証データに基づく)などのパフォーマンス ガイドラインを高度なウェブアプリで確実に満たすには、コードをメインスレッド外(OMT)で実行する方法が必要です。

ウェブ ワーカーを使用する理由

JavaScript は、デフォルトではメインスレッドタスクを実行するシングル スレッド言語です。ただし、ウェブ ワーカーは、メインスレッドから処理を分離するためのエスケープ ハッチのような役割を果たします。デベロッパーは、メインスレッドから処理を分離するための別のスレッドを作成できます。ウェブ ワーカーのスコープは制限されており、DOM に直接アクセスすることはできませんが、メインスレッドに過負荷がかかるような大量の作業が必要な場合には、非常に有効です。

ウェブに関する主な指標に関しては、メインスレッドから処理をオフロードすることが有益な場合があります。特に、メインスレッドからウェブ ワーカーに処理をオフロードすると、メインスレッドの競合を減らすことができ、ページの Interaction to Next Paint(INP)の応答性指標を改善できます。メインスレッドで処理する作業が少ないほど、ユーザー操作にすばやく応答できます。

メインスレッドの作業が減ることで、特に起動時に、長いタスクが減り、Largest Contentful Paint(LCP)の改善につながる可能性もあります。LCP 要素のレンダリングにはメインスレッドの時間がかかります。テキストや画像(頻繁に発生する一般的な LCP 要素)のレンダリングにはメインスレッドの時間がかかります。メインスレッドの作業を全体的に減らすことで、ウェブ ワーカーが処理できる高コストの作業によってページの LCP 要素がブロックされる可能性を減らすことができます。

ウェブワーカーによるスレッド化

他のプラットフォームでは通常、スレッドに関数を指定して、プログラムの残りの部分と並行して実行できるようにすることで、並列処理をサポートしています。両方のスレッドから同じ変数にアクセスできます。これらの共有リソースへのアクセスは、ミューテックスとセマフォで同期して、競合状態を防ぐことができます。

JavaScript では、2007 年から存在し、2012 年からすべての主要ブラウザでサポートされているウェブ ワーカーからほぼ同様の機能を取得できます。ウェブ ワーカーはメインスレッドと並行して実行されますが、OS スレッドとは異なり、変数を共有できません。

ウェブ ワーカーを作成するには、ワーカー コンストラクタにファイルを渡します。これにより、別のスレッドでそのファイルの実行が開始されます。

const worker = new Worker("./worker.js");

postMessage API を使用してメッセージを送信することで、ウェブ ワーカーと通信します。postMessage 呼び出しでメッセージ値をパラメータとして渡し、ワーカーにメッセージ イベント リスナーを追加します。

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

メインスレッドにメッセージを送信するには、ウェブ ワーカーで同じ postMessage API を使用し、メインスレッドでイベント リスナーを設定します。

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

このアプローチには限界があります。これまで、ウェブ ワーカーは主に、重い処理を 1 つだけメインスレッドから移動するために使用されてきました。単一のウェブ ワーカーで複数のオペレーションを処理しようとすると、すぐに扱いにくくなります。パラメータだけでなくオペレーションもメッセージにエンコードし、レスポンスをリクエストに一致させるための簿記処理を行う必要があります。この複雑さが、ウェブ ワーカーが広く採用されていない理由でしょう。

しかし、メインスレッドとウェブ ワーカー間の通信の難しさを軽減できれば、このモデルは多くのユースケースに最適です。幸いなことに、まさにそれを行うライブラリがあります。

Comlink は、postMessage の詳細を考慮することなくウェブ ワーカーを使用できるようにすることを目的としたライブラリです。Comlink を使用すると、スレッドをサポートする他のプログラミング言語とほぼ同じように、ウェブ ワーカーとメインスレッドの間で変数を共有できます。

Comlink を設定するには、ウェブ ワーカーで Comlink をインポートし、メインスレッドに公開する一連の関数を定義します。次に、メインスレッドで Comlink をインポートし、ワーカーをラップして、公開された関数にアクセスします。

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

メインスレッドの api 変数は、ウェブ ワーカーの変数と同じように動作しますが、すべての関数が値自体ではなく値の Promise を返す点が異なります。

どのコードをウェブ ワーカーに移動する必要がありますか?

ウェブ ワーカーは DOM や WebUSBWebRTCWeb Audio などの多くの API にアクセスできないため、そのようなアクセスに依存するアプリの一部をワーカーに配置することはできません。それでも、メインスレッドに移動された小さなコードの断片は、ユーザー インターフェースの更新など、メインスレッドになければならない処理のための余裕を増やします。

ウェブ デベロッパーにとっての問題の一つは、ほとんどのウェブアプリが Vue や React などの UI フレームワークに依存してアプリ内のすべてをオーケストレートしていることです。すべてがフレームワークのコンポーネントであるため、本質的に DOM に関連付けられています。これでは、OMT アーキテクチャへの移行が難しくなります。

ただし、UI の懸念事項を状態管理などの他の懸念事項から分離するモデルに移行すると、フレームワーク ベースのアプリでもウェブ ワーカーが非常に役立ちます。PROXX ではまさにそのアプローチが取られています。

PROXX: OMT の事例紹介

Google Chrome チームは、オフラインでの動作や魅力的なユーザー エクスペリエンスなど、プログレッシブ ウェブアプリの要件を満たすマインスイーパーのクローンとして PROXX を開発しました。残念ながら、ゲームの初期バージョンはフィーチャー フォンのような制約のあるデバイスではパフォーマンスが低く、チームはメインスレッドがボトルネックになっていることに気づきました。

チームは、ウェブ ワーカーを使用してゲームのビジュアル状態をロジックから分離することにしました。

  • メインスレッドはアニメーションと遷移のレンダリングを処理します。
  • ウェブ ワーカーは、純粋に計算処理であるゲームロジックを処理します。

OMT は、PROXX のフィーチャー フォンでのパフォーマンスに興味深い影響を与えました。OMT 以外のバージョンでは、ユーザーが操作してから 6 秒間 UI がフリーズします。フィードバックがなく、ユーザーは 6 秒間待たないと次の操作に進めません。

PROXX の 非 OMT バージョンの UI 応答時間。

ただし、OMT バージョンでは、UI の更新が完了するまでに 12 秒かかります。パフォーマンスの低下のように見えますが、実際にはユーザーへのフィードバックの増加につながります。この遅延は、アプリが OMT 以外のバージョンよりも多くのフレームを送信していることが原因です。OMT 以外のバージョンはフレームをまったく送信していません。そのため、ユーザーは処理が行われていることを認識し、UI が更新されるまでプレイを続けることができます。これにより、ゲームの操作性が大幅に向上します。

PROXX の OMT バージョンの UI 応答時間。

これは意図的なトレードオフです。制約のあるデバイスのユーザーには、ハイエンド デバイスのユーザーにペナルティを課すことなく、より快適なエクスペリエンスを提供します。

OMT アーキテクチャの影響

PROXX の例に示すように、OMT を使用すると、アプリをより幅広いデバイスで確実に実行できますが、アプリの速度は向上しません。

  • 処理をメインスレッドから移動するだけで、処理自体は減っていません。
  • ウェブ ワーカーとメインスレッド間の通信オーバーヘッドが増加すると、処理がわずかに遅くなることがあります。

トレードオフを考慮する

JavaScript の実行中にメインスレッドがスクロールなどのユーザー操作を処理できるため、合計待機時間がわずかに長くなる場合でも、ドロップするフレームが少なくなります。フレームをドロップするよりも、ユーザーを少し待たせる方が望ましいです。なぜなら、フレームをドロップする際の誤差の範囲は小さいためです。フレームのドロップはミリ秒単位で発生しますが、ユーザーが待ち時間を認識するまでには数百ミリ秒あります。

デバイス間のパフォーマンスの予測不可能性のため、OMT アーキテクチャの目標は、並列化のパフォーマンス上のメリットではなく、リスクの軽減、つまり、実行時の条件が大きく変動してもアプリがより堅牢になるようにすることです。復元力の向上と UX の改善は、速度のわずかなトレードオフを補って余りあるものです。

ツールに関する注意事項

ウェブ ワーカーはまだ主流ではないため、webpackRollup などのほとんどのモジュール ツールは、すぐに使用できる状態でウェブ ワーカーをサポートしていません。(Parcel は可能です)。幸いなことに、webpack と Rollup でウェブ ワーカーを機能させるためのプラグインがあります。

まとめ

アプリの信頼性とアクセシビリティを可能な限り高めるため、特にグローバル化が進む市場では、制約のあるデバイスをサポートする必要があります。世界中のほとんどのユーザーが、制約のあるデバイスからウェブにアクセスしているからです。OMT は、ハイエンド デバイスのユーザーに悪影響を与えることなく、このようなデバイスのパフォーマンスを向上させる有望な方法です。

また、OMT には次のような二次的なメリットもあります。

  • JavaScript の実行コストを別のスレッドに移動します。
  • 解析のコストが移動するため、UI の起動が速くなる可能性があります。これにより、First Contentful PaintTime to Interactive が短縮され、Lighthouse スコアが向上する可能性があります。

ウェブ ワーカーは怖いものではありません。Comlink などのツールにより、ワーカーの作業が軽減され、幅広いウェブ アプリケーションでワーカーが実行可能な選択肢となっています。