2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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 の使いまわし」「キューで順番待ちを再現」「非同期のロック解除タイミング」など、非常に学びが多いコードでした。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?