コードに意味を与える技術

コードが語る並列実行の意図 - 同時実行数と完了順序を制御する技術

Tags: 並列処理, 非同期処理, コードの意図, JavaScript, コード設計

並列処理は、アプリケーションのパフォーマンス向上やユーザー体験の改善に不可欠な技術です。しかし、複数の処理が同時に、あるいは非同期に実行されるコードは、その実行順序や依存関係、そして「同時にいくつまで実行できるか」といった開発者の意図が不明瞭になりがちです。意図がコードから読み取れないと、デバッグが困難になったり、予期せぬ競合状態を引き起こしたり、コードレビューでの指摘が増える原因となります。

本記事では、並列実行を行うコードにおいて、その「意図」をいかに効果的にコードで表現するか、具体的なテクニックとコード例を通じて解説します。

並列実行における「意図」とは何か?

並列実行のコードにおける「意図」とは、主に以下の要素を指します。

これらの意図がコード上で明確に表現されていれば、そのコードを読む他の開発者は、実行時の挙動を正確に予測し、安全にコードの変更や拡張を行うことができます。

Before/Afterで見る意図の不明瞭さと明確化

まずは、意図が不明瞭になりがちなコード例と、それを改善した例を見てみましょう。ここでは非同期処理を例に取ります(JavaScriptのasync/awaitPromiseを使用します)。

例1: シンプルな並列実行と待機

複数の非同期処理を並列に実行し、全てが完了するのを待つという意図は比較的よくありますが、その表現方法によっては不明瞭になります。

Before: 意図が不明瞭なコード

async function processItems(items) {
  const promises = items.map(async (item) => {
    // 時間のかかる非同期処理(例: API呼び出し、ファイル読み書き)
    const result = await performAsyncTask(item);
    console.log(`Processed ${item}: ${result}`);
    return result;
  });

  // 全て終わるまで待つ意図があるが、Promise.allが使われていない
  // このままではpromises配列はPromiseオブジェクトの配列のまま
  // 実際にはawait Promise.all(promises)が必要だが、それが書かれていない
  // あるいは、全ての結果を収集する意図がない可能性も読み取れる
  console.log("Processing started.");
  // この時点ではpromises内の非同期処理はバックグラウンドで実行中
  // 明示的な待機がないため、この関数はPromisesが解決する前に完了してしまう可能性がある(文脈による)
}

// 仮の非同期タスク関数
async function performAsyncTask(item) {
  const delay = Math.random() * 1000;
  await new Promise(resolve => setTimeout(resolve, delay));
  return `Result for ${item}`;
}

// 実行例
processItems(['A', 'B', 'C']);
console.log("Function call finished."); // これがpromises内のconsole.logより先に表示される可能性がある

このコードは、performAsyncTaskが非同期に実行されることは分かりますが、processItems関数がそれらの完了を待っているのか、それとも開始するだけで良いのかが明確ではありません。promises変数を作っているものの、その結果を利用したり、完了を待ったりする処理が明示されていません。結果として、"Function call finished."というログが先に表示される可能性があり、開発者の意図(「全ての処理が終わってから次に進みたい」)がコードから読み取れません。

After: 意図を明確にしたコード

async function processItemsClearly(items) {
  console.log("Processing started. Waiting for all tasks to complete...");

  const promises = items.map(async (item) => {
    // 時間のかかる非同期処理
    const result = await performAsyncTask(item);
    console.log(`Processed ${item}: ${result}`);
    return result;
  });

  // Promise.all を使用して、全てのPromiseが解決するまで明確に待機する意図を表現
  try {
    const allResults = await Promise.all(promises);
    console.log("All tasks completed. Results:", allResults);
  } catch (error) {
    console.error("One of the tasks failed:", error);
    // エラーハンドリングの意図も明確にする
    // Promise.allはどれか一つがRejectされたら即座にRejectされる
  }
}

// 仮の非同期タスク関数(再掲)
async function performAsyncTask(item) {
  const delay = Math.random() * 1000;
  await new Promise(resolve => setTimeout(resolve, delay));
  return `Result for ${item}`;
}

// 実行例
processItemsClearly(['A', 'B', 'C']);
console.log("Function call finished. (Main thread continues)");

改善後のコードでは、await Promise.all(promises)を使用することで、「promises配列に含まれる全ての非同期処理が完了するまで待つ」という意図が明確に表現されています。これにより、"All tasks completed."というログは、全てのアイテムの処理が完了した後にのみ表示されることが保証されます。また、try...catchブロックでエラー発生時の挙動に関する意図(この場合は全て完了を待たずにエラーを捕捉する)も示唆されます。

例2: 同時実行数(並列度)の制御

リソースに制限がある場合(APIのレートリミット、DBコネクション数など)、並列実行数を制限したい場合があります。この「並列度をいくつにするか」という意図もコードで明確にすべきです。

Before: 並列度が不明瞭なコード(無制限に並列実行される可能性)

async function processItemsUncontrolled(items) {
  // itemsの数だけ同時にperformAsyncTaskが実行される
  // itemsが非常に多い場合、リソース枯渇やレートリミット超過の可能性がある
  const promises = items.map(item => performAsyncTask(item));
  // 全ての結果が必要なら Promise.all が必要だが、ここでも省略
  // 意図:全てのタスクを開始するだけ? 全ての結果が必要? 並列度は?
  console.log(`Started ${items.length} tasks.`);
}

// 仮の非同期タスク関数(再掲)
async function performAsyncTask(item) {
  console.log(`Starting task for ${item}`);
  const delay = Math.random() * 1000 + 500; // 少し長めに設定
  await new Promise(resolve => setTimeout(resolve, delay));
  console.log(`Finished task for ${item}`);
  return `Result for ${item}`;
}

// 実行例(itemsが多い場合を想定)
processItemsUncontrolled(Array.from({ length: 20 }, (_, i) => `Item-${i}`));

このコードでは、map関数がperformAsyncTaskを全てのアイテムに対して即座に呼び出し、それぞれのPromiseが生成されます。その結果、アイテムの数だけほぼ同時に非同期処理が開始されます。アイテム数が少ない場合は問題になりにくいですが、多い場合は意図しない負荷をシステムに与える可能性があります。「並列度を制限したい」という開発者の意図は、このコードからは一切読み取れません。

After: 同時実行数を明確にしたコード

並列数を制御するにはいくつかの方法がありますが、ここでは簡易的なキューイングメカニズムの考え方を取り入れた例を示します。

async function processItemsWithLimitedConcurrency(items, limit = 5) {
  console.log(`Processing items with concurrency limit: ${limit}`);

  const results = [];
  const queue = [...items]; // 処理待ちキュー
  const active = new Set(); // 実行中のタスク

  // タスクを開始する関数
  const runTask = async (item) => {
    active.add(item); // 実行中に追加
    console.log(`Starting task for ${item}. Active: ${active.size}/${limit}`);
    try {
      const result = await performAsyncTask(item);
      console.log(`Finished task for ${item}`);
      results.push(result); // 結果を収集
    } catch (error) {
      console.error(`Task for ${item} failed:`, error);
      // エラー発生時の挙動(例: 結果をスキップ、エラーを収集など)
    } finally {
      active.delete(item); // 実行中から削除
      // 次のタスクを開始
      processNext();
    }
  };

  // 次のタスクをキューから取り出して実行する関数
  const processNext = () => {
    if (queue.length > 0 && active.size < limit) {
      const nextItem = queue.shift(); // キューから一つ取り出す
      runTask(nextItem); // タスクを実行
    } else if (queue.length === 0 && active.size === 0) {
      // 全てのタスクが完了した場合
      console.log("All tasks processed.");
      // ここで処理完了を通知するPromiseを解決するなど、全体の完了を待つ機構を追加可能
    }
  };

  // 最初のタスクを開始
  // limitに達するまでキューからタスクを取り出し開始
  for (let i = 0; i < Math.min(limit, queue.length); i++) {
    processNext();
  }

  // 注意: この例では全体の完了を待つ await は含まれていません。
  // 完了を待つ場合は、別途、全てのタスクが終了したことを通知するメカニズム(例: 全タスクを管理するPromise.allなど)が必要です。
  // このコードは「並列度を制御してタスクを開始する意図」を示すことに焦点を当てています。
}

// 仮の非同期タスク関数(再掲)
async function performAsyncTask(item) {
  console.log(`  Executing task for ${item}...`);
  const delay = Math.random() * 1000 + 500; // 少し長めに設定
  await new Promise(resolve => setTimeout(resolve, delay));
  // ランダムにエラーを発生させることも可能
  // if (Math.random() < 0.1) throw new Error(`Failed to process ${item}`);
  return `Result for ${item}`;
}

// 実行例(itemsが多い場合を想定)
// 並列度を5に制限して実行
processItemsWithLimitedConcurrency(Array.from({ length: 20 }, (_, i) => `Item-${i}`), 5);

改善後のコードでは、limit引数で最大並列度を明確に指定しています。queueactiveという変数を用いて、現在実行中のタスク数を管理し、指定された並列度を超えないように次のタスクの開始を制御しています。これにより、「同時に実行するタスクは最大5つまで」という開発者の意図がコードの構造と変数名から明確に読み取れます。

このパターンは、asyncライブラリ(Node.jsなどで利用可能)のasync.parallelLimitや、カスタム実装などで実現されます。重要なのは、並列度の「制限」という意図を、変数や制御ロジックを通じてコード上で表現することです。

意図を明確にするその他のテクニック

並列実行の意図を伝えるためには、前述の構造化以外にも、以下のテクニックが役立ちます。

よくあるアンチパターンと回避策

並列実行のコードで意図が不明瞭になる、あるいは隠れてしまうアンチパターンとしては、以下のようなものがあります。

まとめ

並列実行を含むコードは、適切に意図が表現されていないと、その挙動の予測が難しく、デバッグや保守の大きな負担となります。コードを通じて並列実行の「意図」を明確に伝えることは、チーム開発におけるコード理解を促進し、コードレビューでの指摘を減らし、結果としてより堅牢で保守性の高いシステム構築に繋がります。

本記事で紹介した、Promise.allによる待機、並列度の制御、そして適切な命名やコメント、ライブラリの活用といったテクニックは、コードに並列実行の意図を「語らせる」ための強力な手段です。ご自身のコードをレビューする際、あるいは他者のコードを読む際に、「この並列処理は、どのような意図で書かれているのだろうか?」と問いかけ、その意図がコードから明確に読み取れるかを確認してみてください。

コードに意味を与え、開発者間のコミュニケーションを円滑にするために、並列実行の意図表現にもぜひ意識的に取り組んでいただければ幸いです。