もともとの話は、仕事でDartを使う案件があり1、そこでロックを実装したことが始まりだった。それまではsynchronizedパッケージを利用していたが、なぜそんなことをしたかと言えば、このくらいの処理であれば、誰にでも仕組みを解説できるように、よくも悪くも中身を把握しておきたかったからである。

そのあとで、もっと大勢の方にとって分かりやすく、試しやすいようにJavaScriptに移植することにした。DartはJavaScriptの置き換えを狙って作られた言語なので、少し癖はあるものの、同じように書けば同じように動作するので、この作業は簡単だった。つまり、私がDartで実装したロックと同じものをJavaScriptで実装したことになる。

本稿ではJavaScriptによるロックの実装を解説するが、独特ですこし理解しにくいところもあるので、なるべく具体的かつ難しくなりすぎないように解説し、その仕組みに対する理解を深めていく。

なお、ここではDartのコードは一切出てこない。日常的に書く人はあまりいないだろうし、書いたところでというところもあるので、意図的にそうしている。

ロックが必要なとき

いつどんなときでもロックが必要というわけではない。ロックを取って排他制御ができるのであれば安心だが、それなりにコストのかかる操作であるし、必要のないロックを取ることによって性能にも影響が出る。

では、ロックが必要なときとは、どういうときだろうか?ビジネスロジックではいろんな状況があるとは思うが、私の失敗経験からふたつほど紹介する2

処理の順番が重要なとき

JavaScriptはシングルスレッドなので、同じコードが一緒のタイミングで動作することはないが、プログラマが予期しないような動作は発生する。次のコードを見てみよう。

const statusHistory = []
element.addEventListener('click', async () => {
    const response = await fetch('https://example.com')
    statusHistory.push(response.statusCode)
})

このコードは例えばボタン等の特定のエレメントをクリックしたときに、https://example.com にHTTPリクエストを行い、そのステータスコードを変数に保存するものだ。ここで、ごく短い時間に複数のクリックイベントが発生したとすると、どうなるだろうか?3

  • statusHistoryはイベントが発生した順番でステータスコードが入る
  • statusHistoryはイベント内のawaitが終了した順番でステータスコードが入る

答えは後者になる。つまり、シングルスレッドで動作するということは実行順で処理されるということと同義ではなく、await による待機や then() によるコールバックを利用する際は、イベントの発生順とプログラマが思っている処理順が一致しないことがでてくる。

そうなると悩ましいことが起きる。例えば特定のリソースにアクセスする際に、セッション情報が必要であるとする。セッション情報はそれを管理しているリポジトリに保存されていて、リポジトリにセッション情報を問い合わせるとキャッシュされたセッション情報を返すようになっている。もしセッション情報が古くなっていれば、サーバーに問い合わせてセッションを更新して新しいものを返してくれるようになっている。

さて、このリポジトリに対してふたつの処理A, Bからアクセスがある場合に、A→Bの順番でアクセスしたらA→Bの順番で処理を実行してもらわないと困ったことになる。なぜなら、セッションが切れているときに、AとBが同時にアクセスすると、それぞれでセッションの更新が発生して、あとに実行されたほうだけが有効なセッションとなってしまうからだ。こういうときには排他ロックを使って、必ず順序を保証して不整合が発生しないようにしなければならない。

リポジトリってなに?

リポジトリ(Repository)という言葉に親しみがない場合には、データベースやストレージ、管理用ファイルなどと読み替えてもらって構いません。

多重でイベントが発火するとき

多重でイベントが発生する状況でもロックは有効だ。画面を切り替えて戻ってきたときに、アップデートの確認をして、アップデートが必要であればそれを促すためのダイアログを表示したいとする。このダイアログは閉じられるまで await で待機するようにしてある。

document.addEventListener('visibilitychange', async () => {
    if (document.visibilityState === 'visible') {
        const mustUpdate = await getUpdateInfo()
        if (mustUpdate) {
            // アップデートダイアログを表示する。閉じられるまでここで待機する
            await showDialog()
        }
    }
});

このコードの問題点は、画面を何回か連続で切り替えると、イベント自体は発生するので切り替えた回数だけ続けてダイアログが表示されるということだ。ユーザーインタフェースの観点からも、すでに表示されていれば表示しないという考慮を入れたい。

少し慣れた人であればこのコードをどう修正すれば一瞬でわかるはず。そう、フラグを用意してやればいい。こんな感じで…。

let blocking = false
 
async function f() {
    if (!blocking) {
        blocking = true
        // なにか処理をする
        blocking = false
    }
}

この方法はとにかく単純で、誰が見てもやりたいことがわかる。プロジェクトが十分に小さく、コードの把握が容易なときには有効な手段だ4。いっぽうで、フラグを正しく制御することが前提なので、ちょっと賢くて人を信じられない人であれば、フラグの戻し忘れを防ぐためにリソース解放の仕組みを導入したくなるはずだ。

通常の排他ロックでは、すでにロックを獲得している処理があれば、あとから来た処理は先に来た処理が終わるまで待たされる。ここではダイアログが表示されていれば表示しないようにしたい、ということなので、通常の排他ロックを使うだけではうまくいかない。

しかし、ノンブロッキングで排他ロックを獲得する、という考え方を導入すると、すでに実行中の処理があればロックが取れず失敗させられるし、なければ実行するようになるので、フラグによるダイアログの表示制御をしていたときと同じことが実現できる。さらに嬉しいことは、ロックすることだけを考えればいいので、フラグの制御漏れを考えなくてよくなることだ5

Promiseを理解する

本稿ではロックにPromiseを使うので、そのためにはまずPromiseの知識が必要になる。初見だとPromiseの動作はトリッキーに映るが、別記事のJavaScriptの非同期処理にPromiseの特徴をまとめたので、そちらを参照してほしい。ひとつずつ順を追って理解していくことで、ロック実装のために最低限必要な知識を得ることができる。

正直、私も最初はPromise(DartだとFuture)のことは曖昧に捉えていたが、使っていくうちに慣れて「そういうもの」だと認識できるようになったので、最初はよくわからなくても問題はない。おそれず手を出し続けていれば、そのうちわかるようになってくるはずだ。

ロックを実装する

ここまででPromiseの基本的な挙動は理解できたと思うので、ロックの実装に移っていく。Promiseについてあれやこれの説明がないぞ、という人がいるかもしれないが、そういったところについてはほかの文献にお任せしたい。

基本的なロックの実装は、Danny Kim氏のSimple TypeScript Mutex Implementationがわかりやすいので、これを参考にする。このコードはTypeScriptのものなので、JavaScriptに直した上で、利便性や理解を助けるために一部変更を加えている。

要件を整理する

ロックとはなにか?を今一度整理してみよう。おおよそ次の3つが満たされていればそれは排他ロックと言えそうだ。

  1. ロック範囲において、あるひとつの処理だけが同時に実行できる
  2. ロックを獲得できるのは早い者勝ちで、あとから来たものは待たされる(ブロッキングという)
  3. ロックが解放されると次に待っている処理が実行される

1.と3.についてはキュー(Queue)を使うことで、2.のブロッキングについてはPromiseが持つ「resolve() または reject() されるまで待たされる」という特性を応用すれば実現できそうだ。それではさっそく実装していこう。

1. 管理用プロパティを定義する

ロックの実現にはプロパティとしてキューがあれば十分だ。上の参考文献ではもうひとつのプロパティとしてロックフラグを用意しているものの、ロック要求があった時点でロックがあることと同義なので、実際には不要だ。なのでこれは算出プロパティで代用している。

class Lock {
    #queue
 
    constructor() {
        // ロックを管理しておくためのキューで、ひとつずつ順番に取り出されて処理される
        // 処理待ちのものはawaitによって待たされる(ブロックされる)
        this.#queue = []
    }
 
    /*
     * @returns {boolean}
     */
    get locked() {
        // ロックが獲得されているか確認する
        return this.#queue.length > 0
    }
}

2. ロックを獲得するまで待たせる: acquire()

次にロックを獲得するメソッド acquire() を定義する。このメソッドは実行するとPromiseを返すが、resolve は実行されないので、現時点で await acquire() は無限に待つコードになっている。

/**
 * @param {Object} options
 * @param {boolean} options.blocking
 * @param {number?} options.timeout
 * @returns {Promise<function(): any>}
 */
acquire({ blocking = true, timeout } = {}) {
    return new Promise((resolve, reject) => {
        // ノンブロッキングでロックを獲得した際にすでにロックがあれば失敗する
        if (!blocking && this.locked) {
            throw new LockError()
        }
        // 新しいロック管理用オブジェクトを作成する
        const lock = { resolve, timer: null }
        // ブロッキングでタイムアウト付きのロックを獲得しようとした場合
        if (blocking && Number.isSafeInteger(timeout)) {
            lock.timer = setTimeout(() => {
                // タイムアウトしたらキューから取り除いてreject()する
                // つまり、acquire()は失敗する
                this.#queue = this.#queue.filter(x != lock)
                reject(new LockTimedoutError())
            }, timeout)
        }
        // ロックオブジェクトを登録してロックを解放をするための処理を実行する
        this.#queue.push(lock)
        this.release()
    })
}

コードの最後で this.release() を実行しているが、これがロックを解放するための鍵となっている。それでは次に進もう。

オブジェクトの略記

{ resolve }{ resolve: resolve } つまり { 'resolve': resolve } の略記です。オブジェクトの要素に変数名しか書かないと、変数名がオブジェクトのキーとなります。オブジェクト初期化子も確認してみてください。

3. ロックを解放する: release()

release() はキューの先頭の要素を取得してロックを解放する。これはロックをした順番でロックオブジェクトを取り出して解放することに等しい。

release() {
    // 範囲外アクセスはundefinedになるのでこのコードは問題ない
    const first = this.#queue[0]
    // ロックオブジェクトのresolve()を呼び出して
    // await acquire()の結果として無名関数を返している
    first?.resolve(() => {
        // 無効なsetTimeout()の返値は無視されるのでこのコードも問題ない
        clearTimeout(first.timer)
        // 先頭のロックオブジェクトを削除する
        this.#queue.shift()
        // 同じメソッドを呼び出して次のロックを解放しようとする
        this.release()
    })
}

ここの肝は first?.resolve() に与えられた無名関数で、それは await acquire() の結果でもある。そしてこの関数を評価すると次のことが起きる。

  1. タイムアウトが設定されていることがあるので、解除してタイムアウトエラーを防ぐ
  2. キューの先頭要素を取り除くことによってロックを解除する
  3. 再帰的にロックを解除しようとする (別の await acquire() の待機を解除する)

つまり、await acquire() によってロックを獲得すると、ロック解放用の関数を返すので、ロック中にやりたいことを実行して、ロック解放用の関数を実行することで、

  1. ロックを獲得する
    • すでにロックがありブロッキングならロックが獲得できるまで待機する
    • すでにロックがありノンブロッキングならロックの獲得に失敗する
  2. 任意の処理をする
  3. ロックを解放する

といった排他処理を実現できるようになる。

4. 組み合わせる: runExclusive()

最初のうちはこの完成形を見ないと、acquire()release() だけ見せられたとしてもピンとこないかもしれない。仕上げとしてふたつのメソッドを組み合わせて、ロックしたい処理を引数に取って実行する runExclusive() を定義する。

/**
 * @param {function(): (any | Promise<any>)} callback
 * @param {Object} options
 * @param {boolean} options.blocking
 * @param {number?} options.timeout
 * @returns {any}
 */
async runExclusive(callback, { blocking = true, timeout } = {}) {
    // ロックを獲得する。返却されたrelease()を実行するとロックを解放できる
    const release = await this.acquire({ blocking, timeout })
    try {
        // ロックが獲得できたら、与えられたコールバックを実行する
        // isFunction()はcallbackが関数かどうか確認するヘルパーメソッド
        if (isFunction(callback)) {
            // callbackが値Tを返すのかPromise<T>を返すのかわからないので、
            // TであってもPromise<T>であってもTに変換するためのヘルパーメソッドを実行
            return await awaitOr(callback())
        } else {
            throw new Error('callback is not callable object')
        }
    } finally {
        // デッドロックにならないように必ずfinallyでロックを解除する
        release()
    }
}

try-finally テクニック

try-finally はロック解除の文脈でよくでてくる便利な書き方です。catchがないので例外は捕捉しませんが、finallyによって必ず終了処理ができます。JavaScriptだけでなく、ほかの言語でも有効なテクニックです。

基本的な使いかたは次のような形になる。ロックオブジェクトを作成して、runExclusive() を呼び出すだけだ。ロックオブジェクトのスコープに注意しないとロックの意味がないことがあるので、適切なライフタイムを持つスコープに配置することが求められる。

window.addEventListener('DOMContentLoaded', async () => {
    // 基本的な使い方
    const lock = new Lock()
    await lock.runExclusive(async() => {
        // ロックしたい処理
    })
})

5. 試してみる

さて、ここまででロックが実装できたので動作を試してみる。例えば [1, 2, 3, 4, 5] という配列があったときに、それぞれを処理するためにランダムな時間を待たされたとしても、ロックを取った順で実行されるので、順番は保たれる。

window.addEventListener('DOMContentLoaded', async () => {
    const lock = new Lock()
    const values = [1, 2, 3, 4, 5]
    const result = []
    const promises = []
    for (const value of values) {
        // 非同期処理を強調するためにawaitで待機しない
        const promise = lock.runExclusive(async () => {
            // ランダムな時間待機したあとにresultにvalueを追加する
            await new Promise(resolve => setTimeout(resolve, Math.random() * 10))
            result.push(value)
        })
        // Promiseを保存する
        promises.push(promise)
    }
    // ここですべてのPromiseを待機する
    await Promise.allSettled(promises)
 
    // ランダムな時間の待機によって通常はresultの値の順番はvaluesと一致しないはずだが
    // ロックによって実行順で処理されるので result = [1, 2, 3, 4, 5] となる
    console.log(`${result.join(', ')}`)  // 1, 2, 3, 4, 5
})

まとめ

今回はPromiseとPromiseを使った排他ロックの実装を説明してきた。ロックは便利ではあるものの、パフォーマンスの低下を起こすという副作用もあるので、用法容量を守って正しく排他制御をしていきたい。

私も含めてほとんどの人はこういう部分に関しては責任を負いたくないので、実際にはライブラリに頼ることが多いとは考えるが、基本的な動作の仕組みを知っておくに越したことはないし、この生成AI時代においては説明可能であることが大事なことも多いので、そういうところでこうした記事がお役に立てたら幸いである。

おまけ:ヘルパーメソッドの定義

JavaScriptでは自由なデータを指定することができるし、関数はあらゆるデータを返却できるので、こうしたヘルパーメソッドを用意することで、ある程度制約を設けることができる。ここのヘルパーメソッドはそれぞれ大したことはしないが、JavaScriptによる非同期処理をする上で重要なエッセンスが詰まっているとも言える。

/**
 * @param {any} value
 * @returns {boolean}
 */
function isAwaitable(value) {
    return typeof value?.then == 'function'
}
 
/**
 * @param {any} value
 * @returns {boolean}
 */
function isFunction(value) {
    return typeof value == 'function'
}
 
/**
 * @type T
 * @param {T | Promise<T>} value
 * @returns {Promise<T>}
 */
async function awaitOr(value) {
    if (isAwaitable(value)) {
        return await value
    }
    return value
}

Footnotes

  1. そんな案件はほぼ決まっているのだが。

  2. つまりやらかしたのである。

  3. この件に限らず、ボタンを押したときに一時的に無効化しないと問題が起きやすい。

  4. こうしたフラグ管理方式は大抵破滅を迎える。

  5. そもそも制御のための「状態」が外にあること自体がおかしいとも言える。