【Access VBAの限界を超える】TimerIntervalとOnTimerで実装する「擬似非同期マルチスレッド」の極意
こんにちは。チーフアーキテクトの私だ。
日々の業務でAccessと向き合っている君なら、一度はこんな絶望を味わったことがあるはずだ。
「巨大なCSVファイルのインポート中に、画面が完全にフリーズする」
「重い集計クエリを走らせた瞬間、Accessが『応答なし』になり、ユーザーが強制終了してしまう」
無理もない。Access(VBA)の本質は「完全なシングルスレッド・イベント駆動型」だ。
ひとたび重い処理がメインスレッドを占有すれば、UIの描画も、ユーザーの操作も、他のイベントもすべてがその場で凍てつく。
だが、言い訳はそこまでだ。
「Accessだから仕方ない」と諦めるのは、オブジェクトモデルの挙動とイベントループの仕組みを理解していない者のセリフにすぎない。
今回は、Accessの制約を逆手に取り、`TimerInterval` と `OnTimer` イベントを駆使して「バックグラウンドでの擬似マルチスレッド処理(非同期タスクキュー)」を実現する極限のテクニックを伝授しよう。
—
なぜ「DoEvents」ではダメなのか?
素人プログラマがやりがちなのが、ループの中に `DoEvents` を挟むアプローチだ。
‘ ❌ やってはいけない典型的なアンチパターン
For i = 1 To 10000
‘ 重い処理
DoEvents ‘ OSに制御を戻すつもりだが…
Next i
`DoEvents` は一時的にOSへ制御を返し、画面の固まりを防ぐには有効だが、「再入可能性(Reentrancy)の悪夢」を引き起こす。
ユーザーが別のボタンを連打したり、処理が終わっていない段階でフォームを閉じようとしたりすると、メモリ違反や予期せぬ変数上書きによるクラッシュの温床となる。
プロのアーキテクトが目指すべきは、「処理を細切れのチャンク(断片)に分割し、タイマーイベントのループに委譲する」というステートマシン(状態機械)型の非同期アーキテクトニクスだ。
—
アーキテクチャ設計:非同期タスクキューの仕組み
今回構築する仕組みの肝は以下の通りだ。
1. タスクの分割(チャンク化): 一度にすべてをやらず、「100件ずつ処理する」「1ファイルずつ処理する」単位に分割する。
2. ステータス管理: 現在どのフェーズにいるのかをモジュールレベル変数(あるいはフォームのプロパティ)で保持する。
3. タイマーの動的制御: 処理すべきタスクがある時だけ `TimerInterval` を有効(例: `100` ms)にし、アイドル時は `0` にしてリソース消費をゼロにする。
これにより、UIの滑らかな描画を維持しながら、裏で粛々と重いバッチ処理を進めることが可能になる。
—
実装コード:プロダクション・レディな非同期処理フォーム
百聞は一見にしかず。実際の現場でそのまま使える、堅牢なクラス/フォームモジュールのコードを提示しよう。
このコードは、「巨大なテキストファイルをバックグラウンドで1行ずつ安全にDBへ取り込み、進捗をリアルタイムでプログレスバーに反映する」という実務要件を完璧に満たす。
フォームモジュール: `frmAsyncProcessor`
Option Compare Database
Option Explicit
‘ — 定数定義 —
Private Const TIMER_INTERVAL_RUNNING As Long = 50 # 処理中のタイマー間隔(ミリ秒)
Private Const TIMER_INTERVAL_IDLE As Long = 0 # 停止中のタイマー間隔
‘ — 状態管理用変数(ステート) —
Private m_FileNum As Integer
Private m_FilePath As String
Private m_TotalLines As Long
Private m_ProcessedLines As Long
Private m_IsProcessing As Boolean
”’
”’
Private Sub cmdStartAsync_Click()
If m_IsProcessing Then
MsgBox “既にバックグラウンド処理が実行中です。”, vbExclamation, “排他制御”
Exit Sub
End If
m_FilePath = “C:\Data\HugeImportData.csv”
If Dir(m_FilePath) = “” Then
MsgBox “対象ファイルが見つかりません: ” & m_FilePath, vbCritical
Exit Sub
End If
‘ 初期化
m_FileNum = FreeFile
Open m_FilePath For Input As #m_FileNum
m_TotalLines = CountTotalLines(m_FilePath) ‘ 総行数の算出(簡易的)
m_ProcessedLines = 0
m_IsProcessing = True
‘ UIのロック(操作制限)
Me.cmdStartAsync.Enabled = False
Me.cmdCancel.Enabled = True
Me.ProgressBar.Value = 0
‘ 【重要】タイマーを起動してイベントループに処理を委譲
Me.TimerInterval = TIMER_INTERVAL_RUNNING
Debug.Print “[Async] バックグラウンド処理を開始しました。”
End Sub
”’
”’
Private Sub Form_Timer()
On Error GoTo ErrorHandler
‘ 万が一、処理フラグが立っていない場合はタイマーを止める
If Not m_IsProcessing Then
Me.TimerInterval = TIMER_INTERVAL_IDLE
Exit Sub
End If
‘ 【極意】UIフリーズを防ぐため、1回のタイマーティックあたり「50行だけ」処理する(チャンク分割)
Dim i As Long
Dim lineData As String
For i = 1 To 50
If EOF(m_FileNum) Then
‘ — 終了処理 —
Close #m_FileNum
m_IsProcessing = False
Me.TimerInterval = TIMER_INTERVAL_IDLE
‘ UIの復元
Me.cmdStartAsync.Enabled = True
Me.cmdCancel.Enabled = False
Me.ProgressBar.Value = 100
MsgBox “バックグラウンド処理が正常に完了しました。”, vbInformation, “完了”
Exit Sub
End If
Line Input #m_FileNum, lineData
‘ ————————————————–
‘ ここに実際のデータ登録・加工ロジックを記述する
‘ 例: CurrentDb.Execute “INSERT INTO T_Log …”, dbFailOnError
‘ ————————————————–
m_ProcessedLines = m_ProcessedLines + 1
Next i
‘ — 進捗率の更新 —
If m_TotalLines > 0 Then
Dim currentPct As Integer
currentPct = CInt((m_ProcessedLines / m_TotalLines) 100)
If currentPct > 100 Then currentPct = 100
Me.ProgressBar.Value = currentPct
Me.lblStatus.Caption = “処理中… ” & m_ProcessedLines & ” / ” & m_TotalLines & ” 行 (” & currentPct & “%)”
End If
Exit Sub
ErrorHandler:
‘ 異常系ハンドリング:確実にファイルを閉じ、タイマーを停止させる
If m_FileNum > 0 Then Close #m_FileNum
m_IsProcessing = False
Me.TimerInterval = TIMER_INTERVAL_IDLE
Me.cmdStartAsync.Enabled = True
Me.cmdCancel.Enabled = False
MsgBox “予期せぬエラーが発生しました: ” & Err.Description, vbCritical, “致命的エラー”
End Sub
”’
”’
Private Sub cmdCancel_Click()
If Not m_IsProcessing Then Exit Sub
If MsgBox(“処理を中断しますか?”, vbQuestion + vbYesNo, “確認”) = vbYes Then
If m_FileNum > 0 Then Close #m_FileNum
m_IsProcessing = False
Me.TimerInterval = TIMER_INTERVAL_IDLE
Me.cmdStartAsync.Enabled = True
Me.cmdCancel.Enabled = False
Me.lblStatus.Caption = “ユーザーにより中断されました。”
Debug.Print “[Async] 処理が中断されました。”
End If
End Sub
”’
”’
Private Sub Form_Unload(Cancel As Integer)
If m_IsProcessing Then
If MsgBox(“バックグラウンドで処理が実行中です。フォームを閉じると処理が破棄されますがよろしいですか?”, vbCritical + vbYesNo, “警告”) = vbYes Then
If m_FileNum > 0 Then Close #m_FileNum
Else
Cancel = True
End If
End If
End Sub
”’
”’
Private Function CountTotalLines(filePath As String) As Long
Dim fNum As Integer: fNum = FreeFile
Dim cnt As Long: cnt = 0
Dim dummy As String
Open filePath For Input As #fNum
Do While Not EOF(fNum)
Line Input #fNum, dummy
cnt = cnt + 1
Loop
Close #fNum
CountTotalLines = cnt
End Function
—
プロダクション環境における重要注意点(アーキテクトの教訓)
このパターンを実際のマルチユーザー環境や巨大な社内システムに導入する際、以下の3点を怠ると必ず痛い目をみる。
1. `CurrentDb` のライフサイクルとトランザクション
ループ内で毎回 `CurrentDb.Execute` を発行する場合、Accessの内部キャッシュとトランザクションの肥大化に注意しろ。数万件のループを回す場合、適切なタイミングでトランザクションをコミットするか、あるいはADOのコネクションを適切に管理する必要がある。
また、`CurrentDb` は呼び出すたびにオブジェクトが新規生成されるため、パフォーマンスを極限まで高めたい場合はモジュールレベル変数にキャッシュしたDB変数を使うこと。
2. 排他制御と同時実行セッション
バックグラウンドでデータを書き込んでいる最中、ユーザーが別のフォームから同じレコードを更新しようとした場合、書き込み競合(Error 3186 / 3218など)が発生する。
非同期処理を行うバックグラウンドクエリは、極力ローカルのテンポラリテーブル(`LocalVars`や`TempVars`、あるいはローカル一時テーブル)に対して行い、最後に一括して本体のバックエンドDB(SQL Serverや共有MDB)にマージする設計(Staging Pattern)を採用するのがプロの定石だ。
3. エラーハンドリングと「ゾンビタイマー」の回避
万が一、`Form_Timer` 内で未処理のエラーが発生してサブルーチンがクラッシュした場合、`TimerInterval` が動いたままになり、Accessが不安定なゾンビ状態に陥ることがある。
上記のコード例のように、エラーハンドラ(ErrorHandler)の中で必ず `TimerInterval = 0` とファイルクローズを保証する構造を絶対に崩してはならない。
—
結びにかえて
Accessは、正しく飼いならせば、中小規模の業務システムにおいてこれほど素早く・強靭な価値を生み出すプラットフォームはない。
「シングルだから重い処理は無理だ」と匙を投げるのは簡単だ。だが、今回紹介した `TimerInterval` によるステートマシン制御をマスターすれば、UIの滑らかさを一切損なうことなく、裏で重厚なバッチ処理を完遂するプロフェッショナルなアプリケーションへと昇華させることができる。
ぜひ、君のプロジェクトの次のリファクタリングでこの知見を実装してみてほしい。圧倒的な体感速度の差に、ユーザーは驚愕するはずだ。
