304 Not Modified は JavaScript も速くする?

はじめに

こんにちは、Repro Booster のプロダクトマネージャーの Edward Fox(@edwardkenfox)です。

HTTP 304 Not Modified というステータスコードをご存知でしょうか。ブラウザがキャッシュ済みのリソースを再検証する際、サーバーが「変更されていないのでキャッシュを使ってください」と応答するためのステータスコードです。レスポンスボディが省略されるため転送量が削減され、結果としてページの読み込みが速くなる、というのが一般的な説明かと思います。

304 レスポンスには転送量の削減以上のメリットがあります。それは ChromeChromium)の JavaScript エンジンである V8 のコードキャッシュが再利用される、という点です。これにより JavaScriptコンパイル処理がスキップされ、スクリプトの実行開始が早まります。

少し古い記事にはなりますが、2019年に V8 のブログに書かれた Code caching for JavaScript developers をベースに、HTTP 304 と 200 でどの程度パフォーマンスに差が出るのかを検証し、その差が生まれる仕組みを Chromiumソースコードを追いかけながら解説してみたいと思います。

なお本記事に記載の内容は、あくまでも Chromium についての言及であり、それ以外のブラウザやJavaScriptエンジンの実装では大きく異なる可能性があります。また Chromiumソースコードや実装についての完全な、あるいは最新の記述でない可能性がある点、あらかじめご了承ください。

コードキャッシュとは

V8 のコードキャッシュについて簡単に説明します。

ブラウザが JavaScript を実行する際、V8 エンジンはソースコードをパース(構文解析)し、バイトコードコンパイルします。この処理には一定の時間がかかりますが、同じスクリプトを繰り返し読み込む場合、毎回コンパイルし直すのは非効率です。そこで V8 は、コンパイル済みのバイトコードをキャッシュし、次回以降はそれを再利用する仕組みを持っています。これが V8 においてはコードキャッシュと呼ばれています(WebKit/JavaScriptCoreなど他ブラウザのJavaScriptエンジンにも類似のものがありますが、呼称や仕組みは異なります)。V8 のコード実行の仕組みについては brn さんの資料 Source to Binary - journey of V8 javascript engine が詳しいので、気になる方はこちらを参照してください。

V8 のコードキャッシュには2つのレイヤーがあります。1つ目は Isolate と呼ばれる V8 の実行環境におけるインメモリのキャッシュで、コンパイル済みのバイトコードソースコードをキーとしてハッシュテーブルに保存されます。2つ目はディスクキャッシュで、HTTP キャッシュに保存されたスクリプトファイルのメタデータとしてバイトコードが永続化されます。本記事で注目するのは後者のディスクキャッシュです。

ディスクベースのコードキャッシュは、スクリプトへのアクセスパターンに応じて3つのフェーズで動作します。

  1. Cold Run(初回): スクリプトに初めてアクセスした際、Chrome は JS ファイルを HTTP キャッシュに保存し、V8 はコンパイルを実行する。ディスクへのコードキャッシュはこの時点では生成されない。
  2. Warm Run(2回目): 72時間以内に再度アクセスがあると、V8 はスクリプトコンパイルし、そのバイトコードをディスクキャッシュとして HTTP キャッシュのメタデータに保存する。
  3. Hot Run(3回目以降): ディスク上にバイトコードキャッシュが存在する状態でアクセスすると、コンパイルはスキップされ、キャッシュからバイトコードを読み込んで実行する。

この仕組みにより、頻繁にアクセスされるサイトでは JavaScript の実行開始が高速化されます。ここで重要なのはキャッシュが無効化される条件です。本記事の冒頭でも紹介した Code caching for JavaScript developers によると、サーバーが 304 Not Modified を返すとコードキャッシュが維持される一方、200 OK を返すとキャッシュが無効化されると説明されています。

検証

実際に、HTTP 304 と 200 での挙動の差や、パフォーマンスにどの程度の差が出るかを検証してみたいと思います。一般的なCDNやキャッシュサーバーの挙動を模して次の2つのモードを用意し、ブラウザでページを何度かリロードした際の挙動の変化を追っていきます。

  • 200モード: 常に 200 OK でレスポンスボディを返す
  • 304モード: If-None-Match ヘッダがあれば 304 を返す(ETag ベースのキャッシュ検証を行う)

V8 では1KB 以上のスクリプトのみがコードキャッシュの対象となるため、ある程度のサイズがあるライブラリ(今回はThree.js)を同梱した JavaScript ファイル(bundle.js)を用意しキャッシュの動作を観察してみます。

サーバーの擬似コード

import http from 'http';
import fs from 'fs';
import crypto from 'crypto';

const FORCE_200 = process.argv.includes('--force-200');

function generateETag(content) {
  return `"${crypto.createHash('md5').update(content).digest('hex')}"`;
}

const server = http.createServer((req, res) => {
  const content = fs.readFileSync(req.url);
  const etag = generateETag(content);
  
  const clientETag = req.headers['if-none-match'];
  
  if (clientETag === etag && !FORCE_200) {
    res.writeHead(304, { 'ETag': etag });
    res.end();
  } else {
    res.writeHead(200, {
      'Content-Type': 'application/javascript',
      'ETag': etag,
      'Cache-Control': 'no-cache',
    });
    res.end(content);
  }
});

server.listen(3000);

このサーバーから返されるHTML内で bundle.js を読み込み、 chrome://tracing から記録・確認が可能な Chrome の tracing 機能を使って、V8 がコードキャッシュを生成/利用したかを確認してみます。

<!DOCTYPE html>
<html>
<head>
  <title>V8 Code Cache Tester</title>
</head>
<body>
  <h1>V8 Code Cache Tester</h1>
  <script src="/bundle.js"></script>
</body>
</html>

結果

初回ロードなど、bundle.js が 200 となるケースでは v8.compile イベントがこのような中身で記録されます。コンパイルにかかった時間は12μsで、右のArgs部分にはキャッシュに関する情報がありません。

2回目のロードで bundle.js が 304 になると、 v8.compile に続けて v8.produceCache というイベントが記録されます。ここでは producedCacheSize というプロパティが出てきており、bundle.jsのコードキャッシュが生成されたことが分かります。

最後に、3回目のロードのタイミングでは、 v8.compile イベントには consumedCacheSize というプロパティが存在しており、さきほど生成されたコードキャッシュが利用されコンパイル処理がスキップされたことが見て取れます。あくまでも擬似的なコード/Webページで単発計測した結果のため、ベンチマークとしては全く持って不十分な内容ではありますが、タスクの実行時間も12μsから6μsへと半減しています。

反対に、何度リクエストしても bundle.js が 200 OK を返すケースでは、1度目の v8.compile と同じ内容が続けて記録されるだけで、コードキャッシュの生成や利用に関するイベントがありません。期待通り、 bundle.js のレスポンスが 200 を繰り返すか 304 となるかの違いによって、バイトコードキャッシュの扱いが変わることが検証できました。

解説

内容としては全く同じ JavaScript ファイルであっても、200 レスポンスを受け取るとコードキャッシュが無効化される仕組みをもう少し詳しく見てみたいと思います。 https://source.chromium.org/chromium から Chromiumソースコードを追ってみます。

コードキャッシュの検証ロジックは、Chromiumレンダリングエンジンである Blink の v8_code_cache.cc および generated_code_cache.cc に実装されていました。キャッシュエントリは以下のような構造で保存されているようです。

constexpr size_t kResponseTimeSizeInBytes = sizeof(int64_t);  // 8 bytes
constexpr size_t kDataSizeInBytes = sizeof(uint32_t);         // 4 bytes

void WriteCommonDataHeader(buffer, response_time, data_size) {
  int64_t serialized_time = 
    response_time.ToDeltaSinceWindowsEpoch().InMicroseconds();
  memcpy(buffer->data(), &serialized_time, kResponseTimeSizeInBytes);
  // ...
}

ポイントは、コードキャッシュのエントリに response_time(レスポンスを受け取った時刻) が含まれている点です。キャッシュを読み込む際、この response_time が現在の HTTP レスポンスの response_time と照合され、一致しなければキャッシュは無効とみなされます。たとえ JavaScript のコンテンツが1バイトも変わっていなくても、200 レスポンスを返すと response_time が更新されるため、コードキャッシュは使えなくなってしまいます。

2024年11月のバグ修正

興味深いことに、この調査の過程で2024年11月に行われたバグ修正を発見しました。

ref. https://chromiumdash.appspot.com/commit/f5a004e60f7f00dcb0274780d74770d360c0660b

[v8 code cache] Fix timestamp mismatch issue

When we get a HTTP 304 response, we update the response_time in the HTTP cache. But we don't update the code cache response time. Later on, we check that the code cache associated with a response has the same response time, but they won't match. This results in discarding valid code caches.

このコミットによると、304 レスポンス時に HTTP キャッシュの response_time が更新されてしまい、コードキャッシュがマッチせず使えない、というバグがあったようです。修正として original_response_time というフィールドが導入され、コードキャッシュとの照合には元の response_time が使われるようになりました。

このバグ修正からも、response_time のマッチングがコードキャッシュの有効性判定において重要な役割を果たしていることが分かります。

まとめ

本記事では、HTTP 304 レスポンスが単なる転送量の削減だけでなく、V8 コードキャッシュの再利用という観点からもパフォーマンス上のメリットがあることを解説しました。

  • V8 のコードキャッシュは、コンパイル済みバイトコードを保存し、再利用することで JavaScript の実行開始を高速化する
  • キャッシュの有効性は response_time のマッチングによって判定される
  • 200 レスポンスは response_time を更新するため、コードキャッシュが無効化される
  • 304 レスポンスおよび HTTP Cache ヒットは元の response_time を維持するため、コードキャッシュが再利用される

この記事では V8 の内部実装に踏み込んで解説しましたが、公式の V8 ブログ Code caching for JavaScript developers では「do nothing(何もしない)」というアプローチが推奨されています。これには2つの側面があります。1つ目は消極的な意味での「何もしない」で、V8 の内部的なキャッシュの仕組みは頻繁に変更されることもあり、それに過度に最適化しようとせず、より普遍的なパフォーマンス改善のベストプラクティスに則ったコードを書いたり構成を意識することの方が遥かに優先順位は上です。2つ目は積極的な意味での「何もしない」で、スクリプトファイルを不必要に更新しないことがコードキャッシュを維持する最も確実な方法であるという点です。V8 チームのメッセージは明確で、「クリーンで分かりやすいコードを書けば、V8 がうまいことキャッシュを最適化してくれる」ということです。

ブラウザの内部動作を理解していくと、一般的なプラクティスからより踏み込んだ効果的なパフォーマンス最適化が可能になります。パフォーマンス改善を考えるにあたって、必ずしも V8 の挙動に最適化させていくのが正解とは限らず、キャッシュ戦略1つをとってもそのシステムで実現したいことに合わせて多角的に検討する必要がありますが、1つの新しい観点として興味を持ってもらえると嬉しいです。

WE ARE HIRING!

Repro Booster ではこうした細部の仕組みにも注目しながら、「タグを入れるだけでサイトが速くなる」体験の実現に取り組んでいます。 Webパフォーマンスに関心がある方、あるいは「タグを入れる」だけでWebパフォーマンスを改善するRepro Boosterの技術に興味を持っていただけた方は、ぜひ気軽にご連絡ください。