← 記事一覧へ

複数タブ間で chrome.storage.local を
leased channel として使う prewarm coordinator

複数タブが同時に大規模な事前ロード処理(prewarm)を走らせて CPU を取り合う問題を、chrome.storage.local を leased channel として使う first-write-wins 方式と TTL fallback で解決する純粋関数設計。

公開日: 2026-05-01 著者: 君斗りんく / Kimito-Link Project ライセンス: MIT 実装: GitHub

1.解決したい課題

Chrome 拡張機能では、ユーザー操作前に重い初期化(HTML テンプレートのロード、画像のキャッシュ、iframe の先行レンダリング等)を済ませておく prewarm パターンがしばしば必要になる。これにより、ボタンが押された瞬間に UI が即座に開く UX が実現される。

しかし、ユーザーが同時に複数タブを開いている場合、各タブの content script が独立に prewarm を実行すると、以下の問題が発生する:

  • CPU 取り合い: 5 タブが同時に同じ重い処理を始めると、各タブの prewarm が遅延して、結局 1 つも prewarm が完了しない。
  • メモリ重複: 同じリソースを各タブが個別にキャッシュすると、メモリが N 倍消費される。
  • I/O 競合: chrome.storage.local や IndexedDB に同じキーを並行書き込みすると race condition の温床になる。

必要なのは、「複数タブのうち 1 つだけが prewarm を実行し、他は待機する」を保証する軽量な調停機構である。タブはサーバープロセスではなく、いつでも閉じられる可能性があるため、停止したタブの権利を他タブが安全に引き継ぐ仕組みも必要になる。

2.手法の概要

本手法では、Chrome 拡張機能が標準で提供する chrome.storage.localleased channel(リース付き共有変数)として使う。各タブは prewarm を開始する直前に、storage 上の lease 状態を読み、純粋関数 decidePrewarmLeaseAction を呼ぶことで次のいずれかのアクションを得る:

  • 'claim' ― lease を自分の名前で書き込んで prewarm を実行
  • 'proceed' ― 既に自分が lease を保持している、そのまま prewarm 続行
  • 'defer' ― 他タブが prewarm 中、一定時間後に再試行

各タブには起動時に乱数で生成した instanceId(UUID 等)を持たせる。lease の保持者比較はこの ID で行う。

タブが prewarm 完了 / エラー時に明示的に lease を解放(空文字に書き戻し)する。タブが落ちて release されないケースに備え、lease には書き込み時刻も記録し、TTL(既定 10 秒)を経過した古い lease は他タブが claim で横取りできる設計にしている。

3.3 つのアクションと判定ロジック

純粋関数 decidePrewarmLeaseAction は以下の入力を取る:

引数意味
currentLeaseHolderunknownstorage から読んだ現在の lease 保持者 ID(空文字 / undefined / null も許容)
currentLeaseAtunknownstorage から読んだ lease 書き込み時刻(ms。壊れた値も許容)
selfIdstring呼び出し元タブの instanceId
nownumber現在時刻(ms。テスト時に固定値を渡せる設計)
leaseTimeoutMsnumber?TTL(省略時 10000)

判定ロジックは以下の優先順位で行われる:

  1. selfId が空 → 'defer'(呼び出し元異常)
  2. lease が空(誰も保持していない)→ 'claim'
  3. lease が自分のもの → 'proceed'
  4. lease の時刻が壊れている → 'defer'(保守的)
  5. lease の時刻が未来(時計ズレ)→ 'defer'
  6. lease の経過時間が TTL を超える → 'claim'(横取り)
  7. それ以外(他者が正常保持中)→ 'defer'

4.参考実装(JavaScript / 純粋関数)

以下は本拡張機能で実際に使用している実装の抜粋である(src/lib/prewarmCoordinator.js)。

/**
 * @param {{
 *   currentLeaseHolder: unknown,
 *   currentLeaseAt: unknown,
 *   selfId: string,
 *   now: number,
 *   leaseTimeoutMs?: number
 * }} input
 * @returns {'claim' | 'proceed' | 'defer'}
 */
export function decidePrewarmLeaseAction(input) {
  const selfId = String(input.selfId || '').trim();
  if (!selfId) return 'defer';

  const now = Number(input.now);
  const leaseTimeoutMs = Number(input.leaseTimeoutMs ?? 10_000);

  const holder = String(input.currentLeaseHolder ?? '').trim();
  const heldAt = Number(input.currentLeaseAt ?? 0);

  // lease が空 / 無効 → claim
  if (!holder) return 'claim';

  // 自分が保持中 → そのまま続行
  if (holder === selfId) return 'proceed';

  // 他者が保持中 — TTL 判定
  if (!Number.isFinite(heldAt) || heldAt <= 0) {
    // タイムスタンプが壊れている → 保守的に defer
    return 'defer';
  }

  // 未来の timestamp(時計ズレ・別 PC の clock skew)→ defer
  if (heldAt > now) return 'defer';

  const elapsed = now - heldAt;
  if (elapsed > leaseTimeoutMs) {
    // 古い lease → 横取り
    return 'claim';
  }

  return 'defer';
}

5.呼び出し側の使い方

呼び出し側(content script や popup)は次のパターンで使う:

// タブ起動時に instanceId を生成(タブの寿命中は固定)
const selfId = crypto.randomUUID();

async function tryPrewarm() {
  const { lease } = await chrome.storage.local.get('prewarm_lease');
  const action = decidePrewarmLeaseAction({
    currentLeaseHolder: lease?.holder,
    currentLeaseAt: lease?.at,
    selfId,
    now: Date.now()
  });

  if (action === 'claim') {
    await chrome.storage.local.set({
      prewarm_lease: { holder: selfId, at: Date.now() }
    });
    try {
      await runPrewarm();   // 重い処理
    } finally {
      // 完了 / エラーに関わらず lease を解放
      await chrome.storage.local.set({
        prewarm_lease: { holder: '', at: 0 }
      });
    }
  } else if (action === 'proceed') {
    // 自分が既に持っている。続行 or 結果を待つ
    await runPrewarm();
  } else {
    // defer: 一定時間後に再試行
    setTimeout(tryPrewarm, 2_000);
  }
}

注意点として、read → check → write の間にレースが起こり得る(複数タブが同時に空 lease を観測して同時に claim する可能性)。これは典型的な "TOCTOU"(time-of-check-to-time-of-use)問題で、本実装では chrome.storage.local.set の last-write-wins 性質に依存している。1 タブだけが最終的な lease 保持者となり、他タブは次回の tryPrewarm サイクルで 'proceed' または 'defer' に正しく転落する。

6.なぜこの設計なのか

6-1. なぜ chrome.storage.local なのか

Chrome 拡張機能で複数タブ間の通信手段は chrome.runtime.sendMessage / chrome.storage / SharedWorker 等がある。chrome.storage.local を選ぶ理由:

  • 同期しないので外部に漏れないchrome.storage.sync と違い、複数 PC 間に同期されない)
  • 簡潔な API でアトミックな読み書きが可能
  • background service worker が一時停止していても各タブから直接アクセスできる(runtime メッセージは worker 経由が必要で stop 状態だと届かない)

6-2. なぜ純粋関数なのか

調停ロジックを純粋関数 decidePrewarmLeaseAction として切り出すことで、以下が達成できる:

  • 全エッジケース(空 lease、自分保持、他者保持、TTL 切れ、時計ズレ、壊れたタイムスタンプ)の単体テストが書ける
  • 調停ロジックを変更しても呼び出し側は影響を受けない
  • 本実装には 9 ケースの単体テストがあり、全て純粋関数として実行可能

6-3. なぜ TTL fallback が必要か

タブはユーザーが任意のタイミングで閉じる可能性がある。lease を持ったまま閉じられた場合、release が走らないので lease が永久に残る。これを防ぐため、書き込み時刻を記録し、TTL 経過後に他タブが claim で横取りできる仕組みを入れている。

TTL は「prewarm の最大想定実行時間」よりわずかに長く設定する。短すぎると正常タブの lease を誤って横取りしてしまい、長すぎると停止タブの lease 解放が遅れる。本実装では 10 秒を既定値としている。

6-4. なぜ未来時刻を defer 扱いするのか

マルチユーザー Chrome やサイトをまたいだ Chrome プロファイル間で Date.now() がズレる可能性がある(普通は無視できるが、保守的設計)。lease の atnow より未来になっているケースは異常状態として、慎重に 'defer' を返す。

7.既知の限界と拡張可能性

7-1. 限界

  • 厳密な相互排他は保証されない: TOCTOU レースで瞬間的に複数タブが claim する可能性がある。本用途(prewarm)では「2 タブ同時実行」が許容範囲内で済む。クリティカル相互排他が必要なら別の機構(chrome.locks 等)を併用すべき。
  • TTL の調整が必要: 環境によって prewarm 時間が大きく変わる場合、TTL を動的調整する必要がある。
  • storage API のレイテンシ: chrome.storage.local.get/set は数ミリ秒のレイテンシがあるため、超高頻度の呼び出しには向かない。

7-2. 拡張可能性

  • multi-resource lease: 1 つの lease ではなく複数 resource ごとに lease を分け、並列実行できる resource は並列に動かす。
  • priority-based claim: タブの可視性(visible / hidden)や残バッテリー量で claim 優先度を変える。
  • storage onChanged 連動: lease 変化を polling せず chrome.storage.onChanged で受け取り、defer 中タブが即座に reactive に動く。

8.関連する既知技術(先行技術)

8-1. Distributed Lease (Gray, 1989)

Cary Gray と David Cheriton の "Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency" (SOSP 1989) で提唱された分散 lease 概念は、本手法の理論的基盤。本手法はそれを Chrome 拡張機能のタブ間という超軽量な分散環境に適用した特殊形である。

8-2. Leader Election Algorithms

Bully Algorithm / Ring Algorithm 等の古典的な分散リーダー選出アルゴリズム。本手法は厳密なリーダー選出ではなく「first-write-wins + TTL」という単純化した変種である。タブの寿命が短く、リーダーが頻繁に変わってよい用途に最適化されている。

8-3. Web Locks API

ブラウザ標準の navigator.locks API は、本手法より厳密な相互排他を提供する。ただし Chrome 拡張機能の content script では使えない場合がある(context により)、また API がやや重いため、軽量な用途では本手法のような simple lease の方が扱いやすい。

8-4. SharedWorker による調停

同一 origin の複数タブから 1 つの SharedWorker を共有して調停する手法。Chrome 拡張機能の content script の場合、ホストページの origin が違うと SharedWorker を共有できない問題がある。本手法はその制約を回避する。

9.本記事の位置づけ・ライセンス

本記事は、Chrome 拡張機能『君斗りんくの追憶のきらめき』内で 2026 年 4 月 30 日(バージョン 0.1.42)に投入された prewarmCoordinator.js モジュールの設計思想と実装を、2026 年 5 月 1 日付で公開するものである。

公開目的:

  • Chrome 拡張機能を開発する他の開発者が同じ手法を自由に応用できるようにする
  • 本手法に関する先行技術 (prior art) として、本記事の公開日時を公的記録に残す(防御的公開/defensive publication)

参考実装は MIT ライセンス、本記事文章は CC BY 4.0 のもとで自由に複製・引用可。

引用例: 君斗りんく「複数タブ間で chrome.storage.local を leased channel として使う prewarm coordinator」君斗りんくの追憶のきらめき、2026 年 5 月 1 日公開、https://tsuioku-no-kirameki.com/articles/prewarm-coordinator-lease.html