コードが語る並列実行の意図 - 同時実行数と完了順序を制御する技術
並列処理は、アプリケーションのパフォーマンス向上やユーザー体験の改善に不可欠な技術です。しかし、複数の処理が同時に、あるいは非同期に実行されるコードは、その実行順序や依存関係、そして「同時にいくつまで実行できるか」といった開発者の意図が不明瞭になりがちです。意図がコードから読み取れないと、デバッグが困難になったり、予期せぬ競合状態を引き起こしたり、コードレビューでの指摘が増える原因となります。
本記事では、並列実行を行うコードにおいて、その「意図」をいかに効果的にコードで表現するか、具体的なテクニックとコード例を通じて解説します。
並列実行における「意図」とは何か?
並列実行のコードにおける「意図」とは、主に以下の要素を指します。
- 実行順序: 特定の処理が別の処理より前に完了する必要があるか、あるいは依存関係はあるか。
- 同時実行数(並列度): いくつの処理を同時に実行できるか、または許容する最大並列数はいくつか。
- 完了条件: 全ての処理が完了するまで待つか、最初の一つが完了したら次に進むか、全て成功する必要があるか。
- エラーハンドリング: どの処理でエラーが発生した場合、他の並列処理はどうするか(継続するか、全てキャンセルするか)。
これらの意図がコード上で明確に表現されていれば、そのコードを読む他の開発者は、実行時の挙動を正確に予測し、安全にコードの変更や拡張を行うことができます。
Before/Afterで見る意図の不明瞭さと明確化
まずは、意図が不明瞭になりがちなコード例と、それを改善した例を見てみましょう。ここでは非同期処理を例に取ります(JavaScriptのasync/await
とPromise
を使用します)。
例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
引数で最大並列度を明確に指定しています。queue
とactive
という変数を用いて、現在実行中のタスク数を管理し、指定された並列度を超えないように次のタスクの開始を制御しています。これにより、「同時に実行するタスクは最大5つまで」という開発者の意図がコードの構造と変数名から明確に読み取れます。
このパターンは、async
ライブラリ(Node.jsなどで利用可能)のasync.parallelLimit
や、カスタム実装などで実現されます。重要なのは、並列度の「制限」という意図を、変数や制御ロジックを通じてコード上で表現することです。
意図を明確にするその他のテクニック
並列実行の意図を伝えるためには、前述の構造化以外にも、以下のテクニックが役立ちます。
- 命名: 並列処理を行う関数や変数には、その性質を示す名前を付けます。例:
processItemsConcurrently
,taskPromises
,activeTaskLimit
など。 - コメント: 特に並列処理の制御ロジックが複雑な場合、「なぜこの並列度なのか」「なぜここで待機するのか」「この処理の完了を待つ依存関係は何か」といった理由や背景をコメントで補足します。
- ライブラリ/言語機能の活用: 使用しているプログラミング言語やフレームワークが提供する、並列処理や非同期処理を扱うための標準的なAPIやライブラリを積極的に活用します。
Promise.all
,Promise.race
,async.parallel
,ThreadPoolExecutor
,asyncio.gather
など、意図を表現するための豊富な機能が提供されています。これらの機能を使うこと自体が、意図を伝える有力な手段となります。 - エラーハンドリングの設計: 並列実行中のエラー発生時にどう振る舞うか(即時停止か、他の処理は継続か、エラーを収集して最後に報告か)を明確にコード化します。これも重要な実行意図の一部です。
よくあるアンチパターンと回避策
並列実行のコードで意図が不明瞭になる、あるいは隠れてしまうアンチパターンとしては、以下のようなものがあります。
- マジックナンバーとしての並列度: 並列度の制限値が、定数化されず、突然数字としてコード中に現れる。「なぜその値なのか」という意図が全く不明になります。
- 回避策: 並列度を定数として定義し、その定数名で意図(例:
MAX_CONCURRENT_API_CALLS
)を表現します。
- 回避策: 並列度を定数として定義し、その定数名で意図(例:
- 複雑なネストやコールバックチェーン: 非同期処理の依存関係や順序制御が、深いネストや複雑なコールバックの連鎖で表現される。意図が追いづらく、バグの温床となります。
- 回避策:
async/await
、Promiseチェーン、専用の非同期制御ライブラリなどを活用し、処理の流れをフラットに、読みやすくします。
- 回避策:
- グローバルな状態への依存: 複数の並列処理が、保護されていないグローバル変数や共有メモリを読み書きする。意図しない競合状態が発生し、デバッグを極めて困難にします。
- 回避策: 各タスクをできるだけ独立させ、必要なデータは引数として渡すようにします。共有状態が必要な場合は、ミューテックスやセマフォなどの同期機構を適切に使用し、意図をコードで明確に表現します。
まとめ
並列実行を含むコードは、適切に意図が表現されていないと、その挙動の予測が難しく、デバッグや保守の大きな負担となります。コードを通じて並列実行の「意図」を明確に伝えることは、チーム開発におけるコード理解を促進し、コードレビューでの指摘を減らし、結果としてより堅牢で保守性の高いシステム構築に繋がります。
本記事で紹介した、Promise.all
による待機、並列度の制御、そして適切な命名やコメント、ライブラリの活用といったテクニックは、コードに並列実行の意図を「語らせる」ための強力な手段です。ご自身のコードをレビューする際、あるいは他者のコードを読む際に、「この並列処理は、どのような意図で書かれているのだろうか?」と問いかけ、その意図がコードから明確に読み取れるかを確認してみてください。
コードに意味を与え、開発者間のコミュニケーションを円滑にするために、並列実行の意図表現にもぜひ意識的に取り組んでいただければ幸いです。