Programming Field

Web Storageと並列処理

Web Storage (※ W3Cのページへリンクします)localStoragesessionStorage に代表される、Webページが用途に応じて利用できる保存領域です。sessionStorageは原則としてタブやウィンドウを閉じるまで保持される領域、localStorageは閉じても残り続ける領域であり、前者は一時的な保存場所、後者はページを再び開いたときにデータを復元するなどの目的で利用できます。また、これらは複数のタブやウィンドウで共通であり、タブ間(ウィンドウ間)で同じデータを利用することができます。

タブ間での排他制御の必要性

スクリプトを使ったWebページを構築している方であれば、スクリプトはページ内でシングルスレッドであるかのように動作するため、ページ内で排他制御を行う必要が無いということをご存知かもしれません。しかし、現在のほぼすべてのWebブラウザーはマルチタブ・マルチウィンドウに対応しており、それぞれのタブ(ウィンドウ)で実行されるスクリプトが単一スレッド上で動作するとは限りません(そのような制約はありません)。Web Storageの仕様には、複数のスレッドから同時にStorageにアクセスしてデータが異常になることを防ぐために「storage mutex」という概念を置いており、Storageの利用を開始したらこのミューテックス(ブラウザー/User agent単位で1つです)をロック、一連のスクリプト処理が完了する(イベントハンドラーを抜ける、など)までロックの解除を行わない、という形で排他制御を行っています。

※ 「storage mutex」という概念は最新の仕様(HTML5.2)からは外れている模様です。

ただ、もしstorage mutexという概念が有効であったとしても、特に非同期処理の起こる箇所でWeb Storageを用いていると、予期しないデータの上書き等が発生する可能性があるため注意が必要です。例として以下のコードを挙げます。

function retrieveData(callback) {
    var data = localStorage.getItem('cachedData');
    // 既にデータがキャッシュされていたらそのデータを、
    // データが無かった場合は非同期処理によりデータを取得する
    if (!data) {
        callback(data);
    } else {
        asyncGet('/resource', function (d) {
            localStorage.setItem('cachedData', d);
            callback(d);
        });
    }
}

このコードでは localStorage を利用し、「cachedData にデータがあればそのデータを、無ければ非同期処理を呼び出してその結果を(localStorage にキャッシュしつつ)利用する」という処理になっています。もしこの処理が常に一つのタブでのみ実行されるという前提があるのであれば全く問題ありませんが、例えば非同期処理(このコードにおける asyncGet 呼び出し)が行われている間に別のタブで上記の処理(retrieveData)が行われると、その時点では localStorage にデータが無いので非同期処理が実行されることになり、ある意味二重のリクエストが行われることになります。さらに、最初の非同期処理が完了すると localStorage にデータ書き込みを行いますが、もう一方の非同期処理が完了すると無条件で localStorage にデータを書き込むため、最初に書き込んだデータが失われることになります。

これが単に取得処理であれば問題ありませんが、ログインなどの(サーバー上の)セッション確立を行うような処理である場合、無駄なセッション確立が起こるなどサーバーに余計な負荷がかかる可能性が考えられます(特にECサイトなど金銭が絡むような処理である場合は要注意です)。これを避けるためには、タブを超えた排他制御を適切に行う必要が出てきます。

storage mutexが無い場合

もしstorage mutexが無い場合は、より単純に以下のような処理がNGとなる可能性があります。

function updateCount() {
    var count = localStorage.getItem('theCount');
    // 既に「theCount」があればその値を 1 増やし、なければ 1 とする
    count = (count !== null) ? (Number(count) + 1) : 1;
    localStorage.setItem('theCount', count.toString());
    return count;
}

この処理において、localStorage から getItem 経由で値を取得して setItem 経由で書き込むまでのわずかな間に、別のタブでやはり同様の処理が並列で実行され、3行目を完了した時点での「count」の値が双方のタブで全く同じ値になる可能性があり、結果意図しない状態になることが考えられます。やはりこの場合もタブを超えた排他制御を検討する必要が出てきます。

タブ間排他制御を実現するには

C/C++などの「ネイティブ」なアプリケーションを開発した経験をお持ちであれば、システム(ライブラリー)が提供するミューテックスなどの排他制御機構を用いることで、容易に排他制御を行うことが可能ですが、Web上のスクリプトにおいては、少なくとも2018年2月時点でそのようなものをブラウザーが提供しているといったことはありません。そこで排他制御を実現したいのであれば、これを自力で実装する必要があります。「排他制御」の概念については古くから検討されてきたものであるため、既存のミューテックスなどのアルゴリズムを参照してJavaScriptで実装することで排他制御に対応することが可能ですが、ここでポイントとなるのは「制御に用いるための共通変数をどこに置くか」という点になります。通常のスクリプト処理では変数はスクリプトごとに独立しているため、共通にアクセスできる場所をうまく用いる必要があります。

同一ドメインの場合

多くの場合に当てはまりますが、排他制御したい処理が同一ドメインに属する場合、処理が実行中であるかどうかの状態(共通変数)を localStorage に持たせることで、各種ミューテックスなどのアルゴリズムが適用できます。例として以下のような実装があります。

  • IWC ライブラリの SJ.iwc.Lock.interlockedCall 関数
  • tabex ライブラリの client.lock メソッド
  • LockableStorage ライブラリの LockableStorage.lock 関数
  • TabUtils ライブラリの TabUtils.CallOnce 関数

※ 上記は次のページにおける回答を元に挙げています: 「javascript - Mutex Lock (JS) Shared between multiple tabs of a browser? - Stack Overflow」

これらの実装では単にアルゴリズムを用いて排他制御を実装するだけでなく、「ロックを握った状態での処理中にタブが閉じられた」といったケースを考慮し、ロックにタイムアウトを設けてロックが解放されなくなることを防ぐといった工夫がなされています。

補足: 別ドメインの場合

排他制御したい処理がドメインを跨ぐ場合、あるドメイン下のスクリプトで localStorage にロック状態を表すデータを置いても別ドメインでそのデータを(localStorage 経由で)取得することができません。そのため、排他制御を行うためにさらにもうひと工夫行う必要があります。

※ 「Web Storageに対する排他制御」という意味では、Web Storage自体がドメインを跨いで共有されるものではないため、基本的に考える必要がありません。そのため、ここで述べる内容はWeb Storage以外の機能に対してWeb上で排他制御を行いたい場合を想定しています。

上記で挙げたライブラリでは tabex で行われていますが、ドメインを跨ぐ場合には以下のような手順で排他制御を実現することが可能と考えられます。

  1. それぞれのドメインから共通に読み込むことができるドメイン上にスクリプト処理用のWebページ(A)を作成する
  2. 各ドメインのWebページ(B)において、前述のWebページ(A)を iframe で読み込む
  3. Webページ(A)において排他制御(ミューテックスなどのアルゴリズム)を実装する
  4. Webページ(B)とWebページ(A)を、postMessage/onmessage を介してロックのやり取りを行い、Webページ(B)の排他制御を行う

※ この手順においては、セキュリティの観点からWebページ(A)で domain のチェックを行うようにしてください。

すなわち、Window間メッセージ処理を用いてドメインの壁を超えることで、前述した「同一ドメインの場合」の排他制御を利用する、という流れです。異なるドメインで共通の(特定のサーバーに置かれた)スクリプトを読み込ませ、そのスクリプト内で排他制御を行いたい場合に取り得ることができる方法になります。

補足: SharedWorker

上記では排他制御を行うために localStorage を用いた内容を紹介していますが、ブラウザーで利用可能である場合は SharedWorker を用いても排他制御を実現することが可能であると考えられます。SharedWorker を用いる場合も、ロック状態を保持する場所が localStorage から「SharedWorker処理が行われるスクリプト上の変数」に移るだけであり、「ロック保持中にウィンドウが閉じられる」ことへの対策や「ドメインの壁を超える」ための対応は依然として必要になります。

※ SharedWorker は同一ドメインの制約を受けるため、ドメインの壁を超えるための iframe が必要になります。また、現時点で「SharedWorker に接続した元のウィンドウが閉じられた」ことを SharedWorker 内で検知できないので、ロックのタイムアウト処理などが必要になります。