🔄

フロントエンドで使える非同期処理の排他制御を使った小技集

2025/01/14に公開

こんにちは、RUN.EDGE株式会社の桑原です。

今回は、非同期処理の排他制御についての記事になります。

Webのフロントエンドで実行されるJavaScriptエンジンはその性質上、基本的にはシングルスレッドで実行される[1]ので、マルチスレッドでの実行に対応している他の言語/実行環境ほど排他制御を意識することはないかもしれませんが、Webフロントエンドであっても、動画やAPIの操作など複雑な非同期処理を伴う処理を扱う場合に、適切に排他制御を行って、バグなく動作するコードを書くことは非常に大切です。

今回は、実際にプロダクトを作っていく中で使うことが多かった排他制御を使った小技をいくつか紹介できればと思います。

async-lockの機能紹介

async-lockはJavaScriptで利用できる有名な排他制御ライブラリです。
https://www.npmjs.com/package/async-lock

async-lockにはいくつか機能がありますが、よく利用するのはacquire関数とisBusy関数です。

acquire関数でラップした関数は同時に実行できなくなり、複数の呼び出しが行われた場合はキューに積まれて順々に実行されるようになります。
また、isBusy関数は対象のキーの処理が現在実行中かどうかを判定してくれます。

const lock = new AsyncLock()

// acquire関数で既存の非同期処理をラップすることでその処理を排他制御することができる
const sampleFunction = () => lock.acquire('lockKey',async () => {
  // 排他制御したい非同期処理
})

// isBusy関数を利用することで排他制御されている関数が実行中かどうかを判定することができる
const isBusy = lock.isBusy('lockKey') // sampleFunctionが実行中の場合にtrueになる

他にもtimeoutの設定など、いくつかオプションがありますが、async-lockの機能については以下の記事が詳しかったので、興味がある方は覗いてみてください。
https://s1r-j.hatenablog.com/entry/2023/03/30/230953

ボタン連打を防ぐ

Webのフロントエンドで、フォームの投稿など、連打を防ぎたいボタンを配置することはよくあります。ボタンを押したらローディングを表示して〜などを行うことが多いですが、連打を防ぎたいだけであれば排他制御を用いても同じことができます。

const asyncLock = new AsyncLock()

const send = async () => {
  // 送信処理が実行されている間は、他の送信処理を実行せず、処理をスキップする
  if (asyncLock.isBusy('send')) {
    return
  }
  return asyncLock.acquire('send',async () => {
    // 送信処理
  })
}

asyncLock.acquireで囲われた部分は排他制御されているので、同時には実行されなくなります。また、isBusy関数でガード節を作っているので、送信処理実行中はそもそも追加の送信処理自体が行われなくなります。

実際のプロダクトでは、送信中に見た目を変えるなどする必要がある場合も多いですが、軽い送信処理で、送信中も見た目を変えなくて良い場合などは、async-lockを使うことで、新しくstateなどを追加しなくても簡単に連打防止を行うことができます。

もしもVueやReactでリアクティブなisBusyが欲しい場合、以下のようなカスタムフックを作ると便利です。(Vueのサンプルです)

useAsyncLock.ts
export default function useAsyncLock() {
  const asyncLock = new AsyncLock()

  const _isBusy = ref<Record<string, boolean>>({})

  const isBusy = computed(() => _isBusy.value)

  const acquire = async <T>(key: string, func: () => Promise<T>):Promise<T> => asyncLock.acquire<T>(key,
      async () => {
        try {
          _isBusy.value = { ..._isBusy.value, [key]: true }
          const result = await func()
          return result
        } finally {
          _isBusy.value = { ..._isBusy.value, [key]: false }
        }
      }
    )

  return {
    acquire,
    isBusy,
  }
}

このようなhookを作っておけば、以下のように簡単にisBusyをリアクティブな値として取得することができ、送信中にボタンをdisableにする処理なども簡単に書くことができます。

const { acquire, isBusy } = useAsyncLock()

/**
 * 送信中かどうか
 * computedなので、値が変化するとリアクティブにDomに反映される
 */
const isBusySend = computed(() => isBusy.value.send)

/**
 * 送信処理
 */
const send = async () => {
  // 送信中の場合は処理をスキップする
  if (isBusySend.value) {
    return
  }
  await acquire('send', async () => {
    // 送信処理
  })
}

処理を間引く

フロントエンドで動画を扱っていると、動画のシーク処理など、非同期処理を伴う処理を短時間に連続して実行する場合があります。
このような場合、端末の性能やネットワーク状況に合わせて適切に処理間引かないと動作が重くなってしまいます。

このように処理を間引く場合、よく使われるのはlodashなどのライブラリに実装されているthrottle関数です。

const throttledSeek = _.throttle(async () => {
  await seek() // 非同期処理を伴うシーク処理。動画の再生位置がシーク位置に移動したあとにPromiseが解決されるような関数を想定
}, 100); // 100ミリ秒間隔でseek処理を実行する

しかし、throttle関数は固定値でしか間引く間隔を指定できないため、

  • 性能の悪い端末でも問題なく動作させるために大きな値を設定すると、性能の良い端末の場合に滑らかなシーク処理ができなくなる
  • かといって数字を小さくしすぎると、性能の悪い端末の場合に重くなりすぎる

というような問題が出てきます。

そもそも動作が重くなってしまうのは、シーク処理が不必要に並列に実行されてしまうことだということを考えると、シーク処理部分に排他制御を導入することでより賢く問題を解決することができるはずです。

const asyncLock = new AsyncLock()

const _seek = async () => {
  // シーク処理が実行されている間は、処理をスキップする
  if (asyncLock.isBusy('seek')) {
    return
  }
  return asyncLock.acquire('seek',async () => {
    // シーク処理
    await seek()
  })
}

「ボタン連打を防ぐ」の項で紹介した内容と全く同じですが、このような処理にしておけば、どんなに高頻度で_seek関数が実行されたとしても、seek処理が終わるまで次のseek処理の実行はスキップされるようになり、実質的にthrottleを使ったときと同じような効果を得ることができます。
また、固定値で間引いていないので、ネットワーク状況が悪くなって、一時的にシーク処理の実行時間が伸びるような場合にも適切に対応できます。

もしもシーク処理の合間に多少のインターバルを設けたいのであれば、以下のように書くこともできます。

const asyncLock = new AsyncLock()

const _seek = async () => {
  // シーク処理が実行されている間は、処理をスキップする
  if (asyncLock.isBusy('seek')) {
    return
  }
  return asyncLock.acquire('seek',async () => {
    await seek()
    // 実行後に50ミリ秒だけインターバルを設ける
    await new Promise(resolve => setTimeout(resolve, 50))
  })
}

関数実行時に初期化処理を待機する

利用するのに初期化が必要なクラスを扱うことはしばしばあります。
この初期化処理が同期処理のみで書かれている場合は良いのですが、非同期処理の場合、クラスのコンストラクタでは、async関数を利用することができないため、非同期処理をうまくハンドリングすることができません。

class VideoController{
  constractor(url:string){
    this.initialize(url)
  }

  video: Video | null = null

  async initialize(url:string){
    this.video = await videoLoad(url)
  }

  async seek(sec:number){
    // シーク処理、initializeが完了していないとうまく実行できない
  }
}

このようなクラスの場合、例えば以下のようなコードを書くとうまく動きません。

const videoController = new VideoController(url)
videoController.seek(10) // このタイミングではまだ初期化が完了していないのでseek関数は動かない

これを防ぐために、コンストラクタの外で初期化処理を行うという方法があります。

const videoController = new VideoController(url)
await videoController.initialize(url)
await videoController.seek(10) // 初期化が完了しているので動く

しかしこのやり方は、seek関数とinitialize関数が別のコンテキストで実行されているときには使えません。

例えば、initialize関数がコンポーネントAで実行され、seek関数がコンポーネントBの中で実行されているような場合です。

このような場合でも排他制御をうまく使うことでinitialize関数の実行完了後にseek関数を実行させることができます。

class VideoController{
  constractor(url:string){
    this.asyncLock = new AsyncLock()
    this.initialize(url)
  }

  asyncLock: AsyncLock

  video: Video | null = null

  private async initialize(url:string){
    // initialize処理を排他制御しておく
    await this.asyncLock.acquire('initialize',async () => {
      this.video = await videoLoad(url)
    })
  }

  async waitInit(){
    // initialize関数と同じキーの空関数をAsyncLock.acquireでラップして実行することで、
    // initialize処理実行中の場合は、キューに積まれているinitialize関数の実行がすべて終わるまで待機することができる
    await this.asyncLock.acquire('initialize',async () => {})
  }

  async seek(sec:number){
    // コンストラクタで実行されたinitialize処理を待機する
    await this.waitInit()
    // シーク処理、initializeが完了していないとうまく実行できないが、
    // waitInit関数を事前にawaitしておくことでinitialize処理が完了したあと実行できる
    doSeeking()...
  }
}

このような実装を行うことで、seek関数がどのようなタイミングで実行されても問題なく動作するようになります。

const videoController = new VideoController(url)
videoController.seek(10) // コンストラクタの実行直後にseek関数を実行しても、問題なく動作する

async-lockでは、複数の関数に同じキーを設定することで、同じキーを設定した関数同士も排他制御することができます。これを利用して、実行完了を待機したい関数に設定されたキーと同じキーを設定した空関数を事前に実行して awaitしておくことで、その関数が実行中の場合はすべてのキューがなくなるまで待機することができます。なお、そもそもseek関数の実行前にinitialize関数が実行されていなかったり、この空関数を実行したあとに新たに積まれたキューの実行は待ってくれないので、その点は注意してください。
今回のように初期化処理のような、1度しか実行されない事前処理を待つ場合には便利に使えると思います。

まとめ

今回は非同期処理の排他制御に焦点を当てて、いくつか小技を紹介しました。普通のWebページを作成する場合にはあまり意識して使うことはありませんが、複雑な非同期処理が絡むようなクライアントアプリを作成する場合はお世話になることも多いので、参考になると幸いです。

脚注
  1. 一応ブラウザでもWeb Workerを使うことで、マルチスレッドでコードを実行することはできますが、色々制約がある上にメモリ空間の共有ができないので、何も考えずデータ転送すると大きなオーバーヘッドが発生したり、巨大なファイルは移譲の仕組みを使って共有する必要があるなど扱いづらく、一般的なフロントエンドの用途ではあまり普及していません。...と思っていたのですが、最近ではWebAssemblyとともに、フロントエンドでAIモデルを動かしたりする場合に利用されることもあるみたいです。クライアントサイドのリソースがリッチになっていくに従ってこういう技術もどんどん一般的になっていくのかもしれません。 ↩︎

RUN.EDGE株式会社

Discussion