非同期パターン

bca.analyze は CPU バウンドです。処理の中身は tree-sitter によるパースとメトリクスの各パスで、どちらも PyO3 の Python::detach を介して Rust 側で GIL を解放します。したがって正規の非同期パターンは asyncio.to_thread です:

async def analyze_async(path: Path) -> FuncSpaceDict | None:
    """Run ``bca.analyze(path)`` on the default thread executor."""
    return await asyncio.to_thread(bca.analyze, path)


async def analyze_all(
    paths: Iterable[Path],
) -> list[FuncSpaceDict | BaseException | None]:
    """Fan ``analyze_async`` out across ``paths`` with ``asyncio.gather``.

    ``return_exceptions=True`` matters here: ``bca.analyze`` runs
    inside ``asyncio.to_thread`` and Python threads cannot be
    cancelled. If one call raises and gather re-raises with
    ``return_exceptions=False``, the surviving threads keep running
    in the default executor, producing results that are silently
    discarded. With ``return_exceptions=True`` every thread's
    result (success OR exception) lands in the returned list so
    the caller can dispatch per-file.
    """
    return await asyncio.gather(
        *(analyze_async(p) for p in paths),
        return_exceptions=True,
    )

ネイティブ async ではなく to_thread を使う理由

bca.analyze は同期的な Rust コードに支えられた同期的な Python 関数で、内部に await 境界はありません。asyncio.to_thread でラップすると:

  1. 呼び出しをデフォルトのスレッドプールにスケジュールします。
  2. パースとメトリクスのパスが走る間、他のコルーチンを進行させます。
  3. 完了時に結果を呼び出し元のコルーチンへ返します。

Rust 側が重い処理の間 GIL を解放するため、複数の to_thread(bca.analyze, ...) 呼び出しは本当に並列に実行されます — これは協調的な I/O 多重化ではなく、スレッドプールのサイズを上限とする実際のマルチコア活用です。

カスタムエグゼキューター

ワーカー数をより厳密に制限するには、専用に用意したエグゼキューターを to_thread に渡します:

import asyncio
from concurrent.futures import ThreadPoolExecutor

import big_code_analysis as bca

async def analyze_many(paths: list[str]) -> list[object]:
    loop = asyncio.get_running_loop()
    with ThreadPoolExecutor(max_workers=8) as pool:
        return await asyncio.gather(
            *(loop.run_in_executor(pool, bca.analyze, p) for p in paths)
        )

純粋に CPU バウンドな処理では、8 コアのマシンで 8 ワーカーが無理のない上限です。これ以上増やすとマシンを過剰に割り当てることになり、スループットをコンテキストスイッチのオーバーヘッドと引き換えにしてしまいます。

結果のストリーミング

asyncio.as_completed を使うと、最初の解析が完了した時点から結果の消費を始められます。ファイルごとの処理コストが大きくばらつく場合(5 KB のファイルと 500 KB の生成バンドルなど)に有用です:

import asyncio
import big_code_analysis as bca

async def first_failure(paths: list[str]) -> str | None:
    """Return the path of the first file with cyclomatic > 50."""
    tasks = [asyncio.create_task(asyncio.to_thread(bca.analyze, p)) for p in paths]
    try:
        for coro in asyncio.as_completed(tasks):
            result = await coro
            if result is None:
                continue
            if result["metrics"]["cyclomatic"]["sum"] > 50:
                return result["name"]
    finally:
        for t in tasks:
            t.cancel()
    return None

finally ブロックでのキャンセルが重要です。as_completed は呼び出し元が早期リターンしても保留中のタスクを自動でキャンセルしないため、リークしたタスクが非同期関数の復帰後もスレッドプール上で動き続けることがあります。

アンチパターン:コルーチン内での bca.analyze の直接呼び出し

# これはやってはいけません。
async def bad(path: str) -> dict | None:
    return bca.analyze(path)  # 呼び出しのたびにイベントループをブロックします

async def は本体を非同期にするわけではありません。to_thread や明示的なエグゼキューターなしでは、bca.analyze を呼び出すすべてのコルーチンがパースの全時間にわたってイベントループを停止させます — I/O、タイマー、キューを待つ他のタスクはすべて、パースが返るまで凍結します。to_thread ラッパーは 1 行で書けて、応答性のあるサーバーとシングルスレッドのサーバーの違いを生みます。

analyze_batch が適している場面

静的で有限のパスのリストを処理していて結果のストリーミングが不要なら、bca.analyze_batch の方が gather(*to_thread(...)) よりシンプルです。呼び出し元スレッド上で逐次実行されますが、ファイルごとのエラーで例外を送出することはありません。イベントループの応答性を保つには、analyze_batch の呼び出し全体を asyncio.to_thread でラップしてください:

import asyncio
import big_code_analysis as bca

async def batch(paths: list[str]) -> list[object]:
    return await asyncio.to_thread(bca.analyze_batch, paths)

これは gather のファイル単位の並列性を、analyze_batch のよりシンプルなエラーモデルと引き換えにします。並列性と型付き OSError の振り分けの両方が欲しい場合は gather を、非同期呼び出しを 1 つにまとめて例外を送出しない契約が欲しい場合は to_thread(analyze_batch, paths) を選んでください。