テストスイートの実行時間が長大化すると、複数の CPU コアを活用してテストを並列実行し、全体の所要時間を線形に削るアプローチが第一選択となります。しかし、短命な外部プロセスを大量に生成する統合テスト環境では、思わぬ共有リソースの競合によって並列実行時の所要時間が単独実行の十数倍に跳ね上がる落とし穴が存在します。

テスト並列化における共有リソース競合とは、テストスイートをプロセス並列で高速化する際、短命な外部サブプロセスを大量起動することで、CPU 使用率の飽和ではなく OS の共有デーモン(認証や署名検証など)やツールのバックグラウンド自動処理(Git の保守など)の排他待ちによって実行時間が極端に悪化する現象です。

自作 AI エージェント基盤のリポジトリ(nqounet/claude-skills)において、テストスイートの実行時間を直列 405 秒から 10 並列 175 秒へ大幅に短縮した直後、不可解な性能劣化に見舞われました。単独実行ではわずか 43.9 秒で完了するテストクラスが、10 並列実行時には 730.9 秒(全体のクラス所要合計 5,772 秒)へ激しく劣化し、各プロセスの CPU 使用率は 0〜2% のまま「何かの待ち」でプロセスが詰まる状態に陥ったのです。

本記事では、この性能劣化の原因となった macOS 固有の共有システムデーモン(opendirectoryd 103.1%、XProtect 37.2%)の競合と、Git の local transport(ローカルディスク上の同期)において環境変数 GIT_CONFIG_KEY_* が脱落してバックグラウンドの自動保守が走るメカニズムを解明します。さらに、GIT_TEMPLATE_DIR によるテンプレート設定注入、偽 CLI のシバン直指定、および flock による排他制御を導入し、安定した高速実行(217.95 秒)を取り戻すまでの実践設計を詳解します。

検証環境と調査スコープ

本記事における計測および検証は、以下のハードウェアおよびソフトウェア環境で実施しました。

項目設定・バージョン
OSmacOS Sequoia 15.3 (Apple Silicon M-series, 14 コア)
言語・ランタイムPython 3.12 (unittest)
バージョン管理Git 2.4x
対象リポジトリnqounet/claude-skills(テスト実行スクリプト scripts/run_tests.py、および一時リポジトリを作成する skills/pilot/scripts/test_pilot.py 等)
計測期間2026-10-27 〜 2026-10-29(コミット 531d0fc 〜 ffc97c0)

本調査のスコープは、外部ツール(git、task、およびモック用の偽 CLI スクリプト)を短時間に大量に fork/exec する統合テスト環境です。メモリ内で完結する純粋なユニットテストとは異なり、ファイルシステムの作成・破棄、プロセスの起動オーバーヘッド、および OS レベルのセキュリティ・認証サブシステムの負荷が実行時間に色濃く現れる特性を持ちます。

並列化の前提とサブプロセス大量起動の構造

nqounet/claude-skills におけるテストスイート(mise run test)は、当初すべてのテストを 1 本ずつ順次実行しており、直列実行時の所要時間は合計 405 秒に達していました。

その内訳を分析したところ、skills/pilot/scripts/test_pilot.py が 212 秒(151 本)、skills/dispatch/scripts/test_dispatch.py が 165 秒(199 本)を消費しており、この 2 つのスクリプトだけで全体の 93% を占めていました。

テストクラス単位の実装を確認すると、各テストクラスは setUp メソッド内で tempfile.TemporaryDirectory による自前の作業ディレクトリ、独立した設定ファイル(TASKRC)、およびデータ領域(TASKDATA)を生成し、環境変数や作業ディレクトリの共有を避ける設計になっていました。そのため、テストクラス単位で別プロセス(python3 <file> <Class>)に分割すれば、相互に干渉することなく安全に並列実行できる構造でした。

そこで、テストクラスを別プロセスとして 10 並列で実行する仕組みを導入したところ、直列 405 秒から wall-time 175 秒(コミット 531d0fc 時点で 66 ジョブ全成功、wall-time 176 秒)へと約 57% の短縮を達成しました。

しかし、プロファイリング(cProfile)によって実行時間の内訳をさらに調べると、所要時間の大半を占める Launch クラス(31 本)では、1 テストあたり約 30 回、全体で git が 423 回、task が 158 回、モック用の偽 Python CLI が 112 回、偽 gh が 23 回という、膨大な回数の外部サブプロセス呼び出しが発生していました。1 回あたりのサブプロセス起動にかかる時間は 100〜140ms 程度であり、短命な外部プロセスを高速に連打する構造になっていたのです。

以下は、テストクラスを動的に列挙してプロセス並列で実行する基本骨格のコードです。

引用元: nqounet/claude-skills コミット 531d0fc の scripts/run_tests.py

def main():
    n = int(os.environ.get("RUN_TESTS_JOBS") or min(os.cpu_count() or 1, DEFAULT_JOBS_CAP))
    all_jobs = list(jobs())
    failed = []
    with ThreadPoolExecutor(max_workers=max(n, 1)) as pool:
        futures = [pool.submit(run_job, j) for j in all_jobs]
        for f in as_completed(futures):
            label, rc, sec, out = f.result()
            # 出力はジョブ単位でまとめて出す(並列で行が混ざらないように)
            print(f"=== {label}\n{out}=== {label} rc={rc} ({sec:.1f}s)", flush=True)
            if rc != 0:
                failed.append(label)
    if failed:
        print("FAILED: " + ", ".join(sorted(failed)), file=sys.stderr)
        return 1
    print("all tests passed")
    return 0

この実装により、各テストクラスが別プロセスとして並行して走り、独立した環境でテストを実行できるようになりました。しかし、この「短命サブプロセスの大量起動」こそが、後の深刻な性能劣化の引き金を引くことになります。

劣化の原因究明 ── 2つのボトルネック

10 並列化の導入によって 175 秒への短縮を果たしたのも束の間、実運用において極端な性能劣化が発生しました。

複数の作業セッションが同時に mise run test を実行した際、単独実行(直列スイート内)では 100 秒で終わっていた test_pilot.py::Launch が 1,349 秒に悪化し、単独 2.2 秒の test_float.py::DispatchedDrop が 104 秒かかってタイムアウト失敗を引き起こしました。さらに、セッション間の同時実行を防ぐ排他制御(後述の flock)を導入した後も、マシン上で他セッションのテストプロセスが 0 本の状態で実行したにもかかわらず、単体で単独実行すればわずか 43.9 秒で終わる Launch クラスが、10 並列実行時には 730.9 秒(ジョブ全体のクラス所要合計 5,772 秒)へと跳ね上がりました。

このとき、同時実行されているテストプロセスの CPU 使用率を計測すると、わずか 0〜2% にすぎず、14 コア環境での load average も約 5.5 に留まっていました。CPU 計算リソースが不足しているのではなく、プロセス群が「何かの待ち」でブロックされていることは明白でした。単独実行時の Launch クラスでも、子プロセスの実処理時間は約 29 秒(git -C 541 回で 12.1 秒、gh 113 回で 12.2 秒)であり、残りの大半は待機に費やされていたのです。

調査を進めた結果、この極端な遅延は 2 つのボトルネックが重なり合って発生していることが判明しました。

ボトルネック 1: macOS 共有デーモン(opendirectoryd / XProtect)の競合

第 1 のボトルネックは、macOS のシステムセキュリティ・認証デーモンの競合です。

テストプロセス群が一斉に短命サブプロセスを fork/exec することで、短時間に膨大なプロセスが生成される「プロセスストーム(process storm)」状態が発生していました。プロセスストーム発生時のシステムデーモンの負荷については、CoreGuard の調査でも短命プロセスの乱立によってセキュリティデーモンが CPU 180% に達する事例が報告されています1。

実機でのプロセス監視において、macOS のディレクトリサービスデーモンである opendirectoryd が CPU 103.1%、マルウェア防御デーモンである XProtect2 が CPU 37.2% という高い CPU 使用率を記録していることが観測されました。macOS では、プロセスの実行時に Gatekeeper やランタイム保護機構3 がバイナリ検証や署名チェックを行うほか、権限やアカウントの解決で opendirectoryd と頻繁に通信します。短命な外部プロセスが過密に起動されたことで、これらの OS 共有デーモンの処理待ちキューが滞留し、プロセス生成そのものが待機させられていたことが強く示唆されました。

ボトルネック 2: Git local transport における GIT_CONFIG_KEY_* の脱落と自動保守

第 2 のボトルネックは、Git の内部挙動と環境変数の伝播にありました。

テスト内では、一時ディレクトリ上に作成された Git リポジトリに対してローカル同期(clone や push)を頻繁に実行します。Git では、Git 2.31 より従来の git gc に代わるバックグラウンド保守機能として git maintenance が導入されました4。また、receive.autogc の仕様により、データ受信(git-push)や参照更新の完了後にデフォルトで git maintenance run --auto がバックグラウンドで切り離されて自動実行されます56。

テスト実行時の不要なオーバーヘッドを抑えるため、スクリプト側で Git の環境変数 GIT_CONFIG_KEY_* および GIT_CONFIG_COUNT を用いて maintenance.auto = false や receive.autoGC = false を渡す設定を施していました。しかし、実機での検証過程において、Git の local transport(ファイルシステム上のパスを用いた clone や push)を実行する際、これらの環境変数が子プロセスに引き継がれず脱落することが観測されました。

その結果、並列に走る大量のテストジョブがローカルリポジトリを操作するたびに、裏で git maintenance や receive.autogc が暗黙のうちに非同期で次々と起動していました。ツールの既定値は対話利用を想定して最適化されているため、短命サブプロセスを過密に連打する並列テスト環境では、意図しないバックグラウンド保守プロセスの増殖が激しいリソース競合を招いていたのです。

ボトルネックと対策の比較

テスト並列化における極端な劣化は単一の要因ではなく、セッション間の多重実行、OS のプロセス生成境界、およびツール固有の暗黙動作という 3 つの異なるレイヤーで発生していました。

競合レイヤー発生契機主なボトルネックの所在観測された兆候採用した対策適用スコープ
セッション間多重実行複数セッションが同時に mise run test を実行同一マシンの CPU・ディスク・一時ディレクトリの飽和Launch 1,349 秒、他テストのタイムアウト失敗/tmp/...lock による flock 排他マシン全域(ユーザー単位)
OS 共有デーモン短命外部 CLI(git / Python / task)の短時間大量 fork/execopendirectoryd / XProtect による認証・署名検証キューopendirectoryd 103.1%、XProtect 37.2%、CPU 0〜2% 待ち偽 CLI の sys.executable 直指定、hook コピー省略テスト内偽スクリプト・Git 初期化
Git 自動保守local transport による一時 repo への clone / pushGIT_CONFIG_* 脱落に伴う maintenance.auto / receive.autogc の裏起動単独セッション内でもジョブ同士が競合し 730.9 秒へ悪化GIT_TEMPLATE_DIR によるテンプレート設定注入Git コマンド実行環境

上表が示すように、セッション間の排他制御だけでは単一実行内のジョブ同士の競合は解決せず、OS デーモン負荷の軽減と Git の内部挙動を抑止する設定注入を組み合わせて初めて安定した並列化が成立しました。

ベストプラクティスと推奨実装

特定された 3 つのボトルネックを解消するため、テスト基盤に以下の 3 つの恒久的な対策を適用しました。

対策 1: GIT_TEMPLATE_DIR による最小テンプレート設定注入

Git の local transport 実行時に環境変数が脱落する問題に対しては、環境変数経由の指定を諦め、リポジトリの初期設定そのものに抑止フラグを書き込むアプローチを採用しました。

Git では、リポジトリ初期化時にテンプレートディレクトリの内容を展開する機能があり、環境変数 GIT_TEMPLATE_DIR でそのパスを指定できます7。テスト実行の開始時に tempfile.TemporaryDirectory で一時ディレクトリを生成し、その直下の config ファイルに [maintenance] auto = false および [receive] autoGC = false を書き込んで GIT_TEMPLATE_DIR に指定しました。

これにより、テスト内で実行されるすべての git init や git clone において、作成されるリポジトリ自体の設定ファイルに自動保守の無効化が直接注入されます。同時に、デフォルトテンプレートに含まれる 14 本のサンプル hook スクリプトのディスクコピーも省略され、ファイル I/O とプロセス生成の負担をさらに削減できます。

以下は、scripts/run_tests.py におけるテンプレート設定注入の実装です。

引用元: nqounet/claude-skills コミット 710e851 の scripts/run_tests.py

    with tempfile.TemporaryDirectory(prefix="claude-skills-git-template-") as template, \
            ThreadPoolExecutor(max_workers=max(n, 1)) as pool:
        # local transport は GIT_CONFIG_* を消すため、受信側の設定も repo に持たせる。
        # サンプル hook 14 本のコピーと、ユーザーの init.templateDir への依存も省く。
        with open(os.path.join(template, "config"), "w") as config:
            config.write("[maintenance]\n\tauto = false\n[receive]\n\tautoGC = false\n")
        env = test_environment()
        env["GIT_TEMPLATE_DIR"] = template
        futures = [pool.submit(run_job, j, env) for j in all_jobs]

この設定により、local transport を経由した一時リポジトリの作成や push においても、バックグラウンド保守プロセスが起動しない状態を確実に保証できるようになりました。

対策 2: 偽 CLI のシバン置換(sys.executable 直指定)

OS 共有デーモンの負荷を軽減するため、テスト内で数百回起動されるモック用の偽 CLI スクリプト(偽 orca、偽 gh 等)のプロセス生成コストを削ぎ落としました。

従来、これらのスクリプトのシバンには一般的な #!/usr/bin/env python3 を指定していました。しかし、この記述ではスクリプトを 1 回起動するたびに /usr/bin/env プロセスが起動し、PATH 環境変数を走査して Python インタプリタを探索・解決するオーバーヘッドが発生します。

そこで、テスト実行中の正規 Python インタプリタの絶対パスを取得できる sys.executable8 を用い、シバンを "#!" + sys.executable へ置換しました。

以下は、skills/pilot/scripts/test_pilot.py におけるシバン置換の実装です。

引用元: nqounet/claude-skills コミット 710e851 の skills/pilot/scripts/test_pilot.py

# env の起動と PATH 探索を省き、テスト本体と同じ Python を直接起動する(#302)。
FAKE_ORCA = "#!" + sys.executable + r'''
import json, os, sys
argv = sys.argv[1:]
log = os.environ["FAKE_ORCA_LOG"]

中間プロセスである env の起動と PATH 探索を排除し、すでに起動しているテストプロセスと同一の Python バイナリを直接呼び出すことで、OS 共有デーモンに対する検証リクエストの頻度を低減させました。

対策 3: flock 排他制御と解放時 truncate() によるメタデータ消去

複数セッションの同時実行による過大遅延を防ぐため、ユーザー単位のロックファイル /tmp/claude-skills-tests-<uid>.lock に対する排他制御を導入しました。

テスト実行の開始時に Python の標準ライブラリである fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) による排他を試み9、取得できない場合はロック保持者の PID やカレントディレクトリ(cwd)をロックファイルから読み出して表示し、待機状態であることを可視化します。

ロック解放時の設計において重要な点は、ロックファイルを削除(unlink)しない ことです。UNIX における flock はファイルパスではなくファイルの実体である inode に対してロックを保持します。ロック保持中にファイルを削除してしまうと、後からロックを取得しようとした新規プロセスが新しい inode を作成してしまい、待機中プロセスと新規プロセスで参照する inode が分離して排他が破壊されます10。

そのため、ファイル(inode)は削除せずに残し、排他保持中に truncate() を呼び出して保持者メタデータのみを消去して解放する設計としました。

以下は、scripts/run_tests.py におけるロック解放処理の実装です。

引用元: nqounet/claude-skills コミット 8402300 / 92e8df5 の scripts/run_tests.py

            # 旧版は metadata を書かない。次の保持者に前回の PID/cwd を引き継がせない。
            # truncate と flush は排他中に済ませ、ファイル自体(inode)は残す。
            try:
                lock.seek(0)
                lock.truncate()
                lock.flush()
            finally:
                fcntl.flock(lock, fcntl.LOCK_UN)

この設計により、後続プロセスとの inode 分離を防ぎつつ、古い実行メタデータを確実に初期化できる堅牢な排他制御を実現しました。

実機ベンチマークと検証ログ

対策を適用したテストスイートにおいて、実機でのベンチマーク測定と並列数の比較検証を実施しました。

対策適用前後の比較と並列数の検証

テスト件数が 738 件に増加した状態において、対策適用前はテストスイート全体の wall-time が 279.43 秒に達していましたが、10 並列実行での所要時間は 217.95 秒(再実行時 217.4 秒、exit 0)へと回復し、約 61.5 秒の短縮が確認されました。

続いて、最適な並列数を特定するため、単独実行において並列ワーカー数を 5、7、10 に変化させて測定を行いました。

以下は、コミット ffc97c0 に記録された実機ベンチマークの測定ログです。

At 92e8df5, isolated one-run comparisons took 217.95s with ten workers, 224.01s with five, and 236.37s with seven. Restore the cap to ten and align README, CLAUDE and mise descriptions.

A same-code comparison retaining 626 unittest IDs shared with c041415d took 195.59s versus 217.95s for 738 current IDs. Job-duration totals were 1680.7s versus 1828.1s. This accounts for an observed 22.36s wall-time difference; the remaining 21.59s relative to the historical 174s is not isolated by this cohort comparison.

測定結果は以下の通りでした。

  • 5 並列: 224.01 秒
  • 7 並列: 236.37 秒
  • 10 並列: 217.95 秒

7 並列よりも 10 並列のほうが優れたスループットを示したため、並列数の上限キャップを 10 に設定しました。

コホート比較による差分分析

並列化導入当初(2026-10-27)に記録された 174 秒と比較すると、対策適用後の 217.95 秒は約 44 秒遅い状態でした。この差分の原因を切り分けるため、過去コミットと共通する 626 件の unittest ID だけを抽出した対照実行によるコホート比較を実施しました。

  • 同一コード・共通 626 件の対照実行: 195.59 秒(ジョブ所要合計 1,680.7 秒)
  • 同一コード・現在 738 件の全件実行: 217.95 秒(ジョブ所要合計 1,828.1 秒)

共通 626 件と現在 738 件(112 件増)の差分である 22.36 秒は、テスト件数の増加に伴う純増分として明確に説明されました。一方、歴史的記録の 174 秒と共通件数対照実行(195.59 秒)との間に残る約 21.59 秒の残差については、コホート比較では分離されておらず、Python インタプリタ初期化コストやファイルシステムキャッシュ等の未分離の残差として客観的に記録されています。

よくある質問(FAQ)

Q. macOS 上で並列テストを実行すると CPU 使用率は低いのに極端に遅くなるのはなぜですか?

A. 短命なサブプロセスの大量生成により、opendirectoryd や XProtect などの macOS 共有システムデーモンでプロセス生成時の認証・署名検証が集中し、プロセス待機が多発したためと推測されます。実測ログでも両デーモンが高負荷(opendirectoryd 103.1%、XProtect 37.2%)を記録しており、CPU 処理の飽和ではなく共有リソースの待機がボトルネックとなった強い示唆が得られています。

Q. Git の環境変数 GIT_CONFIG_KEY_* では local clone や push 時の automatic maintenance を止められないのはなぜですか?

A. Git の local transport(ローカルディスク上の bare リポジトリとの同期)実行時に、子プロセスへ GIT_CONFIG_KEY_* などの環境変数が伝播せず脱落することが実機検証において確認されたためです。GIT_TEMPLATE_DIR を用いてリポジトリ初期設定(config)自体に maintenance.auto = false および receive.autoGC = false を注入することで、環境変数の脱落にかかわらず確実に自動保守を抑止できます。

A. flock はファイルパスではなく inode に対してロックをかけるため、ロック保持中にファイルを削除(unlink)すると、待機中プロセスと新規実行プロセスが異なる inode を参照して排他が破壊されるためです。ファイル(inode)を保持したまま排他中に truncate() でメタデータを消去するのが安全な設計です。

まとめ

短命サブプロセスを多用するテストスイートの並列化において、安定した高速実行を達成するための設計原則は以下の通りです。

  • OS 共有デーモンを意識したプロセス生成の最小化: 短命サブプロセスの連打は CPU ではなく OS 共有サービス(opendirectoryd / XProtect)の飽和を招くため、シバンのインタプリタ直指定や不要な hook コピーの省略によってプロセス生成オーバーヘッドを極小化する。
  • Git local transport の環境変数脱落への防護: Git のローカル同期処理は環境変数を伝播しない特性があるため、GIT_TEMPLATE_DIR によるテンプレート設定注入を用いて maintenance.auto = false および receive.autoGC = false をリポジトリ設定に直接書き込む。
  • アドバイザリロックにおける inode ライフサイクルの維持: flock による排他制御では、ロックファイルの削除(unlink)による inode 分離を避け、truncate() によるメタデータ初期化を採用して排他の完全性を維持する。
  • 実測ベンチマークに基づく並列数の決定: 並列実行数は CPU コア数から機械的に決めるのではなく、サブプロセスの競合状況に応じた実機測定(5/7/10 並列等の比較)を行って最適な上限値を設定する。

テストスイートの並列化・高速化やエージェント開発環境の自動化基盤設計について、ぜひ コンタクトフォーム よりお気軽にご相談ください。

Footnotes

  1. What Is syspolicyd? Why It Hits 180% CPU on Your Mac CoreGuard Insights, 2026-06-27 公開。短命プロセスの大量生成時に発生するプロセスストームとシステムデーモン高負荷の分析。 ↩

  2. Protecting against malware in macOS Apple Inc. (Apple Platform Security Guide) macOS における XProtect によるランタイムマルウェア防御アーキテクチャの解説。 ↩

  3. Gatekeeper and runtime protection in macOS Apple Inc. (Apple Platform Security Guide) macOS のコード実行時検証と Gatekeeper ランタイム保護機構の解説。 ↩

  4. Highlights from Git 2.31 Taylor Blau, The GitHub Blog, 2021-03-15 公開。git maintenance の導入とバックグラウンド保守の標準化背景。 ↩

  5. Git - git-maintenance Documentation Git Project (Git 2.4x Documentation) maintenance.auto、maintenance.autoDetach、--auto によるバックグラウンド自動保守の仕様。 ↩

  6. Git - git-config Documentation Git Project (Git 2.4x Documentation) receive.autogc(データ受信時に git maintenance run --auto を実行する設定)の仕様。 ↩

  7. Git - git-init Documentation Git Project (Git 2.4x Documentation) $GIT_TEMPLATE_DIR の適用優先順位とリポジトリ初期化時のテンプレート展開仕様。 ↩

  8. Python sys — System-specific parameters and functions Python Software Foundation (Python 3.12 Documentation) sys.executable(実行中インタプリタの絶対パス文字列)の仕様。 ↩

  9. Python fcntl — The fcntl and ioctl system calls Python Software Foundation (Python 3.12 Documentation) fcntl.flock、LOCK_EX、LOCK_NB、LOCK_UN による排他制御の仕様。 ↩

  10. Why you shouldn’t unlink a file while holding an advisory flock lock Stack Overflow コミュニティ(アクセス日: 2026-11-02)flock が inode に対してロックを保持するためファイル削除(unlink)で排他が破壊される理由の解説。 ↩