時間のかかるタスクを最適化する

「メインスレッドをブロックしない」や「長いタスクを分割する」などと言われましたが、これらはどういう意味でしょうか。

Jeremy Wagner 氏
Jeremy Wagner

ウェブ パフォーマンスに関する記事をよく読んでいる方は、JavaScript アプリを高速にするためのアドバイスとして、次のようなヒントが参考になります。

  • 「メインスレッドをブロックしないでください。」
  • 「時間のかかるタスクを分割して」

これはどういう意味でしょうか?JavaScript の配布を減らすことは良いことですが、それによってページのライフサイクル全体を通じてユーザー インターフェースが高速になるのではないでしょうか?その可能性もありますが、そうでない可能性もあります。

JavaScript でタスクを最適化することが重要である理由を理解するには、タスクの役割とブラウザがタスクをどのように処理するのかを理解する必要があります。そして、それはタスクとは何かを理解することから始まります。

タスクとは

タスクとは、ブラウザが実行する個々の作業のことです。タスクには、レンダリング、HTML と CSS の解析、ユーザーが記述した JavaScript コードの実行など、ユーザーが直接制御できない作業が含まれます。これらすべての中で、ユーザーが記述してウェブにデプロイする JavaScript は、主要なタスクのソースです。

Chrome の DevTools のパフォーマンス プロファイルに表示されるタスクのスクリーンショット。タスクはスタックの一番上にあり、その下にクリック イベント ハンドラ、関数呼び出し、その他のアイテムがあります。タスクには、右側にレンダリング処理も含まれています。
Chrome DevTools のパフォーマンス プロファイラの click イベント ハンドラによって開始されたタスクの図

タスクはいくつかの点でパフォーマンスに影響します。たとえば、ブラウザが起動時に JavaScript ファイルをダウンロードすると、その JavaScript を実行できるよう解析、コンパイルするタスクをキューに入れます。ページのライフサイクルの後半では、イベント ハンドラによるインタラクション、JavaScript ドリブンのアニメーション、アナリティクスの収集などのバックグラウンド アクティビティなど、JavaScript が動作したときにタスクが開始されます。これらはすべてメインスレッドで行われます(ウェブ ワーカーや類似の API を除く)。

メインスレッドは何か。

メインスレッドは、ブラウザ内でほとんどのタスクが実行される場所です。メインスレッドと呼ばれるのは、作成する JavaScript のほぼすべてが処理を行う 1 つのスレッドであるため、メインスレッドと呼ばれます。

メインスレッドは一度に 1 つのタスクしか処理できません。ある時点(厳密には 50 ミリ秒)を超えて拡張されたタスクは、長いタスクに分類されます。長時間のタスクの実行中にユーザーがページを操作しようとした場合、または重要なレンダリングの更新が必要な場合は、ブラウザでその処理が遅延します。その結果、インタラクションやレンダリングのレイテンシが発生します。

Chrome の DevTools のパフォーマンス プロファイラで行う長時間のタスク。タスクのブロック部分(50 ミリ秒超)は、赤色の斜線のパターンで示されています。
Chrome のパフォーマンス プロファイラに表示される時間のかかるタスク。時間のかかるタスクは角にある赤い三角形で示され、タスクのブロック部分は赤色の斜線のパターンで塗りつぶされます。

タスクを分割する必要があります。つまり、時間のかかる 1 つのタスクを複数の小さなタスクに分割して、個別に実行するのに時間がかかるようにするということです。

1 つの長いタスクと、同じタスクを短いタスクに分割します。長いタスクは 1 つの大きな長方形で、チャンクされたタスクは 5 つの小さなボックスで、全体として長いタスクと幅があります。
長い 1 つのタスクと、同じタスクを 5 つの短いタスクに分割した図。

タスクが分割されていると、ブラウザは優先度の高い作業に対応する機会が増えるため、これは重要です。これにはユーザー操作も含まれます。

タスクを分割してユーザーの操作を促す方法の図。上部にある長いタスクは、タスクが終了するまでイベント ハンドラの実行をブロックします。下部では、チャンク化されたタスクによって、イベント ハンドラが実行しない場合よりも早くイベント ハンドラが実行されます。
タスクが長すぎてブラウザがインタラクションに迅速に応答できない場合と、長いタスクが小さなタスクに分割された場合とで、インタラクションがどうなるかの可視化。

上の図の上部では、ユーザー操作によってキューに追加されたイベント ハンドラは、1 つの長いタスクを待機しなければならなかったため、操作が発生しません。下部には、イベント ハンドラが早期に実行される可能性があります。イベント ハンドラは、小さなタスクの間で実行する機会があったため、長いタスクが終了するのを待つよりも早く実行されます。上の例では、ユーザーが遅延に気づいたかもしれません。下の例では、操作が即時に感じられた可能性があります。

ただし、問題は、「時間のかかるタスクを分割する」や「メインスレッドをブロックしない」というアドバイスは、方法をすでにわかっていない限り具体的でないことです。このガイドではそれについて説明します。

タスク管理の戦略

ソフトウェア アーキテクチャでの一般的なアドバイスは、作業を小さな機能に分割することです。これにより、コードの可読性が向上し、プロジェクトの保守性が向上するというメリットがあります。これにより、テストの作成も簡単になります。

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

この例では、saveSettings() という名前の関数があり、その中にある 5 つの関数を呼び出して、フォームの検証、スピナーの表示、データの送信などの処理を行います。概念的には、これは適切に設計されています。これらの関数をデバッグする必要がある場合は、プロジェクト ツリーをたどって各関数の動作を把握できます。

ただし問題は、JavaScript がこれらの関数をそれぞれ別のタスクとして実行しないということです。これらの関数は saveSettings() 関数内で実行されているためです。つまり、5 つの関数すべてが 1 つのタスクとして実行されます。

Chrome のパフォーマンス プロファイラに表示される saveSettings 関数。トップレベルの関数は他の 5 つの関数を呼び出しますが、すべての処理はメインスレッドをブロックする 1 つの長いタスクで行われます。
5 つの関数を呼び出す単一の関数 saveSettings()。この作業は、1 つの長いモノリシック タスクの一部として実行されます。

最良のシナリオでは、これらの関数の 1 つでも、タスクの合計時間が 50 ミリ秒以上になります。最悪の場合、特にリソースに制約のあるデバイスでは、より多くのタスクの実行時間がかなり長くなることがあります。ここでは、タスクを分割して優先順位を付けるための戦略を紹介します。

コード実行を手動で延期する

デベロッパーがタスクを小さなものに分割するために使用している方法の一つに、setTimeout() があります。この手法では、関数を setTimeout() に渡します。これにより、タイムアウトを 0 に指定しても、コールバックの実行が別のタスクに延期されます。

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

この方法は、一連の関数を順次実行する必要がある場合には適していますが、コードが必ずしもこのように編成されているとは限りません。たとえば、ループで処理する必要がある大量のデータがあり、数百万のアイテムがある場合、そのタスクには長い時間がかかる可能性があります。

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

ここで setTimeout() を使用すると、人間工学的に実装が困難になります。また、各アイテムを非常に迅速に処理できる場合でも、データ配列全体の処理に非常に長い時間がかかるため、問題があります。結局のところ、setTimeout() はジョブに適したツールではありません。少なくとも、このように使用する場合はそうではありません。

setTimeout() の他にも、コード実行を後続のタスクに委ねることができる API がいくつかあります。1 つは、postMessage() を使用してタイムアウトを高速化するものですrequestIdleCallback() を使用して作業を分割することもできますが、その場合、requestIdleCallback() はブラウザのアイドル時間にのみ、可能な限り低い優先度でタスクをスケジュール設定します。メインスレッドが混雑すると、requestIdleCallback() でスケジュールされたタスクが実行されない場合があります。

async/await を使用して収益点を作成する

このガイドの残りの部分で登場する「メインスレッドに譲る」というフレーズがありますが、これはどういう意味でしょうか。メリット実行するタイミング

タスクを分割すると、ブラウザの内部優先順位付け方式によって他のタスクの優先順位が上がります。メインスレッドに放棄する方法の一つとして、setTimeout() の呼び出しによって解決される Promise の組み合わせを使用する方法があります。

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

saveSettings() 関数では、各関数呼び出しの後に yieldToMain() 関数を await すると、作業の各ビットの後にメインスレッドに譲ることができます。

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread:
    await yieldToMain();
  }
}

その結果、以前はモノリシックだったタスクが別々のタスクに分割されました。

Chrome のパフォーマンス プロファイラに表示されているのと同じ saveSettings 関数ですが、生成のみです。その結果、以前はモノリシックなタスクが 5 つの別々のタスク(関数ごとに 1 つ)に分割されます。
saveSettings() 関数は、その子関数を個別のタスクとして実行するようになりました。

setTimeout() を手動で使用するのではなく、Promise ベースのアプローチを使用して y を生成するメリットは、より使いやすい点で優れています。収益ポイントは宣言型になるため、記述、読み取り、理解が容易になります。

必要な場合にのみ歩く

多数のタスクがあり、ユーザーがページを操作しようとしたときにのみ処理を実行したい場合は、どうすればよいでしょうか。isInputPending() はまさにそのために作られました。

isInputPending() は、ユーザーがページ要素を操作しようとしているかどうかを判断するためにいつでも実行できる関数です。isInputPending() を呼び出すと true が返されます。それ以外の場合は false を返します。

実行する必要があるタスクのキューがあるものの、入力の邪魔にならないようにしたいとします。次のコード(isInputPending() とカスタムの yieldToMain() 関数の両方を使用)により、ユーザーがページを操作しようとしているときに入力が遅延するのを防ぐことができます。

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

saveSettings() は実行中に、キュー内のタスクをループ処理します。ループ中に isInputPending()true を返した場合、ユーザー入力を処理できるように saveSettings()yieldToMain() を呼び出します。それ以外の場合は、次のタスクがキューの先頭に移動され、継続的に実行されます。タスクがなくなるまでこの処理が実行されます。

Chrome のパフォーマンス プロファイラで実行されている saveSettings 関数の画像。結果として得られるタスクは、isInputPending が true を返すまでメインスレッドをブロックし、この時点でタスクはメインスレッドに譲ります。
saveSettings() は 5 つのタスクに対してタスクキューを実行しますが、2 番目の作業アイテムの実行中に、ユーザーがメニューをクリックして開きました。isInputPending() は、メインスレッドに放棄してインタラクションを処理し、残りのタスクの実行を再開します。

isInputPending() を yield メカニズムと組み合わせて使用すると、ブラウザが処理しているタスクをすべて停止させ、ユーザー向けの重要な操作に対応できるようになります。これにより、多くのタスクが実行されている状況で、ページがユーザーに適切に応答できるようになります。

isInputPending() を使用するもう一つの方法は、特に、それをサポートしていないブラウザでフォールバックを提供することを懸念している場合です。これは、時間ベースのアプローチをオプションのチェーン演算子と組み合わせて使用することです。

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

このアプローチでは、isInputPending() をサポートしていないブラウザに対してフォールバックを行います。これは、期限を使用(および調整)して、ユーザー入力に引き継ぐか、特定の時点で作業を分割するように、時間ベースのアプローチを使用します。

現在の API のギャップ

前述した API はタスクを分割するのに役立ちますが、大きな欠点があります。それは、コードを遅延させて後続のタスクで実行すると、そのコードがタスクキューの最後に追加されるという点です。

ページ上のすべてのコードを管理している場合は、タスクに優先順位を付ける機能を備えた独自のスケジューラを作成することも可能ですが、サードパーティのスクリプトはスケジューラを使用しません。実質的に、このような環境で処理に優先順位を付けることはできません。チャンク化するか、ユーザーの操作に明示的に放棄することしかできません。

幸い、こうした問題に対処する専用のスケジューラ API が現在開発中です。

専用のスケジューラ API

現在、scheduler API は postTask() 関数を提供しています。この機能は、執筆時点では Chromium ブラウザと Firefox でフラグによって提供されています。postTask() を使用すると、タスクをきめ細かくスケジュール設定できます。これは、優先度の低いタスクがメインスレッドに引き継がれるようにブラウザが処理に優先順位を付けるための方法の一つです。postTask() は Promise を使用し、priority 設定を受け入れます。

postTask() API には、使用できる優先度が 3 つあります。

  • 'background': 優先度が最も低いタスクです。
  • 'user-visible': 優先度が中程度のタスク。priority が設定されていない場合は、これがデフォルトです。
  • 'user-blocking' は、高い優先度で実行する必要がある重要なタスクに使用します。

次のコードを例にとります。ここでは、postTask() API を使用して 3 つのタスクを最も高い優先度で実行し、残りの 2 つのタスクを可能な限り低い優先度で実行しています。

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

ここでは、ブラウザで優先されるタスク(ユーザーの操作など)を処理できるようにタスクの優先度がスケジュールされます。

Chrome のパフォーマンス プロファイラに示されている saveSettings 関数ですが、postTask を使用します。postTask は、各関数を分割して、ユーザーの操作がブロックされることなく実行できるように優先順位付けします。
saveSettings() が実行されると、この関数は postTask() を使用して個々の関数のスケジュールを設定します。ユーザー向けの重要な処理は高い優先度でスケジュールされ、ユーザーが知らない処理はバックグラウンドで実行されるようにスケジュールされます。これにより、処理が適切に分割され、優先順位も適切に設定されるため、ユーザー操作がより迅速に実行されます。

これは postTask() の使用方法の簡単な例です。異なる TaskController オブジェクトをインスタンス化して、タスク間で優先度を共有することもできます。たとえば、TaskController インスタンスごとに異なる優先度を必要に応じて変更できます。

scheduler.yield を介した連続性を持つ組み込みの yield

スケジューラ API で提案されている部分の 1 つが scheduler.yield です。これは、現在オリジン トライアルとして試用可能な、ブラウザのメインスレッドに結果を渡すために特別に設計された API です。使用方法は、この記事で前に説明した yieldToMain() 関数に似ています。

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

上記のコードはおなじみのものですが、yieldToMain() を使用する代わりに await scheduler.yield() を呼び出しています。

放棄、放棄、放棄、継続を伴うタスクを示す 3 つの図。妥協しなければ、時間のかかるタスクに直面します。放棄を使用すると、より短いタスクが多くなりますが、無関係な他のタスクによって中断される可能性があります。yield と継続を使用すると、より短いタスクは多くなりますが、その実行順序は保持されます。
放棄、放棄、放棄、継続を伴うタスクの実行を可視化したものです。scheduler.yield() を使用すると、タスク実行は、明け渡し点後でも中断したところから再開されます。

scheduler.yield() のメリットは継続性です。つまり、一連のタスクの途中で放棄する場合は、他のスケジュール タスクも、その移行ポイントより後に同じ順序で続行されます。これにより、サードパーティのスクリプトのコードによってコードの実行順序が損なわれるのを防ぐことができます。

まとめ

タスクの管理は簡単ではありませんが、管理することでユーザーの操作にページがすばやく応答できるようになります。タスクの管理と優先順位付けについて、アドバイスは 1 つではありません。さまざまな手法があります繰り返しになりますが、タスクを管理する際は、主に以下の点を考慮してください。

  • ユーザー向けの重要なタスクについては、メインスレッドに譲ります。
  • ユーザーがページを操作しようとしたときに、isInputPending() を使用してメインスレッドに譲歩します。
  • postTask() を使用して、タスクに優先順位を付けます。
  • 最後に、関数での作業はできるだけ少なくします

これらのツールのうち 1 つ以上を使用すれば、重要性の低い処理を引き続き実行しながら、ユーザーのニーズを優先するようにアプリケーション内の処理を構造化できる必要があります。それによってユーザー エクスペリエンスが向上し、応答性と使いやすさが向上します。

この記事の技術的調査を行ってくれた Philip Walton に感謝します。

ヒーロー画像提供元: Unsplash(提供: Amirali Mirhashemian)。