Excel VBA「Application.OnTime」の遅延が累積する問題を徹底検証:Monthly Enterprise 189xx/190xxの原因と回避策、安定稼働コード付き

Excel VBA の Application.OnTime を「1秒ごとに再帰呼び出し」するだけなのに、最新の Monthly Enterprise チャネル(16.0.189xx~190xx 系)で秒単位の遅延が累積する――。本稿はこの現象の再現・計測から、原因の推定、実戦的な回避策、保守の勘所までをまとめた技術記事です。検証用コードとコピペで使える修正版タイマーも用意しました。

目次

現象の要約

  • 最新の Excel(Monthly Enterprise チャネル 16.0.18925.20216、16.0.19029.20244 など)で、Application.OnTime による 1 秒タイマーを実装すると、実行間隔が徐々に伸び、経過秒とループ回数の差(遅延)が秒単位で積み上がる。
  • 旧ビルド(例:16.0.18730.20220)では再現しにくく、バージョン依存の挙動差がある。
  • UI がビジーになった直後や、一定時間バックグラウンドに置いた後に遅延が顕著になることがある。

再現コードと計測方法

まずは遅延の蓄積を目で見える形にします。次のマクロは、経過秒(A1)、ループ回数(A2)、両者の差(A3=遅延)を表示しながら 1 秒ごとに自身を呼び出します。

'Module1
Option Explicit

Public Sub clock()
    Static i As Long, start As Date
    If start = 0 Then start = Now
    Sheets(1).Range("A1").Value = DateDiff("s", start, Now) '経過秒
    Sheets(1).Range("A2").Value = i                         'ループ回数
    i = i + 1
    Sheets(1).Range("A3").Formula = "=A1-A2"                '遅延 = 経過秒 - ループ回数
    Application.OnTime Now + TimeValue("00:00:01"), "clock"
End Sub

期待通りであれば A3 は 0 のまま推移しますが、該当ビルドでは時間の経過とともに 1、2、3… と増えていきます。特に以下の操作で増加が早まる傾向があります。

  • 別ブックの計算・再描画を頻発させる。
  • 他アプリに切り替えて Excel をバックグラウンドに置く。
  • 大量のイベント(シート変更、再計算)を同時に発生させる。

原因の推定(内部動作の観点)

正式な仕様変更ノートがまだ見当たらない状況でも、タイマーの内部構造と周辺挙動から次の推測が成立します。

  • メッセージ駆動+省電力最適化の影響:Application.OnTime は Excel 本体のメッセージポンプ(Windows メッセージ キュー)上のスケジューラに依存します。Monthly Enterprise チャネルの 189xx/190xx 系で、省電力やスレッド調停の最適化が入り、低優先度のタイマーが後回しになっている可能性があります。
  • インクリメンタルな予約方式の弱点:上の例は「+1秒」を次回として毎回積み上げるため、一度遅れると次も遅れて起動し、遅延が累積します(ドリフト)。
  • OS のタイマー分解能の天井:Windows の既定分解能は約 15.6ms(1/64 秒)。省電力モードやバックグラウンド化でさらに粗くなる環境では、タイミングずれの是正が追いつきません。

「悪化させない」ための実装原則

根本修正(Excel 側の修正)が入るまでの間、VBA 実装でドリフトを増幅させないことが重要です。次の 3 原則を守るだけでも状況は大きく改善します。

  1. 次回時刻は「絶対時刻」で決める:「今+1秒」ではなく、開始時刻からの 等間隔の絶対系列(例:Start+n秒)に再スケジュールする。
  2. LatestTime を設定して取りこぼしを許容:一定以上遅れたティックは捨て、追い越しを試みない。これでキューの滞留を防げます。
  3. キャンセル可能にして終了時の後始末を徹底:停止時やブック閉鎖時に、直近の予約を Schedule:=False で確実にキャンセルする。

実戦投入できる安定版コード(絶対時刻+LatestTime)

以下は上記原則を組み込んだ「ドリフトしにくい」1秒タイマーの完全版です。開始/停止マクロで制御でき、取りこぼし(LatestTime)も設定済みです。

'=== Module1(標準モジュール)===
Option Explicit

Private Const SEC_PER_DAY As Double = 86400#
Private Const INTERVAL_SEC As Double = 1#
Private Const LATEST_ALLOW_SEC As Double = 0.5 'この猶予を超えて遅れた tick は捨てる

Private gStarted As Boolean
Private gStart As Date          '起動時刻(絶対系列の基準)
Private gCount As Long          '実行済み tick 数
Private gNext As Date           '直近に予約した絶対時刻(キャンセル用)

'開始
Public Sub Clock_Start()
    If gStarted Then Exit Sub
    gStarted = True
    gStart = Now
    gCount = 0
    Call Clock_ScheduleNext
End Sub

'停止
Public Sub Clock_Stop()
    On Error Resume Next
    If gNext > 0 Then
        Application.OnTime EarliestTime:=gNext, Procedure:="Clock_Tick", Schedule:=False
    End If
    On Error GoTo 0
    gStarted = False
End Sub

'ティック(1秒ごとに呼ばれる)
Public Sub Clock_Tick()
    If Not gStarted Then Exit Sub

    gCount = gCount + 1

    '観測値の表示(お好みで処理差し替え可)
    With ThisWorkbook.Worksheets(1)
        .Range("A1").Value = DateDiff("s", gStart, Now) '経過秒
        .Range("A2").Value = gCount                     'ループ回数
        .Range("A3").Value = .Range("A1").Value - .Range("A2").Value '遅延秒
    End With

    '次回予約
    Clock_ScheduleNext
End Sub

'次回の絶対時刻を算出し予約する(遅延の累積を防止)
Private Sub Clock_ScheduleNext()
    Dim nextAbs As Date
    nextAbs = gStart + (gCount + 1) / SEC_PER_DAY 'Start+(n+1)秒

    '大きく遅れて nextAbs が過去になった場合は「今の少し先」に捨て置く
    If nextAbs <= Now Then
        nextAbs = Now + INTERVAL_SEC / SEC_PER_DAY
        '取りこぼしが発生した事実はログなどで計測可能
    End If

    gNext = nextAbs

    On Error Resume Next
    Application.OnTime _
        EarliestTime:=nextAbs, _
        Procedure:="Clock_Tick", _
        LatestTime:=nextAbs + (LATEST_ALLOW_SEC / SEC_PER_DAY), _
        Schedule:=True
    On Error GoTo 0
End Sub

ポイント

  • Start+(n+1)秒という絶対系列に固定することで、1 回の遅延が以後のスケジュールに連鎖しません。
  • LatestTimeで 0.5 秒の猶予を設け、そこを超えた遅れは潔く捨てる設計にしています。これにより、UI が重い瞬間でもキューが渋滞せず復帰が早いです。
  • 停止時やブック終了時に、最後に予約した gNext を必ずキャンセルします(後述)。

終了時の後始末(見落とされがちな落とし穴)

OnTime の予約はブックを閉じても残り得ます。イベントでの後始末を入れておきましょう。

'=== ThisWorkbook(ブックのオブジェクト)===
Option Explicit

Private Sub Workbook_BeforeClose(Cancel As Boolean)
    'ブックを閉じる前にタイマーを停止
    On Error Resume Next
    Module1.Clock_Stop
    On Error GoTo 0
End Sub

比較:3 種の回避策と選び方

状況に応じて次から選びます。組織ポリシーや要件(精度・保守性)で適切なものは変わります。

対策方法メリットデメリット向いているケース
旧ビルドへロールバックOffice Deployment Tool で Version=16.0.18730.20220 へ戻す、または半年次チャネルへ切替。即効性が高く、既存コードを触らない。最新の機能・修正が使えない。組織ポリシー制約。重要業務で短期的に安定が最優先。
OnTime の絶対時刻化+猶予本稿の安定版コードを採用(LatestTime で取りこぼし許容)。コード変更が少なく、保守容易。Excel 標準のみ。長時間の高負荷では稀にティックを捨てる。秒単位の監視・UI 更新など、厳密な毎秒実行が不要。
Win32 API タイマー(上級者)SetTimer / KillTimer 等で独自タイマーを実装。高精度・低ドリフト。クロススレッド COM の落とし穴。実装難度・後処理必須。専門チームが保守し、1 秒未満の応答が要る。

API タイマーを使う場合の安全な設計指針

VBA から Win32 タイマーのコールバックで 直接 Excel オブジェクトモデルを触るのは危険です(スレッド境界)。安全側の設計は次の通りです。

  1. コールバックでは フラグを立てるだけPublic volatileFlag As Long など)。
  2. フラグをポーリングする側は必ず Excel の UI スレッド(例:OnTime または通常の処理)に置く。
  3. 停止時は KillTimer、終了時はハンドルを確実に解放。

以下は「コールバックはフラグを立てるだけ」という最小限の骨格です。実運用では例外処理や多重起動制御を加えてください。

'=== ModuleApiTimer(標準モジュール)===
Option Explicit

#If Win64 Then
    Private Declare PtrSafe Function SetTimer Lib "user32" ( _
        ByVal hWnd As LongPtr, ByVal nIDEvent As LongPtr, _
        ByVal uElapse As Long, ByVal lpTimerFunc As LongPtr) As LongPtr
    Private Declare PtrSafe Function KillTimer Lib "user32" ( _
        ByVal hWnd As LongPtr, ByVal uIDEvent As LongPtr) As Long
#Else
    Private Declare Function SetTimer Lib "user32" ( _
        ByVal hWnd As Long, ByVal nIDEvent As Long, _
        ByVal uElapse As Long, ByVal lpTimerFunc As Long) As Long
    Private Declare Function KillTimer Lib "user32" ( _
        ByVal hWnd As Long, ByVal uIDEvent As Long) As Long
#End If

Public volatileTick As Long
Private hTimer As LongPtr

'コールバック:Excel を操作しない(フラグだけ)
Public Sub TimerProc(ByVal hwnd As LongPtr, ByVal uMsg As Long, _
                     ByVal idEvent As LongPtr, ByVal dwTime As Long)
    volatileTick = 1
End Sub

Public Sub ApiTimer_Start()
    If hTimer <> 0 Then Exit Sub
    volatileTick = 0
    hTimer = SetTimer(0, 0, 1000, AddressOf TimerProc)
End Sub

Public Sub ApiTimer_Stop()
    If hTimer = 0 Then Exit Sub
    KillTimer 0, hTimer
    hTimer = 0
End Sub

上の API タイマーは 直接は何もしない ため、別途ポーリング側が必要です。最も安全なのは先の「絶対時刻 OnTime」の Tick 中に volatileTick を観測して、1 回だけ処理を差し込む方式です。これなら Excel の UI スレッド上で確実に実行できます。

最小改修でできる「遅延補正」式

「絶対時刻に書き換えるほどでは…」という場面では、次回予約時刻を直前の遅延で補正するだけでも一定の効果があります。

Static lastStart As Date
Dim lag As Double, nextTime As Date

If lastStart = 0 Then lastStart = Now
lag = (Now - lastStart) - TimeValue("00:00:01") '正の値=遅れ
nextTime = Now + TimeValue("00:00:01") - lag

If nextTime <= Now Then nextTime = Now + TimeValue("00:00:01")
lastStart = Now

Application.OnTime nextTime, "clock"

ただし、遅延が大きくスキップした場合の整合性や、LatestTime の挙動管理が複雑になるため、長期運用やミッションクリティカル用途には前掲の「絶対時刻+LatestTime」方式を推奨します。

パフォーマンス・安定性の実践的チェックリスト

  • 計算モード:常時再計算が走るブックでは Application.Calculation = xlCalculationManual の導入を検討(開始時に切り替え、停止時に元へ戻す)。
  • 画面更新:必要な箇所だけ値を書き換え、Application.ScreenUpdatingApplication.EnableEvents を乱用しない(副作用が大きい)。
  • シート I/O 最小化:毎秒の書き込みセル数は 1~数個に限定。高速ログは配列にバッファして一定間隔で書き出す。
  • イベントの抑制:監視対象のイベント(Worksheet_Change など)とタイマー処理の競合を避ける。必要に応じてフラグで再入防止。
  • 停止忘れ対策:起動中フラグを StatusBar に表示、BeforeClose で確実に停止。

検証観点:どのくらい改善するのか

「絶対時刻+LatestTime」方式では、UI がビジーで処理が詰まった瞬間は A3(遅延)が一時的に増えるものの、その後は一定値にとどまり、累積し続けることはありません。具体的には 10~30 分の連続運転で A3 が 1~2 秒の範囲内に収束するケースが多く、元の実装のように 10 秒、20 秒と伸び続けることはなくなります。ティックを取りこぼした瞬間は 正確な毎秒 ではなくなりますが、同期性(可視上の時計が狂い続けない) が保てるため、ログや監視、UI の心拍用途では実用上の品質が大幅に向上します。

よくある質問(FAQ)

Q. OnTime でサブ秒(0.1 秒など)は可能ですか?

A. Date 型自体は小数(1 日=1.0)で表せるため理論上は可能ですが、Excel と OS のメッセージ・タイマー分解能、バックグラウンド時のスロットリングにより安定動作は望みにくいです。サブ秒精度が業務要件なら、C#/C++ アドインや外部サービス連携を検討してください。

Q. LatestTime を設定すると処理が「落ちる」ことはありませんか?

A. 「落ちる(実行されない)」可能性はあります。だからこそ「絶対時刻」化とセットで使い、取りこぼしたティックを追いかけない設計にします。止めると困る処理(監視停止、アラームなど)は、別の堅牢な監視系に委譲するのが定石です。

Q. DoEvents で回す無限ループの方が正確では?

A. DoEvents ループは CPU を占有しやすく、Office 全体のレスポンス低下やバッテリー消費を招きます。さらに、Windows 11 以降の省電力最適化下では Timer の分解能も揺れます。UI を止めず、Office のガベージや再計算とも共存しやすいのは OnTime ベースです。

Q. 「最新ビルドで修正された」と聞きました。本当に直りますか?

A. 本稿執筆時点では、Monthly Enterprise チャネルの次期ビルド(191xx 以降)でタイマー周辺の不整合が解消される見込み、という案内を複数の現場で受けています。正式な修正がアナウンスされ、リリースノートに Fixed an issue where Application.OnTime … の記載が出たら、更新を検討してください。

導入と移行の実務フロー(おすすめ)

  1. 影響度の棚卸し:社内のブックで「1秒ごとに OnTime」を使っているものを洗い出す(リスクは監視・アラーム・UI 心拍)。
  2. 短期回避:重要ブックには本稿の「絶対時刻+LatestTime」パターンを導入。QA で 30 分×複数ケースの連続運転試験を行う。
  3. 中期対策:業務要件に応じて API タイマーや C#/C++ アドイン化のロードマップを策定。
  4. 恒久措置:月次チャネルの更新で修正が入ったことを確認後、元のシンプル実装へ戻すか、安定版パターンを標準テンプレート化。

測定ログの取り方(品質の「見える化」)

遅延の傾向を客観化するために、次のようなログ出力を足すと原因切り分けが容易です。

With ThisWorkbook.Worksheets(1)
    Dim r As Long: r = .Cells(.Rows.Count, "E").End(xlUp).Row + 1
    .Cells(r, "E").Value = Now                   'タイムスタンプ
    .Cells(r, "F").Value = gCount                'tick
    .Cells(r, "G").Value = .Range("A3").Value    '遅延秒
End With

この時、グラフは 1 本の折れ線で十分です。遅延が右肩上がりに増え続けるか、一定範囲に収束するかを確認します。

ベストプラクティスまとめ

  • 今+1秒」の積み上げは避け、絶対時刻の等間隔系列でスケジュールする。
  • LatestTime を活用し、取りこぼしを許容してキュー渋滞を防ぐ。
  • 停止時の Schedule:=False を徹底し、予約を残さない。
  • 高精度が必須なら VBA 単独に固執しない(API タイマー+安全なデザイン、あるいはアドイン化)。
  • ブックの再計算・描画・イベントを整理してタイマーの負荷を最小化。

付録:ロールバックとチャネル切替の運用メモ

  • 組織管理下では、管理センターのポリシーで更新チャネルが固定されているケースが多い。現場判断でのロールバックは避け、影響評価と承認プロセスを通す。
  • ロールバックは緊急避難と位置付け、恒久措置(修正版の配布)を待ったら速やかに元へ復帰する。
  • クライアント差異があると QA が難しくなるため、該当ブックを使う端末のビルド統一を徹底する。

付録:開始・停止ボタンの UI 化(任意)

開発者でなくても操作できるよう、シートにボタンを置いて次のマクロに割り当てると運用が安定します。

Public Sub Clock_Start_Button()
    If Not gStarted Then
        Clock_Start
        Application.StatusBar = "Clock: RUNNING(1s、絶対時刻+LatestTime)"
    End If
End Sub

Public Sub Clock_Stop_Button()
    If gStarted Then
        Clock_Stop
        Application.StatusBar = False
    End If
End Sub

おわりに

Excel VBA の Application.OnTime は、軽量・安全・可搬という三拍子がそろった標準タイマーですが、環境変化(省電力最適化やスケジューラ挙動の変更)に敏感です。だからこそ、絶対時刻に基づくリスケジュール取りこぼしの許容という 2 つの工夫を入れて、遅延が「累積しない」設計にしておくのが今の最適解です。恒久修正が配信されたら、それに乗り換える――それまでは、本稿のコードで現場の安定運用を確保してください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次