シングルスレッドの限界を突破するアーキテクチャ
Windowsスクリプティング環境において、VBScript(ActiveScripting)は本質的にシングルスレッドで動作する。これは、COM(Component Object Model)のSTA(Single-Threaded Apartment)モデルに拘束されているためだ。
しかし、現代のシステム運用において、大量のサーバーへの同時Ping、複数ディレクトリの並行同期、あるいは重たい外部CLIツールの同時実行などを「直列(シングルスレッド)」で処理することは、ビジネスの速度において許容されない。
多くの初級エンジニアは、`WScript.Shell`の`Run`メソッドを非同期フラグ(`bWaitOnReturn = False`)で呼び出し、制御をスクリプトに戻すことで「並列化できた」と誤解する。しかし、`Run`メソッドは投げっぱなし(Fire and Forget)の制御しかできず、起動したプロセスの生存監視、終了コード(Exit Code)の取得、ましてや標準出力(StdOut)の回収といったオーケストレーションは不可能である。
我々プロフェッショナルが選択すべきは、`WScript.Shell.Exec`を用いた非同期プロセスの完全掌握である。
本稿では、`Exec`メソッドが返す`WshScriptExec`オブジェクトを高度に制御し、OSレベルのバッファ仕様やCPUスケジューリングを考慮した、プロダクション環境に耐えうる「極限の疑似マルチスレッド・タスクランナー」の設計と実装を詳解する。
—
深淵なる罠:バッファデッドロックとビジーループ
`WScript.Shell.Exec`による並列処理を実装する際、素人アーキテクトが必ず踏み抜く2つの致命的な「罠」が存在する。これらを予期し、事前に対策を講じることなしに、堅牢なシステムは構築できない。
1. 標準出力バッファの枯渇による「デッドロック(死の固定化)」
最も凶悪な罠が、OSのパイプバッファ制限である。
`Exec`で起動された子プロセスは、標準出力(StdOut)および標準エラー出力(StdErr)用のパイプを介して親プロセス(VBScript)と接続される。Windowsカーネルがこのパイプに割り当てるバッファ容量は非常に小さい(環境によるが通常4KB〜64KB程度)。
もし子プロセスがこの上限を超えるテキストをStdOutに書き出し、かつ親プロセス側がそれをリアルタイムに「吸い出し(Read)」て空きを作らない場合、子プロセスは書き込み処理の途中で永続的にブロッキング(フリーズ)する。
[子プロセス] –(大量の出力)–> [OSパイプバッファ (満杯)] –(ブロック)–> 子プロセス停止
↑
[VBScript (監視中)] <-- 終了を待ち続けるが、永久に終わらない
このデッドロックを回避するためには、以下のいずれかの設計アプローチが必須となる。
- アプローチA: 子プロセスのコマンドライン側で、出力をファイルにリダイレクト(`> log.txt 2>&1`)するか、あるいはヌルデバイス(`> nul 2>&1`)に破棄させ、COMパイプにデータを流さない。
- アプローチB: 監視ループ内で、各プロセスの`StdOut.AtEndOfStream`や`ReadLine`をブロッキングさせないように注意しながら、定期的にバッファを読み出す(ドレイン処理)。
本稿のタスクランナーでは、最も汎用性が高く堅牢な「アプローチA(リダイレクト・サイレント化)」を基本戦略とし、必要に応じて出力を安全に回収する設計を提示する。
2. ビジーループによるCPU占有(100%スパイク)
非同期プロセスの終了を監視する際、以下のような単純なポーリング(問い合わせ)ループを書いてはならない。
‘ 絶対にやってはならないアンチパターン
Do While objExec.Status = 0
‘ 何もしない空ループ
Loop
このコードを実行した瞬間、VBScriptが動作するCPUコアの1つが100%に張り付き、システム全体のパフォーマンスを著しく阻害する。
これを防ぐには、Windowsカーネルに対して実行権限を一時的に返却(コンテキストスイッチを許容)する`WScript.Sleep`の挿入が不可欠である。適切なSleep値は、タスクの粒度(Granularity)に依存するが、システム管理においては 100ms 〜 500ms がCPU負荷と応答性のベストバランスである。
—
極限の設計:スロット制御型非同期タスクランナー
ここで構築するタスクランナーの仕様を定義する。単に複数のプロセスを同時に立ち上げるだけでは、サーバーのサーバリソース(メモリ、CPU)を食いつぶす「フォーク爆弾」になりかねない。
したがって、本スクリプトは「最大同時実行数(並列スロット数)」を制御できるセマフォ的なアーキテクチャを採用する。
状態遷移モデル
[タスクキュー] ──(デキュー)──> [実行スロット (最大Nスレッド)]
│
(ポーリング監視 & Sleep)
│
[処理完了] <──(リソース解放)───── [終了検知]
1. タスクのキューイング: 実行したいコマンド群を配列(またはディクショナリ)に格納する。
2. スロットへの割り当て: 現在実行中のプロセス数が「最大同時実行数」未満であれば、キューから新しいタスクを取り出して `Exec` を実行し、実行中リストに加える。
3. ライフサイクル監視: ループ内で実行中リストの全プロセスの `Status` を監視する。
4. クリーンアップ: 終了したプロセス(`Status = 1`)を検知したら、終了コードを取得し、オブジェクトを明示的に `Nothing` にしてメモリを解放、空いたスロットに次のタスクを充填する。
—
完全実装:プロダクションクオリティの並列タスクランナー
以下に、実戦に投入可能な極限の並列タスクランナーのソースコードを示す。このコードはWindows Server 2012 R2からWindows 11 / Windows Server 2022までの環境で動作を検証している。
Option Explicit
‘ ==============================================================================
‘ System Configuration
‘ ==============================================================================
Const MAX_PARALLEL_JOBS = 4 ‘ 同時並列実行スロット数(環境に合わせて調整)
Const POLLING_INTERVAL = 200 ‘ 監視ループのスリープミリ秒(CPU負荷を極小化)
‘ ==============================================================================
‘ Main Logic Entry Point
‘ ==============================================================================
Call Main()
Sub Main()
WScript.Echo “[” & Now() & “] [INFO] Task Runner Started.”
‘ 1. タスクリスト(キュー)の定義
‘ ※実務ではテキストファイルやDBから動的に読み込むロジックに置き換え可能
Dim arrTasks
arrTasks = Array( _
“ping.exe 127.0.0.1 -n 5”, _
“ping.exe 127.0.0.1 -n 3”, _
“ping.exe 127.0.0.1 -n 7”, _
“ping.exe 127.0.0.1 -n 4”, _
“ping.exe 127.0.0.1 -n 2”, _
“ping.exe 127.0.0.1 -n 6” _
)
‘ 2. 並列タスクランナーの実行
Dim lngSuccessCount
lngSuccessCount = ExecuteParallelTasks(arrTasks, MAX_PARALLEL_JOBS)
WScript.Echo “[” & Now() & “] [INFO] All tasks completed. Success: ” & lngSuccessCount & “/” & (UBound(arrTasks) + 1)
End Sub
‘ ==============================================================================
‘ Core Architecture: Parallel Execution Engine
‘ ==============================================================================
Function ExecuteParallelTasks(ByRef arrTasks, ByVal intMaxSlots)
Dim objShell
Set objShell = CreateObject(“WScript.Shell”)
Dim intTotalTasks, intNextTaskIdx
intTotalTasks = UBound(arrTasks) + 1
intNextTaskIdx = 0
‘ 実行中のジョブを管理するScripting.Dictionary
‘ Key: タスクID (インデックス), Value: WshScriptExec オブジェクト
Dim dictActiveJobs
Set dictActiveJobs = CreateObject(“Scripting.Dictionary”)
‘ タスクごとの実行ステータス記録用
Dim arrExitCodes
ReDim arrExitCodes(UBound(arrTasks))
Dim intFinishedCount
intFinishedCount = 0
Dim intSuccessCount
intSuccessCount = 0
‘ メイン・オーケストレーション・ループ
Do While intFinishedCount < intTotalTasks
' スロットに空きがあり、かつ未実行のタスクが残っている場合、プロセスをフォークする
Do While (dictActiveJobs.Count < intMaxSlots) And (intNextTaskIdx < intTotalTasks)
Dim strCommand, objExec
strCommand = arrTasks(intNextTaskIdx)
WScript.Echo "[" & Now() & "] [SPAWN] Slot " & (dictActiveJobs.Count + 1) & " -> Task ” & intNextTaskIdx & “: ” & strCommand
‘ 実行時のエラーハンドリング(パスが通っていない、権限不足などの即時エラーをトラップ)
On Error Resume Next
‘ ※標準出力を抑止するため、必要に応じてコマンド自体に「> nul 2>&1」を付与することを推奨
Set objExec = objShell.Exec(strCommand)
If Err.Number <> 0 Then
WScript.Echo “[” & Now() & “] [ERROR] Failed to execute Task ” & intNextTaskIdx & “: ” & Err.Description
arrExitCodes(intNextTaskIdx) = -9999 ‘ 起動失敗を示す内部コード
intFinishedCount = intFinishedCount + 1
Err.Clear
Else
‘ 辞書に登録してライフサイクル管理下に置く
dictActiveJobs.Add intNextTaskIdx, objExec
End If
On Error GoTo 0
intNextTaskIdx = intNextTaskIdx + 1
Loop
‘ アクティブなジョブのステータス監視
Dim arrKeys, varKey, i
arrKeys = dictActiveJobs.Keys
For Each varKey In arrKeys
Dim objCurrentExec
Set objCurrentExec = dictActiveJobs(varKey)
‘ プロセスの終了判定 (Status: 0=実行中, 1=終了)
If objCurrentExec.Status = 1 Then
Dim lngExitCode
lngExitCode = objCurrentExec.ExitCode
arrExitCodes(varKey) = lngExitCode
If lngExitCode = 0 Then
WScript.Echo “[” & Now() & “] [SUCCESS] Task ” & varKey & ” finished with ExitCode 0.”
intSuccessCount = intSuccessCount + 1
Else
WScript.Echo “[” & Now() & “] [FAILURE] Task ” & varKey & ” finished with ExitCode ” & lngExitCode & “.”
End If
‘ リソースの明示的解放(メモリリークの完全防止)
‘ WshScriptExecが保持する標準入出力ストリームを閉じる
On Error Resume Next
objCurrentExec.StdIn.Close
objCurrentExec.StdOut.Close
objCurrentExec.StdErr.Close
On Error GoTo 0
‘ ライフサイクルを終えたオブジェクトの参照を完全に切る
Set objCurrentExec = Nothing
dictActiveJobs.Remove varKey
intFinishedCount = intFinishedCount + 1
End If
Next
‘ CPUのビジーループを回避するためのウェイト
‘ このSleepにより、OSのスケジューラにスレッドタイムスライスを明示的に返却する
If intFinishedCount < intTotalTasks Then
WScript.Sleep POLLING_INTERVAL
End If
Loop
' 後処理
Set dictActiveJobs = Nothing
Set objShell = Nothing
ExecuteParallelTasks = intSuccessCount
End Function
---
コードの超詳細解説とアーキテクチャの真髄
上記のスクリプトは、レガシーなVBScriptエンジン上で動作するとは思えないほどの堅牢性とパフォーマンスを発揮する。その心臓部にある設計思想を解説する。
1. `Scripting.Dictionary` をセマフォとして応用する
並列スロット数の制限を実現するために、COMオブジェクトである `Scripting.Dictionary` を使用している。
Keyに「タスク配列のインデックス」、Valueに「`WshScriptExec` オブジェクト」を格納することで、以下のメリットを得ている。
- 動的な追加と削除: `dictActiveJobs.Remove` により、終了したプロセスを即座に管理対象から除外できる。
- 正確なインデックス管理: どのタスクが終了し、どのタスクが実行中なのかを、配列の再割り当て(`ReDim Preserve`)のような高コストな処理を伴わずに高速に走査できる。
2. ストリームの明示的クローズによるカーネルリソースの保護
多くのVBScript解説書で省略されているのが、以下の処理である。
objCurrentExec.StdIn.Close
objCurrentExec.StdOut.Close
objCurrentExec.StdErr.Close
`WshScriptExec` オブジェクトが破棄される際、COMの参照カウントが0になればガベージコレクション(VBScriptのエンジンが持つメモリ管理機能)によって最終的には解放される。しかし、大量のプロセスをミリ秒単位で処理するようなバッチシステムでは、OSのファイル記述子(パイプ用ハンドル)の解放が追いつかず、一時的にハンドルリークを起こす危険性がある。
終了検知と同時に、ストリームを明示的に `Close` することは、エンタープライズ領域における「絶対に止められないシステム」を構築する上での鉄則である。
3. 即時エラーのトラップ
`objShell.Exec` は、指定されたコマンドラインが実行不可能な場合(例:実行ファイルが存在しない、パスが通っていない、権限がない)、即座にランタイムエラーをスローする。
これに対し、`On Error Resume Next` でガードをかけ、エラー番号(`Err.Number`)を評価している。
このガードがない場合、並列処理の途中で1つのコマンドがスペルミスしていただけで、スクリプト全体が異常終了(アボート)し、既に起動していた他のプロセスの監視すら放棄されて「ゾンビプロセス」がシステムに残存することになる。
—
レガシーを超越するための設計思想
VBScriptは、20年以上にわたりWindowsインフラの自動化を支え続けてきた枯れた技術である。しかし、枯れているからこそ、その内部挙動(シングルスレッドモデル、COM参照カウント、Windows API/OSカーネルとの境界)を深く理解して記述されたコードは、最新のランタイムに匹敵する軽量さと、極めて高い信頼性を提供する。
本稿で示した「スロット制御型非同期タスクランナー」は、プロセス生成という最も重たい処理を、OSのマルチタスク機能に完全に委ねつつ、親プロセスは最小限のメモリフットプリント(数MB程度)でそれらを統制する。
このような低レイヤーへの配慮こそが、新旧のテクノロジーを問わず、真のチーフアーキテクトに求められる「技術の支配力」である。レガシーを単にレガシーとして切り捨てるのではなく、その制限をハックし、極限の性能を引き出す知的興奮を、ぜひ現場のコードに反映させてほしい。
