JavaScript | 非同期処理:実務での非同期制御 - 排他制御の考え方

JavaScript JavaScript
スポンサーリンク

排他制御を一言でいうと

排他制御(はいたせいぎょ)は、
「同時に走ると困る処理を、“必ず1つずつしか動かさないようにする考え方” です。

非同期処理が増えてくると、
「同じデータを同時に書き換える」「同じ処理が二重に走る」
といった問題が起きやすくなります。

排他制御は、
そういう“同時実行されると壊れる処理”に対して、
「順番待ちのルール」を決めることだと思ってください。


なぜ JavaScript でも排他制御が必要になるのか

「シングルスレッドだから安全」では全然ない

JavaScript はシングルスレッドなので、
「同時に2つの処理が走ることはないから安全」と思われがちです。

でも、非同期処理が絡むと話は変わります。

例えば、こういうコードを考えます。

let count = 0;

async function increment() {
  const current = count;
  await wait(100); // 何か非同期処理
  count = current + 1;
}

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
JavaScript

これをほぼ同時に 2 回呼びます。

increment();
increment();
JavaScript

直感的には「2 回インクリメントされて、count は 2 になる」と思いますよね。
でも実際には、こういう流れが起きます。

1回目の incrementcurrent = count を読む(0)
2回目の incrementcurrent = count を読む(まだ 0)
両方とも await wait(100) で一旦中断
100ms 後、1回目が count = current + 1(1)
そのあと 2回目も count = current + 1(また 1)

結果、count は 1 のままです。

「2 回増やしたのに 1 にしかなっていない」
これも立派な競合状態であり、
本来は排他制御が必要なパターンです。

ここが重要です。
JavaScript がシングルスレッドでも、
“非同期で一旦中断している間に、他の処理が同じ変数を触る”ことは普通に起きる。
だから排他制御の考え方は、フロントエンドでも無関係ではない。


排他制御の基本イメージ

「この処理は、同時に1つだけ」というルールを作る

排他制御のイメージを、日常に寄せてみます。

トイレが1つしかない家を想像してください。
同時に2人は入れません。

ドアに鍵が付いていて、
誰かが入っている間は鍵が閉まっている。
外から来た人は、鍵が開くまで待つ。

これが排他制御です。

プログラムの世界では、

「この関数は、同時に2回動いてはいけない」
「このデータは、同時に2つの処理から書き換えられてはいけない」

という場所に“鍵”をつけて、
「鍵を取れた人だけが処理を実行できる」ようにします。


一番シンプルな排他制御:フラグで「今実行中か」を持つ

「実行中なら新しい呼び出しを断る」

まずは、超シンプルなパターンから。

let isRunning = false;

async function doSomethingOnce() {
  if (isRunning) {
    console.log("すでに実行中なのでスキップ");
    return;
  }

  isRunning = true;

  try {
    console.log("処理開始");
    await wait(1000); // 何か重い非同期処理
    console.log("処理完了");
  } finally {
    isRunning = false;
  }
}

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
JavaScript

これを連続で呼んでみます。

doSomethingOnce();
doSomethingOnce();
doSomethingOnce();
JavaScript

1回目の呼び出しは isRunning が false なので実行されます。
2回目・3回目は、1回目がまだ終わっていないので isRunning が true。
そのため「すでに実行中なのでスキップ」となります。

ここでのポイントは 2つです。

1つ目は、
「実行前にフラグを見て、実行中なら断る」 こと。

2つ目は、
finally で必ず isRunning = false に戻す こと。
エラーが起きてもフラグが戻らないと、
二度と実行されなくなってしまいます。

これは「同時に1つだけ動けばいい処理」を守る、
一番簡単な排他制御です。


「スキップ」ではなく「順番待ち」にしたい場合

キュー(待ち行列)のイメージ

さっきの例は「実行中ならスキップ」でしたが、
「スキップせずに、順番に全部実行したい」こともあります。

例えば、
「保存ボタンを連打されたら、全部の保存を順番に処理したい」
というケースです。

このときは、
「今の処理が終わるまで待ってから次を始める」
という仕組みが必要になります。

簡易的な「1本だけのキュー」を作ってみましょう。

Promise を使った簡易キュー

let lastPromise = Promise.resolve();

function enqueue(task) {
  // task は async 関数(または Promise を返す関数)
  lastPromise = lastPromise.then(() => task()).catch((err) => {
    console.error("タスク中のエラー:", err);
  });
  return lastPromise;
}
JavaScript

使い方はこうです。

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function task(name, ms) {
  console.log(name, "開始");
  await wait(ms);
  console.log(name, "終了");
}

enqueue(() => task("A", 1000));
enqueue(() => task("B", 500));
enqueue(() => task("C", 300));
JavaScript

実行順はこうなります。

A 開始
A 終了(1秒後)
B 開始
B 終了(さらに0.5秒後)
C 開始
C 終了(さらに0.3秒後)

ポイントは、

lastPromise に「直前のタスクの Promise」を覚えておく
新しいタスクは、lastPromise.then(() => task()) として“前のタスクの後ろにぶら下げる”
これにより、タスクが必ず直列に実行される

というところです。

ここが重要です。
「同時に1つだけ」どころか、「必ず順番に全部実行したい」場合は、
“キュー(待ち行列)”という発想で排他制御を考える。


もう一歩踏み込んだ排他制御:簡易 Mutex(ミューテックス)

「鍵を取った人だけが通れる」仕組み

排他制御の代表的な概念に「Mutex(ミューテックス)」があります。
“Mutual Exclusion” の略で、まさに「排他」のことです。

JavaScript には標準で Mutex はありませんが、
Promise を使って簡易的なものを作れます。

class Mutex {
  constructor() {
    this._locked = false;
    this._waiting = [];
  }

  lock() {
    return new Promise((resolve) => {
      if (!this._locked) {
        this._locked = true;
        resolve(this._unlock.bind(this));
      } else {
        this._waiting.push(resolve);
      }
    });
  }

  _unlock() {
    if (this._waiting.length > 0) {
      const nextResolve = this._waiting.shift();
      nextResolve(this._unlock.bind(this));
    } else {
      this._locked = false;
    }
  }
}
JavaScript

使い方はこうです。

const mutex = new Mutex();

async function criticalSection(name) {
  const unlock = await mutex.lock(); // 鍵を取る(ここで待たされることがある)

  try {
    console.log(name, "がクリティカルセクションに入りました");
    await wait(1000);
    console.log(name, "がクリティカルセクションから出ます");
  } finally {
    unlock(); // 鍵を返す
  }
}

criticalSection("A");
criticalSection("B");
criticalSection("C");
JavaScript

実行結果はこうなります。

A がクリティカルセクションに入りました
A がクリティカルセクションから出ます
B がクリティカルセクションに入りました
B がクリティカルセクションから出ます
C がクリティカルセクションに入りました
C がクリティカルセクションから出ます

ポイントは、

lock() を呼ぶと、「鍵を取れるまで待つ Promise」が返ってくる
鍵を取れたら unlock 関数が渡される
処理が終わったら unlock() を呼んで鍵を返す
待っている人がいれば、そのうちの1人に鍵を渡す

という流れです。

ここが重要です。
Mutex は「この区間(クリティカルセクション)は、同時に1人しか入れない」というルールを、
コードで表現するための道具。
JavaScript でも、Promise を使えば同じ考え方を実現できる。


実務での排他制御の典型パターン

パターン1:同じ API を同時に叩かせない

例えば「在庫更新 API」を考えます。

同じ商品に対して、
同時に複数の更新リクエストを投げると、
在庫数が狂う可能性があります。

フロント側でできることとしては、

「同じ商品に対する更新処理は、1つずつ順番に送る」
「更新中はボタンを無効化する」

といった排他制御があります。

これは、
「商品ごとに Mutex を持つ」
という設計で書くこともできます。

パターン2:フォーム送信の多重送信防止

これはすでにやりましたが、
「送信中はボタンを無効化する」「isSubmitting フラグでガードする」
というのも立派な排他制御です。

「送信処理」というクリティカルセクションに、
同時に2回入らないようにしているわけです。

パターン3:キャッシュの更新

例えば、
「ユーザー情報をキャッシュしておき、必要なときだけ API を叩く」
というコードを考えます。

let userCache = null;

async function getUser() {
  if (userCache) {
    return userCache;
  }

  const res = await fetch("/api/user");
  const data = await res.json();
  userCache = data;
  return data;
}
JavaScript

ここで、
getUser() がほぼ同時に 3 回呼ばれたらどうなるか。

キャッシュがまだないので、
3 回とも API を叩いてしまいます。

これを防ぐために、
「取得中は同じ Promise を返す」という排他制御を入れます。

let userCache = null;
let userPromise = null;

async function getUser() {
  if (userCache) {
    return userCache;
  }

  if (userPromise) {
    return userPromise; // 進行中のリクエストを使い回す
  }

  userPromise = (async () => {
    const res = await fetch("/api/user");
    const data = await res.json();
    userCache = data;
    userPromise = null;
    return data;
  })();

  return userPromise;
}
JavaScript

これで、
同時に何回 getUser() が呼ばれても、
実際に API を叩くのは 1 回だけになります。

ここが重要です。
「同じことを何度もやらなくていいなら、“進行中の処理を共有する”という排他制御もある」
という感覚を持っておくと、実務でかなり役に立ちます。


初心者として「排他制御の考え方」で本当に押さえてほしいこと

排他制御は、
「同時に走ると壊れる処理に、“1つずつしか通さないルール”を与えること」

JavaScript はシングルスレッドでも、
非同期処理の途中で他の処理が同じ変数を触ることは普通に起きる。
だから「同時に触らせない」という発想が必要になる。

基本パターンは、

「今実行中か」をフラグで持ち、実行中なら新しい呼び出しを断る
全部実行したいなら、Promise をつないで“順番待ちのキュー”にする
もっと汎用的にやるなら、Mutex のような「鍵」を用意して、
鍵を取れた処理だけがクリティカルセクションに入れるようにする

そして何より、
非同期処理を書くときに、
「この処理、同時に2つ動いたら困らないか?」
「同じデータを同時に書き換えていないか?」

と一度自分に問いかけてみてください。

「困るな」と感じたら、そこが排他制御を設計するポイントです。
その瞬間から、あなたの非同期コードは「動けばいい」から
「壊れにくく、信頼できるもの」に一段レベルアップします。

タイトルとURLをコピーしました