JavaScript で非同期処理を扱うとき、「並列で走ってはいけない処理」や「同時実行を制御したい」ケースがよくあります。
そういったときに有効なのが「ロック」の仕組み。
この記事では、supabase-js の内部実装から学べる async / Promise ベースのロック機構を紹介しつつ、
僕が実際に読んでいて つまずいたポイントも交えながら詳しく解説します。
対象コード:_acquireLock
今回ご紹介するコードはsupabase-jsのcreateClientで生成されるSupabaseClientの認証に関するメソッドを実装しているGoTrueClientから抜粋した private async _acquireLock(...) という関数を紹介します:
private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
this._debug('#_acquireLock', 'begin', acquireTimeout)
try {
if (this.lockAcquired) {
const last = this.pendingInLock.length
? this.pendingInLock[this.pendingInLock.length - 1]
: Promise.resolve()
const result = (async () => {
await last
return await fn()
})()
this.pendingInLock.push(
(async () => {
try {
await result
} catch (e: any) {}
})()
)
return result
}
return await this.lock(`lock:${this.storageKey}`, acquireTimeout, async () => {
this._debug('#_acquireLock', 'lock acquired for storage key', this.storageKey)
try {
this.lockAcquired = true
const result = fn()
this.pendingInLock.push(
(async () => {
try {
await result
} catch (e: any) {}
})()
)
await result
// keep draining the queue until there's nothing to wait on
while (this.pendingInLock.length) {
const waitOn = [...this.pendingInLock]
await Promise.all(waitOn)
this.pendingInLock.splice(0, waitOn.length)
}
return await result
} finally {
this._debug('#_acquireLock', 'lock released for storage key', this.storageKey)
this.lockAcquired = false
}
})
} finally {
this._debug('#_acquireLock', 'end')
}
}
全体像の理解
ざっくり要約すると…
- 同時に複数の処理を走らせたくないときの制御
- lockAcquired で「今ロック中かどうか」を判定
- ロックが取れていないなら、先に処理を実行しつつ、後続をキューで待たせる
- pendingInLock という Promise の配列を使って、順番待ちを実現している
最初の処理:ロック未取得の場合
まず、最初のリクエスト(ロックがまだ取られていない)では lockAcquired は false。
以下のブロックが実行されます:
return await this.lock(`lock:${this.storageKey}`, acquireTimeout, async () => {
this.lockAcquired = true
const result = fn()
this.pendingInLock.push((async () => { await result })())
await result
while (this.pendingInLock.length) {
const waitOn = [...this.pendingInLock]
await Promise.all(waitOn)
this.pendingInLock.splice(0, waitOn.length)
}
return await result
})
ここがポイント:
| 行 | 説明 |
|---|---|
this.lockAcquired = true |
ロックを取得済みにする |
const result = fn() |
最初の処理を実行(Promise) |
this.pendingInLock.push(...) |
自分自身をキューに登録 |
await result |
自分の処理が終わるのを待つ |
while (...) |
追加された処理(あとから来たリクエスト)を順番に全て終わらせるまで待機 |
僕がつまずいたポイント:「なんでwhileでキューに何か入ってるの?」
最初はこう思いました:
「ロックがまだ取得されてない=キューに何もないはずなのに、while(this.pendingInLock.length) が true になるのおかしくない?」
🔍 理由1:自分自身が push している
this.pendingInLock.push((async () => {
try {
await result
} catch {}
})())
→ 自分自身の処理 result を push しているので、キューは1件ある状態になる。
→ これは await result した直後にはすぐに resolve 済みなので、while ではすぐ抜ける。
🔍 理由2:後続が先に if (this.lockAcquired) に入って追加される可能性
lockAcquired が true になった時点で、次の呼び出しは if (this.lockAcquired) 側に入る。
const last = this.pendingInLock[this.pendingInLock.length - 1]
const result = (async () => {
await last
return await fn()
})()
→ このとき、last に今実行中の Promise が入っているので、後続は自然と順番待ちになる。
そして result が完成した後、this.pendingInLock.push(...) によりまたキューに追加される。
ちなみにこのawait last は pendingInLock の最後に追加された すでに実行済みの Promise を await している。ここで再度実行を始めているわけではないです。
イメージ図(順番)
// 初回
requestA:
- lockAcquired = false → ロック取得して fn 実行 → 自分をキューに入れる → while 実行
// 次の呼び出し(同時に発生)
requestB:
- lockAcquired = true → last = requestA.result → await last → fn 実行 → 自分をキューに
requestC:
- lockAcquired = true → last = requestB.result → await last → ...
→ 最初に来た requestA がすべての完了を見届ける。
→ requestB / C は順番に result を待ち、同様に push される。
まとめ
lockAcquired フラグと pendingInLock 配列を組み合わせて非同期のロック制御をしている。
最初にロックを取った処理が、すべての順番待ち処理の完了まで責任を持つ設計。
while (this.pendingInLock.length) が機能するのは、自分自身+後続がそこに push しているから。
最後に
このような非同期制御は、普通の JavaScript アプリでも DB アクセス制御やキャッシュ更新の時に活躍します。
Supabase の実装から学べるように、「Promise の使いまわし」「キューで順番待ちを再現」「非同期のロック解除タイミング」など、非常に学びが多いコードでした。