【実務中級者権限】複数バッチの同時実行時における同一ログファイルへのFSO競合回避と簡易ロック機構の実装
レガシーシステムの維持において、VBScriptによるバッチ処理の並行稼働は日常茶飯事である。
Windowsタスクスケジューラーから複数のジョブが寸分の狂いもなく同時に起動され、それらが悉く同一の共通ログファイルへ書き込みを試みる――。結果はどうなるか。
`エラー 70: 書き込みできません (Permission Denied)`
この悪夢のような例外に直面し、場当たり的な`On Error Resume Next`でエラーを握りつぶした挙句、ログが完全に破損・欠損した経験を持つエンジニアは少なくないはずだ。
FileSystemObject(FSO)を用いたファイル操作は極めて直感的だが、OSレベルの排他制御(File Sharing)を隠蔽しているがゆえに、マルチプロセス環境下では無力な素人玩具と化す。
今回は、外部の重厚なサードパーティ製ライブラリに頼らず、純粋なVBScriptのプリミティブとファイルシステム自体の特性をハックすることで、「軽量かつ確実な排他ロック機構(Mutex)」を実装する極限の知見を授ける。
—
1. なぜFSOの `.OpenAsTextStream` はマルチプロセスで破綻するのか
VBScriptでファイルに追記を行う際、多くのエンジニアは以下のようなコードを書く。
‘ 【アンチパターン】絶対に真似してはならない実装
Dim fso, ts
Set fso = CreateObject(“Scripting.FileSystemObject”)
‘ ForAppending (8) で開くが、他プロセスが掴んでいると即座に例外発生
Set ts = fso.OpenAsTextStream(LogFilePath, 8)
ts.WriteLine “Log message”
ts.Close
FSOの仕様上、`OpenAsTextStream` や `CreateTextFile` は、OSの `CreateFile` APIを特定の共有モード(通常は排他、あるいは限定的な読み取り共有)で呼び出す。
複数プロセスがほぼ同時にこのメソッドを叩いた場合、後続のプロセスは容赦なく弾かれる。OSの競合解決を待つようなリトライ機能は、FSOの内部には存在しないのだ。
ここで必要なのは、「書き込み権の獲得(ロック取得)」と「実際の書き込み」を完全に分離し、アトミック(不可分)な操作としてエミュレートする仕組みである。
—
2. ファイルシステムをハックする「アトミック・ロックファイル方式」
マルチプロセス環境における排他制御の基本は、「不可分な作成操作(Atomic Creation)」の利用にある。
Windowsのファイルシステム(NTFS)において、「特定の名前のファイルが存在しない場合のみ、排他作成する」という操作は、OSカーネルレベルでアトミックであることが保証されている。
これを利用し、本稿では以下の戦略をとる。
1. ロックファイルの生成: メインログファイルとは別に、`log.txt.lock` という空ファイルを排他モードで作成を試みる。
2. 競合のハンドリング: 作成に成功した場合のみ、ログファイルへの書き込み権利(クリティカルセクション)を得る。失敗した場合は、一定時間スリープしてリトライする。
3. 確実な解放: 処理完了後、あるいは異常終了時であっても、必ずロックファイルを削除する。
これをVBScript上で極限まで最適化し、メモリリークの温床となるオブジェクトの残骸を残さないアーキテクチャとして構築する。
—
3. 実装コード:堅牢な排他ログ書き込みクラス
以下のコードは、実務の現場でそのままコピー&ペーストして利用できる、クラス構造化された排他ログ書き込みモジュールである。
オブジェクトのライフサイクル管理、WScript.Sleepによるバックオフ制御、そしてレガシー環境特有のエラーハンドリングのベストプラクティスが凝縮されている。
‘ ==============================================================================
‘ File: LoggerWithLock.vbs
‘ Description: FSOの競合を回避し、複数プロセスから安全に単一ログへ追記するクラス
‘ ==============================================================================
Option Explicit
Class ThreadSafeLogger
Private m_Fso
Private m_LogPath
Private m_LockPath
Private m_TimeoutSec
‘ コンストラクタ
Private Sub Class_Initialize()
Set m_Fso = CreateObject(“Scripting.FileSystemObject”)
m_TimeoutSec = 10 ‘ ロック取得タイムアウト(秒)
End Sub
‘ デストラクタ(メモリの確実な解放)
Private Sub Class_Terminate()
Set m_Fso = Nothing
End Sub
‘ 初期設定(ログパスの指定)
Public Sub Initialize(ByVal logFilePath)
m_LogPath = logFilePath
m_LockPath = logFilePath & “.lock”
End Sub
‘ ログ書き込みメインメソッド
Public Sub WriteLog(ByVal message)
Dim acquired
acquired = AcquireLock()
If Not acquired Then
‘ タイムアウト時は強制スキップするか、イベントログ等に吐く設計にする
‘ ここでは標準エラー出力に逃がす
WScript.StdErr.WriteLine “[” & Now & “] ERROR: Log lock timeout. Message lost: ” & message
Exit Sub
End If
On Error Resume Next
‘ — クリティカルセクション(ここから) —
Dim ts
‘ 追記モード (ForAppending = 8, Create = True)
Set ts = m_Fso.OpenAsTextStream(m_LogPath, 8)
If Err.Number = 0 Then
ts.WriteLine “[” & Now & “] ” & message
ts.Close
End If
Set ts = Nothing
‘ — クリティカルセクション(ここまで) —
On Error GoTo 0
‘ ロック解放
Call ReleaseLock()
End Sub
‘ 排他ロックの取得(ビジーウェイト + 指数バックオフ的アプローチ)
Private Function AcquireLock()
Dim startTime, waitInterval
startTime = Timer
waitInterval = 50 ‘ 初期ウェイト 50ms
Do
On Error Resume Next
‘ TextFileの作成 (True = 上書き許可だが、存在しない場合のみ作成したいので別の方法を使う)
‘ ここがポイント:FSOのCreateTextFileの第2引数(Overwrite)をFalseにすると、
‘ ファイルが存在する場合にエラー 58 (File already exists) が発生する。
Dim lockFile
Set lockFile = m_Fso.CreateTextFile(m_LockPath, False)
If Err.Number = 0 Then
‘ ロックファイル作成成功=排他権獲得
lockFile.Close
Set lockFile = Nothing
AcquireLock = True
On Error GoTo 0
Exit Function
End If
On Error GoTo 0
‘ タイムアウト判定
If (Timer – startTime) > m_TimeoutSec Then
AcquireLock = False
Exit Function
End If
‘ 乱数を含めた待機(プロセス同士の競合(Thundering Herd問題)を緩和)
‘ VBScriptにはRndがあるが初期化が必要。簡易的に固定値+微小ウェイトとする
WScript.Sleep waitInterval
‘ ウェイト時間を少しずつ伸ばす(最大500ms)
If waitInterval < 500 Then
waitInterval = waitInterval + 50
End If
Loop
End Function
' ロックの解放
Private Sub ReleaseLock()
On Error Resume Next
If m_Fso.FileExists(m_LockPath) Then
m_Fso.DeleteFile m_LockPath, True
End If
On Error GoTo 0
End Sub
End Class
' ==============================================================================
' 使用例 (Sample Usage)
' ==============================================================================
Dim logger
Set logger = New ThreadSafeLogger
logger.Initialize "C:\Logs\BatchExecution.log"
' 複数プロセスから同時実行されたと仮定してログ出力
logger.WriteLog "バッチプロセス " & WScript.ScriptFullName & " が処理を開始しました。"
Set logger = Nothing
---
4. チーフアーキテクトが解説する「死角のない」実装の要点
このスクリプトは、単なる「動くコード」を超えた設計思想に基づいている。レガシー環境特有の罠を回避するための極意を以下に記す。
① `CreateTextFile(path, False)` によるアトミック性
FSOの `CreateTextFile` メソッドの第2引数(Overwrite)に `False` を指定すると、対象ファイルが既に存在する場合、即座にランタイムエラー(エラー58)が発生する。
このOSレベルの排他エラーを逆手に取り、「エラーが出なかった=自分が最初にロックファイルを生成した」という判定に利用している。これが本質的な排他制御の肝である。
② Thundering Herd(群れをなす獣)問題の回避とバックオフ
複数プロセスが一斉にロック獲得を狙うと、CPU資源がロックファイルの作成・削除のポーリングだけで浪費される(ビジーウェイト)。
これを防ぐため、ループ内に `WScript.Sleep` を挟み、さらにループを回すごとにウェイト時間を増加(バックオフ)させている。これにより、OSやストレージ(HDD/SSD)への無駄なI/O負荷を劇的に軽減させている。
③ デッドロックの防止(タイムアウト機構)
もし、ロックファイルを作成したプロセスが、書き込み途中でOSの強制終了や予期せぬクラッシュを起こした場合、ロックファイルが遺棄され、後続のすべてのバッチが永久にフリーズ(デッドロック)する危険性がある。
本実装では `Timer` 関数を用いた経過時間計測(`m_TimeoutSec = 10`)を導入しており、万が一の孤立状態に陥っても、一定時間経過後に強制離脱(あるいはエラー吐き出し)できる安全弁を備えている。
(※より厳密には、ロックファイル内にプロセスIDやタイムスタンプを書き込み、古すぎるロックファイルを強制削除するセーフティネットを追加すると完璧になる)
④ メモリ最適化とVBScriptの闇
VBScriptの COMコンポーネント(特にFSO)は、インスタンスのスコープが消滅するまでメモリにしがみつく傾向がある。
クラス内であっても、利用が終わった `TextStream` オブジェクトや `File` オブジェクトは明示的に `Set xxx = Nothing` を行い、さらに `Class_Terminate` で大元の `m_Fso` を完全に破棄している。
長期間稼働するタスクスケジューラー配下のバッチ群において、メモリリークはジワジワとサーバーを死に至らしめる毒薬である。この作法を怠ってはならない。
—
総括
VBScriptはレガシーな言語と揶揄されるが、Windowsの根幹を知り尽くしたエンジニアが手を入れることで、モダンなシステムにも引けを取らない堅牢なバックグラウンド処理基盤へと昇華させることができる。
「動けばいい」という妥協を捨て、プロセス間の調停とリソースのライフサイクルを完全に掌握すること。それこそが、真のインフラストラクチャー・エンジニアの矜持である。
