同期的な処理をあえてasync関数でやろうとしたらつまづいた話

2 min読了の目安(約2500字TECH技術記事

自プロジェクトで以下のようなオブジェクトプーリングの仕組みを構築していたときの話です。
(簡易化してます)

const objectPool = {
  _pool: [],

  // parentプロパティがnullのオブジェクトを取得
  pick() {
    return this._pool.find(obj => obj.parent == null);
  },
  
  // pickを非同期的に処理する
  async pickAsync() {
    const obj = this.pick();
    if (obj) {
      return obj;
    } else {
      throw null;
    }
  },
  
  // プールにオブジェクトを追加
  add(obj) {
    this._pool.push(obj)
  }
};

注目してほしいのがpickAsyncメソッドです。この非同期処理についての勘違いによってつまずきました。技術的にはボタンの掛け違いレベルの内容ですが、気持ちの供養のため記事化します。

さてpickAsyncですが、async関数内ではreturnするとPromise.resolve相当、throwするとPromise.reject相当の処理が返ることを利用し、プールから適当なオブジェクトが見つかった場合と、見つからなかった場合の処理をスマートな感じに書けるようにしていました。

// 子オブジェクトクラス
class ChildObject {

  constructor() {
    this.parent = null;
  }

  setParent(parent) {
    this.parent = parent;
  }
}

// 仮の親オブジェクト
const dummyParent = {};

// プールに4コオブジェクトを補充
for (let i = 0; i < 4; i++) {
  objectPool.add(new ChildObject());
}

// オブジェクトをプールから拾う
objectPool.pickAsync()
  .then(obj => {
    // 適当なオブジェクトが見つかった場合の処理
    obj.setParent(dummyParent);
  })
  .catch(e => {
    // 見つからなかったときの処理
    console.warn("見つかりませんでした");
  });

これは単発の処理では期待通り動作しますが、ループに組み込んだ際に問題が起こりました。

for (let i = 0; i < 8; i++) {
  objectPool.pickAsync()
    .then(obj => {
      obj.setParent(dummyParent);
    })
    .catch(e => {
      console.warn("見つかりませんでした");
    })
}

プールされてるオブジェクトは4個だけなので、ループ前半4回はthen側の処理、後半4回はcatch側の処理が走ることを期待してますが、実際は全てthen側処理が走ってしまいます。しかも全ループ、同じオブジェクトが選ばれてしまいます。

理由は簡単で、非同期関数の処理はペンディング状態になるだけでループを中断せず、ループ後に時間差で実行されるためでした。
上記の例では同一オブジェクトのsetParentがループ後に8回実行される形になります。

これは気づいてみると いや、当たり前だろ...? というようなミスですが、利便のために本来同期的だった処理を非同期にしたせいでなかなかおかしさに気づけずにいました。
(サーバー通信など、いかにも非同期っぽい処理内容であればすぐに気付けたと思いますが...)

この件以外にも、Promise.allを使った処理で似たような轍を踏んだり、プールの挙動が想定通りにならず無為に悩む時間が発生したりなどのトラブルがあったため、
同期的なループに非同期処理を組み込むのはお勧めできないという話でした。

今回の話のサンプルコード:

https://runstant.com/pentamania/projects/46b08148

おまけ:asyncのまま同期ループ処理をする

後々気付きましたが、for-await...ofパターンを使うことで同期ループ処理すること自体は可能です。

(async function() {
  // 8回pick
  // number -> iterableへの変換は Array-likeへの変換で行う
  for await (let i of Array.from({length: 8})) {
    ObjectPool.pickAsync()
    .then((item)=> {
      item.setParent(dummyParent)
    })
    // 見つからなかったときの処理
    .catch((err)=> console.error(err))
  }
})();

これはこれでasync関数でくくる必要があったり、ループ回数のiterable変換が必要だったりで、ちょっと微妙ですが参考程度に…。

サンプルコード