2024/03/14

処理を一定時間停止させる




表示を点滅させたり一定時間で自動的にメッセージを閉じたりする時、またユーザーの入力待ちの時には、プログラムを一定時間停止させる必要があります。一時停止の手段として一般的にWaitやSleepが良く使われますが、それらを含めて以下4つの方法があると思います。
 ・Waitメソッドで、指定時間に再開させる
 ・Do~Loopで、指定時間まで滞留させる
 ・Sleep関数(Win32API)で、一定時間停止させる
 ・OnTimeメソッドで、一定時間後に別プロシージャを実行する
また同じ手段でも、取得した時間の分解能で実停止時間のバラツキ等が変わってきます。時間取得方法には複数種類がありますが、詳細については「日時や時間の取得」を参照下さい。今回はその内の一部を使って停止時間を制御しますが、他の手法でも可能です。
なお「サンプルファイル」では、ここで紹介したコードをシート上に配置したボタンから実行できるようにしています。
関数等の種類名+ボタン表面文字=プロシージャ名としてあります。
サンプルファイル上でのコードの実行。
図01

1.Waitメソッドで一定時間停止

1-1.Waitメソッドの特性等

ExcelのAplicationオブジェクトのWaitメソッドは、引数に指定した時刻まで停止(=指定した時刻に再開)するものです。
構文としては、以下になります。
 Excel.Application.Wait(Time)
ExcelのVBAから実行するのであれば、先頭の「Excel.」は不要になります。
引数のTime(再開時刻)はVariant型で、文字列で時刻を指定する方法と、日付値で指定する方法とに分かれます。
まず文字列として指定する方法です。
「Application.Wait "12:34:56"」と文字列で時刻を指定すれば、指定した時刻に再開して、次行のコード行に移ります。
また時刻の表記法を変え、「Application.Wait "12時34分56秒"」や「Application.Wait "0:34:56 PM"」でもOKです。
なお「Application.Wait "12:34:67"」のように間違えても、Excel側で勝手に「12:35:07」と解釈してくるようです。
指定する時刻は「"12:34:56"」のように秒単位とMicrosoftでも説明していますが、試してみるとミリ秒の指定もできます。つまり「"12:34:56.789"」のように指定すれば、ミリ秒で制御された再開時間になります。
寄り道(ミリ秒の文字列指定)
この「ミリ秒を文字列として指定」できる事は、他のサイトではあまり触れていません。しかし他サイトでも、シリアル値を使う方法としては「Application.Wait Evaluate("now()") + 1 / 86400」のようにEvaluateメソッドを使うと「ミリ秒制御が可能」との説明が行われています。
考えてみるとWaitメソッドの引数は、「文字列の時刻」と「シリアル値(数値)の時刻」のどちらもOKなのだから、シリアル値でミリ秒が可能 →「文字列でもミリ秒が可能」と考えた次第です。
Waitメソッドで時刻を直値指定する事は少ないと思いますが、機能を比較して試してみると面白い発見があるかもしれません。

Waitメソッドの引数は「時刻データ」とMicrosoftでも説明していますが、日付も含めて指定する事が可能です。例えば「Application.Wait "2024/03/01 12:34:56"」のようにすれば「指定日付+指定時刻に再開」することが出来ます。
しかし、日付も含めて指定した場合と時刻指定のみでは、Waitメソッドの動きは以下の様に異なります。
実行時Waitの引数
時刻のみ(A)
(シリアル値=1未満)
日付+時刻(A)
(シリアル値=1以上)
Aより前A時に再開A時に再開
Aより後翌日のA時に再開即時再開
図02

Waitに指定したのが時刻のみで、例えば10:00を指定した場合、現在が10:00前なら「10:00に再開」し、10:00を過ぎていれば「翌日の10:00に再開」することになります。
一方Waitに指定したのが日付+時刻で、例えば2024/02/01の10:00を指定した場合、現在が2024/02/01の10:00より前なら「2024/02/01の10:00に再開」し、過ぎていれば「即時に再開」することになります。
なお、この関係は日付値(数値)でも成立します。図02の表中にも記載しましたが、時刻のみを指定するとシリアル値は1未満となり、日付を含めるとシリアル値が1以上となります。
日付値(数値)を指定する方法は、色々考えられます。
もっとも一般的なのは「Application.Wait Now() + TimeValue("0:00:01")」のようにNow関数を使用するものです。実行するとNow関数の部分で「現在の日時」が得られ、その値に対しTimeValueで秒数などを加えるため、「現日時から指定時間後に再開(=指定した時間だけ停止)」という動きになります。
Now関数では無く、Nowの時刻部分のみの「Time関数」を使い「Application.Wait time() + TimeValue("0:00:01")」としてもほぼ問題ありません。但し24時近くに実行し「time() + TimeValue("0:00:01")」のシリアル合計値が1を超してしまうと、日付も指定した事になってしまいます。シリアル値の1は 1899年12月31日(「日付の基準値」参照 )ですので、大昔の日付を指定したという事になってしまい、Waitで停止することなく即時に次行に移ります。
またTimeValueを使わず「数値の1=1日」というシリアル値のルールを使い、停止秒数を「60秒 × 60分 × 24時間 = 86,400」で除算して「Application.Wait Now() + 1 / 86400」とする方法も可です。
またVBA関数を使い「Application.Wait DateAdd("s", 1, Now())」と、現日時に停止時間を加算する式でもOKです。
絶対時刻を指定する方法としては「Application.Wait TimeSerial(12, 34, 56)」のようにTimeSerial関数を使用する手法もあります。この方法でも例え「TimeSerial(12, 34, 67)」となっても「12:35:07」と勝手に変換してくれます。
なおWaitメソッドでは一時停止している間に「ESCキー」を押すと、再開時刻が来ていなくても再開し次の行に移行します。
以下では「1秒間停止」させるコードを紹介します。

1-2.実停止時間の測定について

停止させる時間として、Waitメソッドをはじめ他の関数等にも「1秒」を指定するのですが、実際に停止している時間が1秒とは限りません。その停止している実時間を調べるために、今回はWin32APIのQueryPerformanceCounter関数を使用します。この関数はWindowsが起動してからのカウント数を戻す関数で、今のところ最も精度良く時間が測れると思われるものです。
このQueryPerformanceCounter関数、及びQueryPerformanceFrequency関数(秒あたりのカウント数)の宣言を図03のように宣言部で行っておきます。
  1. '========== ⇩(1) Win32API関数の宣言 ============
  2. #If Win64 Then
  3.  Declare PtrSafe Function QueryPerformanceFrequency Lib "kernel32" (lpFrequency As Currency) As Long
  4.  Declare PtrSafe Function QueryPerformanceCounter Lib "kernel32" (lpPerformanceCount As Currency) As Long
  5. #Else
  6.  Declare Function QueryPerformanceFrequency Lib "kernel32" (frequency As Currency) As Long
  7.  Declare Function QueryPerformanceCounter Lib "kernel32" (procTime As Currency) As Long
  8. #End If
図03

図03では、データを受け取る引数(lpFrequency、およびlpPerformanceCount)のデータ型をCurrencyとしました。このCurrency型は64ビット(8バイト)の小数点4桁の固定小数点型ですが内部的には整数として管理されているようなので、カウント数のように大きな整数を受け取るのには適していると判断しました。
QueryPerformanceCounterの詳細については「日時や時間の取得」を参照下さい。

1-3.秒単位での停止制御

秒単位の制御をするため、ここではNow関数とTimeValue関数を使っています。
  1. '========== ⇩(2) 秒単位での停止制御 ============
  2. Sub Wait_Second()
  3.  Dim CntStart As Currency   '←カウント数(実行直前)
  4.  Dim CntEnd As Currency    '←カウント数(実行直後)
  5.  Dim freq As Currency     '←周波数(カウント数/秒)
  6.  Call QueryPerformanceFrequency(freq)
  7.  Call QueryPerformanceCounter(CntStart)
  8.  Application.Wait Now() + TimeValue("0:00:01")
  9.  Call QueryPerformanceCounter(CntEnd)
  10.  MsgBox (CntEnd - CntStart) / freq
  11. End Sub
図04

図04の中で本体は29行目「Application.Wait Now() + TimeValue("0:00:01")」です。Waitメソッドの引数として「Now() + TimeValue("0:00:01")」を指定することで「Waitメソッドを実行した時から1秒間停止させ、その後再開」させます。
Now関数もTimeValue関数もDate型の日付値を戻しますので、合計値もDate型となります。
そのWaitメソッドで停止している実時間を測るため、直前の27行目「Call QueryPerformanceCounter(CntStart)」で開始時刻(ここではカウント数)を測り、直後の31行目「Call QueryPerformanceCounter(CntEnd)」で終了時刻(ここではカウント数)を測ります。
1秒間のカウント数は26行目「Call QueryPerformanceFrequency(freq)」で取得しておき、最後に33行目「MsgBox (CntEnd - CntStart) / freq」で、Waitメソッドで停止した実際の時間をメッセージとして表示します。
秒単位の制御を行う手法として、今回の「Now関数+TimeValue関数」を使う以外では、以下のような方法も考えられます。
 ① Application.Wait Now() + 1 / 86400
 ② Application.Wait DateAdd("s", 1, Now())
 ③ Application.Wait Time() + TimeValue("0:00:01")
①はTimeValue関数の戻り値を直接分数値とする方法です。「1日 = 24時間 × 60分 × 60秒 = 86,400秒」という計算です。
②はDateAdd関数を用いて、1秒後のシリアル値を計算しています。
③はNow関数の代わりにTime関数を用いていますが、24時直前に使うと即時再開してしまうデメリットがあります。
図04を50回繰り返して得たデータが図05になります。
Waitメソッド+Now関数での実時間分布
図05

Date型の分解能は1秒で、秒数が変わる直前にWaitメソッドが実行された場合は「実時間は約ゼロ秒」、変わった直後に実行された場合は「実時間は約1秒」になる事は予測されます。図05では、データ数の不足の為かバラバラ感がありますが、平均値は予測に近い値(半分の0.5秒)になります。注目は標準偏差で、0.29と非常に大きなバラツキがあると言えます。

1-4.ミリ秒単位で制御

ミリ秒単位で制御する方法も複数の方法が考えられます。ここではワークシートのNOW関数とTimeValue関数を使って制御します。
  1. '========== ⇩(3) ミリ秒単位での停止制御 ============
  2. Sub Wait_mmSecond()
  3.  Dim CntStart As Currency   '←カウント数(実行直前)
  4.  Dim CntEnd As Currency    '←カウント数(実行直後)
  5.  Dim freq As Currency     '←周波数(カウント数/秒)
  6.  Call QueryPerformanceFrequency(freq)
  7.  Call QueryPerformanceCounter(CntStart)
  8.  Application.Wait Evaluate("now()") + CDbl(TimeValue("0:00:01"))
  9.  Call QueryPerformanceCounter(CntEnd)
  10.  MsgBox (CntEnd - CntStart) / freq
  11. End Sub
図06

49行目「Application.Wait Evaluate("now()") + CDbl(TimeValue("0:00:01"))」では、Waitメソッドで1秒間停止させています。
引数の内「Evaluate("now()")」は、ミリ秒の分解能がある「ワークシートのNOW関数」をEvaluateで呼び出しています。この戻り値のデータ型はDouble型です。
停止時間の部分である「CDbl(TimeValue("0:00:01"))」は、図04と同じくTimeValue関数で1秒を表しているのですが、ここで問題が生じます。TimeValue関数の戻り値はDate型なので、TimeValueをそのまま使い「Evaluate("now()") + TimeValue("0:00:01")」としてしまうと「Double型 + Date型 = Date型」となり、せっかくミリ秒の現在日時を取得したのに、元の秒単位に戻ってしまうのです。
そのため、TimeValue値をDouble型にし「Double型 + Double型 = Double型」とし、ミリ秒の分解能を確保しています。
この他には、以下のような指定方法もあります。
 ① Application.Wait Evaluate("now()") + 1 / 86400
 ② Application.Wait Evaluate("now()") + [TimeValue("0:00:01")]
 ③ Application.Wait (Timer() + 1) / 86400
①は、TimeValue関数の戻り値を直接分数値とする方法です。分数ならDouble型となります。
②は、ミリ秒が得られるワークシートのTIMEVALUE関数を使う方法です。Evaluateを使っても同じです。
③は、ミリ秒が得られるTimer関数を使います。Timer関数は秒単位の値なので「60秒×60分×24時間=86400」で除算します。
図06のコードで得られるデータの分布は図07のようになります。
Waitメソッド+Evaluate_Now関数での実時間分布
図07

グラフの通り「1 ± 0.015 秒」の範囲にほとんどのデータが入る感じです。しかし、Evaluate("now()")の分解能は「約15ミリ秒」なので、理論的には「1-0.015 ~ 1+0」となりそうですが、上限側に15ミリ秒超はみ出しています。
寄り道(再開が遅れる理由)
この上限側に「はみ出す」理屈を少し考えてみたのが図08です。(間違っていたらごめんなさい)
Waitメソッド+Evaluate_Now関数での値がバラつく理屈
図08

図08の左側は基本的な考え方です。まず時間を取得するタイミング(時間の分解能)を約15ミリ秒とした時、Waitメソッドを実行するが①のタイミングの場合は0秒をa秒だけ過ぎていますが、取得する現在時間としては0秒です。その0秒に停止時間の1秒を加算し1秒の時点で再開しますが、実質停止していた時間は「1-a 秒」となります。
②の場合は次の日時取得タイミングの直前ですが、取得する現在時間としてはやはり0秒です。計算上の1秒後に再開しますが、実質停止していた時間は「1-b 秒」となります。
つまり理論的には、実停止時間は「1-0.015 ~ 1+0」になるはずです。
図08の右図説明の前に、図06の49行目の再開する1秒後の日時「Evaluate("now()") + CDbl(TimeValue("0:00:01"))」を詳しく見てみます。
例えば現在の時刻が「0:01:00」ジャストだったとします。その現在時刻に1秒を加算するのですから、再開時間は「0:01:01」となります。Date型ではどこにも問題は無いのですが、日付の元となっているDouble型(8バイト)で値を比較したのが以下になります。
 [TimeValue("0:01:00")] + CDbl(TimeValue("0:00:01")) = 7.06018518518519E-04
 [TimeValue("0:01:01")] = 7.06018518518518E-04
本来は同じ値になりそうですが、上段と下段では最小桁の値が変わっています。原因はDouble型とDouble型の加算時に最小桁位置の1つ下で丸めの計算(≒四捨五入)が発生している為と考えられます。
このため二つの式をイコールで結ぶと「False(同じでは無い)」となります。
 ( [TimeValue("0:01:00")] + CDbl(TimeValue("0:00:01")) = [TimeValue("0:01:01")] ) = False
このため、本来の再開するはずの位置では「指定の時間に達していないと判断」されて再開せず、一つ遅れたタイミングで再開するため「上限側にはみ出る」のでは と考えています。
但し全ての時刻の足し残がFalseになる訳では無く、ざっと調べたところ2割位がFalseとなりそうです。また1秒を足した式の方が必ず大きくなる訳でも無く、小さくなる場合もあるので、その2割全てが再開遅れになる訳でも無さそうです。
それなのに上限側にはみ出しているのが半数もあるのは、これだけが原因では無く「Waitメソッドの停止精度の限界」で説明するように、Waitメソッドが10ミリ秒毎に時間を測っている(推定)事も関係している気がします。
なお、ここでは1秒をDouble型に変換してから足していますが、足した後で全体をDouble型に変換しても同じ値になります。

1-5.タイマー精度を変更して制御

Windowsでは、Windows上のアプリやアプリ内の各動作を細かな時間で切り替えながら動かす仕組みとなっており、その切り替え時間をタイマーと呼びます。上記のEvaluate("Now()")なども、そのタイマーの間隔で時間データを取得しています。
標準のタイマーは約15ミリ秒なので、Evaluate("Now()")などの分解能も約15ミリ秒となるのですが、timeBeginPeriod関数を使用するとタイマー精度を変更できます。タイマー精度は最高で1ミリ秒まで向上できます、
下記ではタイマーを1ミリ秒に向上させた上でワークシートのNOW関数で停止時間を制御しています。
  1. '========== ⇩(4) Win32APIの宣言 ============
  2. #If Win64 Then
  3.  Declare PtrSafe Function timeBeginPeriod Lib "winmm.dll" (ByVal uPeriod As LongPtr) As LongPtr
  4.  Declare PtrSafe Function timeEndPeriod Lib "winmm.dll" (ByVal uPeriod As LongPtr) As LongPtr
  5. #Else
  6.  Declare Function timeBeginPeriod Lib "winmm.dll" (ByVal uPeriod As Long) As Long
  7.  Declare Function timeEndPeriod Lib "winmm.dll" (ByVal uPeriod As Long) As Long
  8. #End If
  9. '========== ⇩(5) タイマー精度1ミリ秒での停止制御 ============
  10. Sub Wait_1ms()
  11.  Dim CntStart As Currency   '←カウント数(実行直前)
  12.  Dim CntEnd As Currency    '←カウント数(実行直後)
  13.  Dim freq As Currency     '←周波数(カウント数/秒)
  14.  Call QueryPerformanceFrequency(freq)
  15.  timeBeginPeriod 1
  16.   Call QueryPerformanceCounter(CntStart)
  17.   Application.Wait Evaluate("now()") + CDbl(TimeValue("0:00:01"))
  18.   Call QueryPerformanceCounter(CntEnd)
  19.  timeEndPeriod 1
  20.  MsgBox (CntEnd - CntStart) / freq
  21. End Sub
図09

timeBeginPeriod関数を使用するには、61~67行目のようなWin32APIの宣言が必要です。またタイマーを元に戻すにはtimeEndPeriod関数が必要ですので、同時に宣言しています。
77行目「timeBeginPeriod 1」では、タイマー精度を1ミリ秒に変更しています。timeBeginPeriod関数の引数に「変更するミリ秒」を指定することで変更できます。
81行目「Application.Wait Evaluate("now()") + CDbl(TimeValue("0:00:01"))」は図06の49行目と同じでミリ秒の停止制御をさせています。
85行目「timeEndPeriod 1」では、タイマー精度を標準(約15ミリ秒)に戻しています。timeEndPeriod関数の引数には、77行目のtimeBeginPeriod関数の引数(ここでは 1)と同じ値を指定する必要があります。
図09のコードで得られるデータの分布は図10のようになります。
Waitメソッド+Evaluate_Now関数+タイマー精度1msでの実時間分布
図10

図07と比較すると一周り停止精度が良くなり、「1±0.01 秒」の範囲にほとんどのデータが入ります。なおタイマー精度が1ミリ秒の環境下ではEvaluate("now()")の分解能は、「約10ミリ秒(≒小数点2桁秒)」ですので、その値に沿っているような感じです。

1-6.Waitメソッドの停止精度の限界

「タイマー精度1ミリ秒」の環境下では、GetLocalTime関数自体の分解能は1ミリ秒まで向上します(「日時や時間の取得」参照)。このGetLocalTime関数を使ってWaitメソッドをより精密に制御しようとしたのが図11です。
  1. '========== ⇩(6) Win32API宣言 ============
  2. #If Win64 Then
  3.  Declare PtrSafe Sub GetLocalTime Lib "kernel32" (lpSystemTime As SYSTEMTIME)
  4. #Else
  5.  Declare Sub GetLocalTime Lib "kernel32" (lpSystemTime As SYSTEMTIME)
  6. #End If
  7. '========== ⇩(7) SYSTEMTIME構造体の定義 ============
  8. Type SYSTEMTIME
  9.  wYear As Integer      '←年
  10.  wMonth As Integer      '←月
  11.  wDayOfWeek As Integer   '←曜日(0=日曜、1=月曜、・・・)
  12.  wDay As Integer       '←日
  13.  wHour As Integer      '←時
  14.  wMinute As Integer     '←分
  15.  wSecond As Integer     '←秒
  16.  wMilliseconds As Integer   '←ミリ秒
  17. End Type
  18. '========== ⇩(8) タイマー1ミリ秒環境下での停止制御 ============
  19. Sub Wait_1ms2()
  20.  Dim CntStart As Currency   '←カウント数(実行直前)
  21.  Dim CntEnd As Currency    '←カウント数(実行直後)
  22.  Dim freq As Currency     '←周波数(カウント数/秒)
  23.  Dim sysT As SYSTEMTIME    '←GetLocalTimeで取得する日時データ
  24.  Call QueryPerformanceFrequency(freq)
  25.  timeBeginPeriod 1
  26.  Call GetLocalTime(sysT)
  27.   Call QueryPerformanceCounter(CntStart)
  28.   
  29.   Application.Wait sysT.wHour / 24 + sysT.wMinute / 24 / 60 + _
  30.           (sysT.wSecond + 1) / 24 / 60 / 60 + sysT.wMilliseconds / 24 / 60 / 60 / 1000
  31.   Call QueryPerformanceCounter(CntEnd)
  32.  timeEndPeriod 1
  33.  MsgBox (CntEnd - CntStart) / freq
  34. End Sub
図11

GetLocalTime関数はWin32APIですので、101~105行目のようなAPI宣言が必要です。
またGetLocalTime関数は、SYSTEMTIME構造体として値を戻してきますので、108~117行目のように構造体の定義も必要です。
128行目「timeBeginPeriod 1」で、タイマー精度を1ミリ秒に変更しています。
130行目「Call GetLocalTime(sysT)」では、タイマー精度1ミリ秒の環境でGetLocalTime関数を実行しています。そのデータは、変数sysT(SYSTEMTIME構造体)に入ります。
134~135行目ではWaitメソッドを実行します。Waitメソッドの引数には日時のシリアル値を指定しなければなりませんが、GetLocalTime関数の各要素に収められている値はInteger型で整数値です。ですので各要素をシリアル値相当に換算して足し合わせる必要があります。
ですので引数としては「sysT.wHour / 24 + sysT.wMinute / 24 / 60 + (sysT.wSecond + 1) / 24 / 60 / 60 + sysT.wMilliseconds / 24 / 60 / 60 / 1000」としています。例えば「秒の位(要素:wSecond)」について説明すると、1秒は1日÷24時間÷60分÷60秒 ですので「sysT.wSecond / 24 / 60 / 60」となりますが、今回1秒後を指定しますので「(sysT.wSecond + 1) / 24 / 60 / 60」としています。
結果は図12のようになりました。
Waitメソッド+GetLocalTime+タイマー精度1msでの実時間分布
図12

バラツキとしては図10の約半分の「1-0~1+0.01 秒」です。しかしタイマー精度が1ミリ秒になってもこのバラツキがあるのは、Waitメソッドは「小数点2桁のミリ秒(≒ワークシートのNOW関数の限界分解能の10ミリ秒)」で停止制御を行っているのではないか と推測しています。

2.Do~Loopで一定時間滞留

Do~Loopステートメントは、その間を繰り返し実行するものです。そのままだと無限ループになってしまいますので、While文(条件を満たす間繰り返しを続ける)や Until文(条件を満たしたら繰り返しを終わる)で繰り返しを制限します。
なおDo~Loop内に条件式を持ってきて「Exit Do」で抜ける手法もあります。
今回は「一定の停止時間の間だけDo~Loopを回し続ける」という方法で停止制御を行います。

2-1.秒単位で制御

2-1-1.Date型での足し算方式

まず秒単位の停止制御に、Now関数とTimeValue関数の足し算を使うのが図13です。
  1. '========== ⇩(9) 秒単位での停止制御 ============
  2. Sub DoLoop_Second()
  3.  Dim DT As Date        '←指定する1秒後の再開日時
  4.  Dim CntStart As Currency   '←カウント数(実行直前)
  5.  Dim CntEnd As Currency    '←カウント数(実行直後)
  6.  Dim freq As Currency     '←周波数(カウント数/秒)
  7.  Call QueryPerformanceFrequency(freq)
  8.  Call QueryPerformanceCounter(CntStart)
  9.  DT = Now() + TimeValue("0:00:01")
  10.  Do While DT > Now()
  11.   DoEvents: DoEvents
  12.  Loop
  13.  Call QueryPerformanceCounter(CntEnd)
  14.  MsgBox (CntEnd - CntStart) / Freq
  15. End Sub
図13

まず160行目「DT = Now() + TimeValue("0:00:01")」では、Do~Loopに入る直前の日時データ(Now関数)を取得し、それに1秒「TimeValue("0:00:01")」を足した日時値を変数DTに代入しています。
161~163行目のDo~Loopで1秒間回り続ける事で停止時間を確保します。
161行目「Do While DT > Now()」では、Do~Loopで回し続ける条件を「DT > Now()」としています。つまりDo~Loopの直前に測定した日時に1秒足した値(変数DT)を、現在の日時と比べています。指定時よりも現在時の方が大きくなった「 DT <= Now() 」が成立すれば、Do~Loopを抜けて165行目に移ります。
Do~Loopの間に入っている162行目「DoEvents: DoEvents」は、Do~Loopを中断するためのものです。DoEvents自体はプログラムの制御をO/Sに一旦戻す機能を持っています。
Do~Loopが問題無く動けば、このDoEventsは不要なのですが、もし何らかの原因で無限ループになってしまった場合、ESCキーやCtrl+Breakキー等を押下することでプログラムを強制停止させる事ができるため、必ず入れるようにしています。且つDoEventsを2個入れているのは、1個だと強制停止できない場合があるとの情報に基づいています。
167行目「MsgBox (CntEnd - CntStart) / Freq」では、Do~Loopで停止していた実秒数を表示しています。
図13は何の問題も無さそうに見えますが、落とし穴があります。実は図13で得られるデータ分布は以下のようになります。
Do-Loop+Now関数をDate型での実時間分布
図14

1秒を超え2秒まで達しているデータを見て「おや?」と思った方もいるかもしれません。理論的には図15の左側のように「1-1 ~ 1+0」となるはずだからです。
Do-LoopでDate型の値がバラつく理屈
図15

実はここでも「よりみち」で説明した「Double型+Double型での丸め」が影響しているようです。コード上は全てDate型ですが、実体はDouble型なので最小桁数の丸めが起こり、再開時刻の値が本来の時刻の値よりもわずかに大きくなる場合があります。大きくなった場合には、再開するはずの1秒後の時刻(図13の161行目の一番右側のNow()の値)の時点では「まだ予定時刻に達していない」と判断され、図15の右のように再開されません。
その後、Date型のNow()の分解能は1秒(≒1秒ごとにしかデータが更新されない)であるため、、次のタイミング(=2秒後)で条件式が成立し再開することになります。
ただし、図14では約3割のデータが1秒超になっており、Double型の丸めの割合(多くて約2割)からすると「ちょっと多すぎ」の感があります。これが偶然の偏りなのか、まだ何か原因があるのか分かりませんが、とりあえず「Double型の丸めが原因の値ズレ」をなくす手法を下記で紹介します。

2-1-2.一旦String型に変更する方式

「Double型の丸めが原因の値ズレ」をなくす手法として考えたのは以下です(図13の手法も①としています)。
 ① TimeValueを使用「Now() + TimeValue("0:00:01")」 ←これは上記の通りダメ
 ② 分数値を使用「Now() + 1 / 86400」
 ③ DateAdd関数を使用「DateAdd("s", 1, Now())」
 ④ Date型を一旦文字列にする「CDate(CStr(Now() + TimeValue("0:00:01")))」
結果的には②も③も①と同じです。原因は内部計算はDouble型で行っている為のようです。うまく行ったのは④の「Date型の日時を一旦文字列」にする方式で、それをコード化したのが下記(図16)です。
  1. '========== ⇩(10) 秒単位での停止制御(一旦文字列化) ============
  2. Sub DoLoop_Second2()
  3.  Dim DT As Date        '←指定する1秒後の再開日時
  4.  Dim CntStart As Currency   '←カウント数(実行直前)
  5.  Dim CntEnd As Currency    '←カウント数(実行直後)
  6.  Dim freq As Currency     '←周波数(カウント数/秒)
  7.  Call QueryPerformanceFrequency(freq)
  8.  Call QueryPerformanceCounter(CntStart)
  9.  DT = CStr(Now() + TimeValue("0:00:01"))
  10.  Do While DT > Now()
  11.   DoEvents: DoEvents
  12.  Loop
  13.  Call QueryPerformanceCounter(CntEnd)
  14.  MsgBox (CntEnd - CntStart) / Freq
  15. End Sub
図16

190行目「DT = CStr(Now() + TimeValue("0:00:01"))」では、現在の1秒先の日時(Date型)をCStr関数で文字列化し、それをDate型の変数DTに代入しています。
図13「DT = Now() + TimeValue("0:00:01")」の場合はDate型を直接Date型変数に代入していたため、内部的には「最小桁の丸めが残ったままの値」がDate型変数となっていましたが、日時データを一旦文字列化してからDate型に戻すことで「正しいDate型の値」にする事が出来るようです。
なお変数DTをDate型で宣言しているため、文字列型→Date型に勝手に変換されますが、明示的にCDate関数を使って「DT = CDate(CStr(Now() + TimeValue("0:00:01")))」としてもOKです。
この図16で得られるデータ分布は以下のようになります。
Do-Loop+Now関数を文字列型での実時間分布
図17

日時値を一旦文字列にすることで、1を超えた値がすっかり無くなり「1-1 ~ 1+0」の範囲に収まりました。という事は、図14の1を超えたデータ量が少し多目なのは、単にサンプル数が少ないだけなのかもしれません。

2-1-3.不等号に「>=」を使用する方式

日時データを一旦文字列化する事で、バラツキは「1-1 ~ 1+0(最大-最小≒1)」の範囲となりました。別な手法で「最大-最小≒1」を実現する事も可能です。それが下記のコードです。
  1. '========== ⇩(11) 秒単位での停止制御(不等号の改善) ============
  2. Sub DoLoop_Second3()
  3.  Dim DT As Date        '←指定する1秒後の再開日時
  4.  Dim CntStart As Currency   '←カウント数(実行直前)
  5.  Dim CntEnd As Currency    '←カウント数(実行直後)
  6.  Dim freq As Currency     '←周波数(カウント数/秒)
  7.  Call QueryPerformanceFrequency(freq)
  8.  Call QueryPerformanceCounter(CntStart)
  9.  DT = Now() + TimeValue("0:00:01")
  10.  Do While DT >= Now()
  11.   DoEvents: DoEvents
  12.  Loop
  13.  Call QueryPerformanceCounter(CntEnd)
  14.  MsgBox (CntEnd - CntStart) / Freq
  15. End Sub
図18

221行目「Do While DT >= Now()」では、不等号を「等号付きの不等号」に変更しています。
この図18で得られるデータ分布は以下のようになります。
Do-Loop+Now関数+等号付き不等号での実時間分布
図19

全データが「1-0 ~ 1+1」の範囲に入ります。これは図14で1秒未満だったデータが、「等号付き」になることで「+1秒」され、一方1秒超だったデータはそのまま「1~2秒の間」にいるため、図19のような分布となります。

2-2.ミリ秒単位で制御

Evaluateメソッドを使ってミリ秒で制御したのが図20です。
  1. '========== ⇩(12) ミリ秒単位で制御 ============
  2. Sub DoLoop_mmSecond()
  3.  Dim DT As Double       '←指定する1秒後の再開日時
  4.  Dim CntStart As Currency   '←カウント数(実行直前)
  5.  Dim CntEnd As Currency    '←カウント数(実行直後)
  6.  Dim freq As Currency     '←周波数(カウント数/秒)
  7.  Call QueryPerformanceFrequency(freq)
  8.  Call QueryPerformanceCounter(CntStart)
  9.  DT = Evaluate("Now()") + CDbl(TimeValue("0:00:01"))
  10.  Do While DT > Evaluate("Now()")
  11.   DoEvents: DoEvents
  12.  Loop
  13.  Call QueryPerformanceCounter(CntEnd)
  14.  MsgBox (CntEnd - CntStart) / Freq
  15. End Sub
図20

250行目「DT = Evaluate("Now()") + CDbl(TimeValue("0:00:01"))」では、Evaluateメソッドを使い「ワークシート関数のNOW関数」を使ってミリ秒単位で現在日時を取得し、それに対して1秒を加えています。TimeValue関数はDate型の値を戻すので、Date型+Double型=Date型(秒単位)となることを防ぐためCDbl関数でDouble型にしています。
251行目「Do While DT > Evaluate("Now()")」では、比較する現在日時もミリ秒単位にするためEvaluateメソッドを使っています。
この図20で得られるデータ分布は以下のようになります。
Do-Loopのミリ秒制御での実時間分布。>を使用
図21

理論的には「1-0.015 ~ 1+0」の範囲になるはずですが、「よりみち」でも説明したような「Double型の丸め」の影響もあり、1超にもデータが現れているものと思われます。
なお、図20では比較演算子として「>」を使用しましたが、これを「>=」にすると以下の様な分布になります。
Do-Loopのミリ秒制御での実時間分布。>=を使用
図22

全体がプラス側に少し移動するのが分かりますが、1秒未満にもデータが残ってしまう理由は良く分かりません。
なお、Evaluate("Now()")の代わりに、ミリ秒が得られる「Timer()」を使っても、分布はあまり変わりませんでした。
但しこのTimer()は日付を跨ぐ時に使われると「丸1日停止」してしまう事があるため使用には注意が必要です。

2-3.タイマー精度を変更して制御

timeBeginPeriod関数で、タイマー精度を「標準15ミリ秒 → 1ミリ秒」に向上させて停止制御したのが図23です。なおここでは「タイマー精度が1ミリ秒の時には、分解能も1ミリ秒」となるtimeGetTime関数を使用しています。
timeGetTime以外でも分解能が1ミリ秒となる「GetLocalTime関数」を使用してもOKです。
  1. '========== ⇩(13) Win32API宣言 ============
  2. #If Win64 Then
  3.  Declare PtrSafe Function timeGetTime Lib "winmm.dll" () As Long
  4. #Else
  5.  Declare Function timeGetTime Lib "winmm.dll" () As Long
  6. #End If
  7. '========== ⇩(14) タイマー精度1ミリ秒での停止制御 ============
  8. Sub DoLoop_1ms()
  9.  Dim DT As Long       '←指定する1秒後の再開ミリ秒数
  10.  Dim CntStart As Currency   '←カウント数(実行直前)
  11.  Dim CntEnd As Currency    '←カウント数(実行直後)
  12.  Dim freq As Currency     '←周波数(カウント数/秒)
  13.  Call QueryPerformanceFrequency(freq)
  14.  timeBeginPeriod 1
  15.  Call QueryPerformanceCounter(CntStart)
  16.  DT = timeGetTime + 1000
  17.  Do While DT > timeGetTime
  18.   DoEvents: DoEvents
  19.  Loop
  20.  Call QueryPerformanceCounter(CntEnd)
  21.  timeEndPeriod 1
  22.  MsgBox (CntEnd - CntStart) / Freq
  23. End Sub
図23

271~275行目はGetLocalTime関数の宣言部です。Windows起動後の時間をミリ秒単位の整数で戻す関数です。
286行目「timeBeginPeriod 1」でタイマー精度を1ミリ秒に変更し、297行目「timeEndPeriod 1」で標準状態(タイマー精度約15ミリ秒)に戻しています。この間は、タイマー精度1ミリで動いている事になります。
290行目「DT = timeGetTime + 1000」で、起動後のミリ秒をtimeGetTime関数で取得し、それに対し「1000(ミリ秒)」を加算して変数DTに代入しています。timeGetTime関数の戻り値はLong型(宣言部参照)なので、変数DTも279行目「Dim DT As Long」でLong型宣言しています。
291行目「Do While DT > timeGetTime」では、1秒が経過するまでDo~Loopを回しています。
この図23で得られるデータ分布は以下のようになります。
Do-Loopでタイマー精度向上での実時間分布。
図24

データは「1±0.001」に収まっており、確かにバラツキ少なく停止制御ができる手法と言えます。但しタイマー精度を元に戻すのを忘れたり戻せなかったりした場合には、システム全体のパフォーマンスが悪くなったり、PCハード自体に悪影響を与えたりしますので、慎重に使うことが必要です。

2-4.QueryPerformanceCounterを使用して制御

上記ではタイマー精度を向上させる事で精度高く停止させましたが、タイマー精度が標準状態でも停止精度を上げることは可能です。下記では「Windows起動後からのカウント数(QueryPerformanceCounter)」を使って停止時間を制御させています。
  1. '========== ⇩(15) QueryPerformanceCounterで停止制御 ============
  2. Sub DoLoop_1ms2()
  3.  Dim DT As Currency      '←指定する1秒後の再開カウント数
  4.  Dim CntStart As Currency   '←カウント数(停止直前)
  5.  Dim CntEnd As Currency    '←カウント数(停止中、停止直後)
  6.  Dim freq As Currency     '←周波数(カウント数/秒)
  7.  Call QueryPerformanceFrequency(freq)
  8.  Call QueryPerformanceCounter(CntStart)
  9.  DT = CntStart + freq * 1
  10.  Do
  11.   Call QueryPerformanceCounter(CntEnd)
  12.   DoEvents: DoEvents
  13.  Loop While DT > CntEnd
  14.  Call QueryPerformanceCounter(CntEnd)
  15.  MsgBox (CntEnd - CntStart) / Freq
  16. End Sub
図25

まず318行目「Call QueryPerformanceCounter(CntStart)」では、今までは実停止時間を測るために測定していた「停止直前のカウント数(変数CntStart)」を制御のためにも取得をします。また周波数(1秒間のカウント数)は変化しない値なので、317行目「Call QueryPerformanceFrequency(freq)」であらかじめ取得しておきます。
その2種の値から319行目「DT = CntStart + freq * 1」で、再開する時点でのカウント数を計算します。ここでは1秒間停止させますので、停止直前のカウント数(変数CntStart)に1秒間のカウント数(変数freq)を足します。最後の「* 1」が停止秒数となるので、異なる停止時間の時にはこの値を変更します。
321~324行目でDo~Loopを回します。いままで抜け出す条件式はDo側に置いていましたが、今回はLoop側に与えています。これはDo側に条件式を置いてしまうと、初回の現在カウント値取得式(322行目相当)が必要になってしまうためです。
322行目「Call QueryPerformanceCounter(CntEnd)」で現在のカウント数を取得(変数CntEnd)し、324行目「Loop While DT > CntEnd」で再開カウント数(変数DT)と比較をし、不等式が成立している間はDo~Loopを回しています。
1秒間が経過しDo~Loopを抜け出したら326行目「Call QueryPerformanceCounter(CntEnd)」で停止の実時間を取得していますが、実際には322行目で取得済みなので不要です。しかし他のコードでは「Do~Loopを抜けた後に時間測定」をしているため、それと合わせています。
この図25で得られるデータ分布は以下のようになります。
Do-LoopでQueryPerformanceCounter使用での実時間分布。
図26

停止時間のバラツキは「0.5ミリ秒以内」と、タイマー精度変更法(図24)よりも精度良く停止制御ができていることになります。但しカウントの周波数(QueryPerformanceFrequencyで得られる値:今回は10,000,000Hz)はPCや環境により異なる可能性があるので、停止制御の精度は変わってくる可能性があります。

3.Sleep関数で一定時間中断

Sleep関数はWin32API関数で、引数に指定した「ミリ秒」の時間だけ停止させるものです。構文は以下の通りです。
 Sleep (中断するミリ秒単位の整数 )
Sleep関数が実行されると「O/Sによって眠らされている状態」になるようで、ESCキー等では実行中断できません。
API関数なので、宣言部で以下のような関数宣言が必要です。
  1. '========== ⇩(16) Win32API宣言 ============
  2. #If Win64 Then
  3.  Declare PtrSafe Sub Sleep Lib "kernel32" (ByVal ms As Long)
  4. #Else
  5.  Declare Sub Sleep Lib "kernel32" (ByVal ms As Long)
  6. #End If
図27

3-1.ミリ秒単位で制御

Sleep関数は、停止時間をミリ秒単位で指定できます。1秒(=1000ミリ秒)停止させるのが以下のコードです。
  1. '========== ⇩(17) Sleep関数で1秒間の停止制御 ============
  2. Sub Sleep_mmSecond()
  3.  Dim CntStart As Currency   '←カウント数(実行直前)
  4.  Dim CntEnd As Currency    '←カウント数(実行直後)
  5.  Dim freq As Currency     '←周波数(カウント数/秒)
  6.  Call QueryPerformanceFrequency(freq)
  7.  Call QueryPerformanceCounter(CntStart)
  8.  Sleep 1000
  9.  Call QueryPerformanceCounter(CntEnd)
  10.  MsgBox (CntEnd - CntStart) / Freq End Sub
図28

359行目「Sleep 1000」では、Sleep関数に1000ミリ秒(=1秒)を指定し、プログラムを停止させています。
この図28で得られるデータ分布は以下のようになります。
標準状態で、Sleepでの実時間分布。
図29

データは、ほぼ「1-0 ~ 1+0.015」に収まっています。このバラツキの「15ミリ秒」は、タイマー精度の標準約15ミリ秒に関係していると思われます。

3-2.タイマー精度を変更して制御

上記のバラツキは約15ミリ秒で、それがタイマー精度に基づいているとすれば、タイマー精度をアップさせればバラツキが小さくなる可能性があります。そこでtimeBeginPeriod関数を使って、タイマー精度を1ミリ秒に向上させたのが以下です。
  1. '========== ⇩(18) タイマー精度1msで、Sleep関数で1秒間の停止制御 ============
  2. Sub Sleep_1ms()
  3.  Dim CntStart As Currency   '←カウント数(実行直前)
  4.  Dim CntEnd As Currency    '←カウント数(実行直後)
  5.  Dim freq As Currency     '←周波数(カウント数/秒)
  6.  Call QueryPerformanceFrequency(freq)
  7.  timeBeginPeriod 1
  8.   Call QueryPerformanceCounter(CntStart)
  9.   Sleep 1000
  10.   Call QueryPerformanceCounter(CntEnd)
  11.  timeEndPeriod 1
  12.  MsgBox (CntEnd - CntStart) / Freq
  13. End Sub
図30

378行目「timeBeginPeriod 1」でタイマー精度を1ミリ秒に向上させ、382行目「Sleep 1000」で1秒間停止させています。 一時停止が終了したら、386行目「timeEndPeriod 1」で元の状態(約15ミリ秒)に戻しています。
この図30で得られるデータ分布は以下のようになります。
タイマー精度を1msにした上で、Sleepでの実時間分布。
図31

データは「1-0 ~ 1+0.0015」に収まっており、図29(タイマー標準)の約1/10 になっています。

4.OnTimeメソッドで一定時間後に別プロシージャを起動

OnTimeメソッドは、指定マクロを指定日時に実行するものです。構文は以下のようになります。
 Excel.Application.OnTime(EarliestTime, Procedure, LatestTime, Schedule)
ExcelのVBAから実行するのであれば、先頭の「Excel.」は不要です。
4つの引数は、以下のような内容になります。
引数名内容
EarliestTime必須プロシージャを実行する時刻
Procedure必須実行プロシージャ名
LatestTime省略可他マクロが作動中の場合の待つ限界日時
省略=実行可能になるまで待機
Schedule省略可True(既定):指定時刻に実行
False:指定時刻の予約を取り消し
図32

必須は「プロシージャを実行する時刻」と「実行するプロシージャ名」です。第3引数は使った事がありません。第4引数は予約をした日時をその通りに指定しないとキャンセル出来ないので、Now関数等を使っている場合にはちょっと面倒です。
第1引数に指定する「時刻」ですが、Now関数を良く使うことからも分かるように、日付+時刻でもOKです。その場合は指定する値により、以下のように動作が異なります。
内容的にはWaitメソッドの図02と同じで、指定日時が過去の値であれば「即座に実行」されますので注意が必要です。
実行時OnTimeの第一引数
時刻のみ(A)
(シリアル値=1未満)
日付+時刻(A)
(シリアル値=1以上)
Aより前A時に実行A時に実行
Aより後翌日のA時に実行即時実行
図33

第2引数の「実行プロシージャ」ですが、プロシージャ名をそのまま記しただけだと「OnTimeメソッドを実行したブックの標準モジュール」内を探します。標準モジュールに無ければエラーとなります。
もし実行プロシージャがシートモジュールにある場合は「"Sheet1.Test1"」等と「シート名+ピリオド」で場所を指定します。
実行プロシージャがOnTimeメソッドとは別のブックにある場合等については「OnTimeメソッドの第一・第二パラメータの設定方法」を参照下さい。

4-1.秒単位で制御

秒単位での停止制御に、Now関数とTimeValue関数の足し算を使うのが図34です。
  1. '========== ⇩(19) OnTimeメソッドで1秒間の停止制御 ============
  2. Sub OnTime_Second()
  3.  Dim DT As Date        '←指定する1秒後の日時
  4.  Dim CntStart As Currency   '←カウント数(停止直前)
  5.  Call QueryPerformanceCounter(CntStart)
  6.  DT = Now() + TimeValue("0:00:01")
  7.  Application.OnTime DT, "'MsgB1 " & CntStart & "'"
  8. End Sub
図34

407行目「DT = Now() + TimeValue("0:00:01")」で、現在よりも1秒先の日時を取得します。
408行目「Application.OnTime DT, "'MsgB1 " & CntStart & "'"」では、407行目で設定した日時に「MsgB1」というプロシージャを起動させます。
MsgB1プロシージャには引数(停止直前のカウント数)を渡す必要があるため、指定する文字列としては「'プロシージャ名 引数'」と、プロシージャ名と引数の間には「1つの半角スペース」を入れ、また(スペースが入る事で別の文字列と判断されないように)全体を「'(シングルクォーテーション)」で囲みます。
呼び出されるプロシージャ側(MsgB1)は、以下になります。
  1. '========== ⇩(20) 実行されるプロシージャ ============
  2. Public Sub MsgB1(CntStart As Currency)
  3.  Dim CntEnd As Currency    '←カウント数(停止直後)
  4.  Dim freq As Currency     '←周波数(カウント数/秒)
  5.  Call QueryPerformanceCounter(CntEnd)
  6.  Call QueryPerformanceFrequency(freq)
  7.  MsgBox (CntEnd - CntStart) / Freq
  8. End Sub
図35

呼び出されるプロシージャでは実際に停止した時間を測定するため、425行目「Call QueryPerformanceCounter(CntEnd)」で停止直後(≒プロシージャの呼び出し時)のカウント数を取得し、426行目「Call QueryPerformanceFrequency(freq)」で周波数(秒あたりのカウント数)を取得します。
実行プロシージャの引数として渡された「停止直前のカウント数(変数CntStart)」と合わせて、428行目「MsgBox (CntEnd - CntStart) / Freq」で実時間を表示します。
この図34図35で得られる停止時間のデータ分布は以下のようになります。
OnTimeを使用しNow関数で1秒停止させた時の実時間分布。
図36

結果は「(1-1 ~ 1+0 ) +α」と、Waitメソッド(図05)とほぼ同レベルです。+α の内容として「同時刻に動いている他プロセスを優先」したり「あらたにプロシージャを起動するのには時間が掛かる?」などが考えられますが、詳細は分かりません。

4-2.ミリ秒単位で制御

Evaluate("Now()")を使い、ミリ秒単位で停止制御をするのが下記のコードです。呼び出すプロシージャは図35を共用で使います。
  1. '========== ⇩(21) OnTimeメソッドで1秒間をミリ秒単位で停止制御 ============
  2. Sub OnTime_mmSecond()
  3.  Dim DT As Double       '←指定する1秒後の日時
  4.  Dim CntStart As Currency   '←カウント数(停止直前)
  5.  Call QueryPerformanceCounter(CntStart)
  6.  DT = Evaluate("Now()") + CDbl(TimeValue("0:00:01"))
  7.  Application.OnTime DT, "'MsgB1 " & CntStart & "'"
  8. End Sub
図37

447行目「DT = Evaluate("Now()") + CDbl(TimeValue("0:00:01"))」では、現在の日時をミリ秒レベルで得るためにワークシートのNow関数を使用し、それに対して1秒を加えています。但しDouble型+Date型=Date型と「秒レベル」に戻ってしまわないように、TimeValue関数をCDbl関数でDouble型に変換しています。
この図37図35で得られる停止時間のデータ分布は以下のようになります。
OnTimeを使用しEvalueate_Nowで1秒停止させた時の実時間分布。
図38

図36と比べると大幅にバラツキは減り、「1-0 ~ 1+0.05」くらいの範囲には凡そ入るのですが、たまには大きく外れる場合もあるようです。
この遅れの原因は良く分かりませんが、Windowsの裏で動いている別なプロセスが「予定されているプロシージャ実行」よりも優先されるのではないかと推定しています。1つのプロシージャ内の連続しているコードとは異なり「別な場所からプロシージャを呼び出す」という構造上、ある程度は仕方がないかな とも思います。
なおEvaluate("Now()")の代わりに「Timer関数」を使い、図37の447行目を「DT = (Timer() + 1) / 86400」と変えても、バラツキ方は図38とほぼ一緒となりました。この式はスッキリしている気がしますが、24時近くに実行して「計算結果の変数DTが1を超し」てしまうと日付も指定した事になり、即時実行されてしまいますので注意が必要です。

4-3.タイマー精度を変更して制御

タイマー精度を向上(標準約15ミリ秒 → 1ミリ秒)させてOnTimeメソッドで停止制御させたのが以下です。なお、プロシージャ呼び出し側でタイマー精度変更を行い、呼び出されたプロシージャ側でタイマー精度を元に戻す必要があるため、呼び出されるプロシージャは図35とは異なるものになります。
  1. '========== ⇩(22) OnTimeメソッド+タイマー精度1msでの1秒間の停止制御 ============
  2. Sub OnTime_1ms()
  3.  Dim DT As Double       '←指定する1秒後の日時
  4.  Dim CntStart As Currency   '←カウント数(停止直前)
  5.  timeBeginPeriod 1
  6.  Call QueryPerformanceCounter(CntStart)
  7.  DT = Evaluate("Now()") + CDbl(TimeValue("0:00:01"))
  8.  Application.OnTime DT, "'MsgB2 " & CntStart & "'"
図39

465行目「timeBeginPeriod 1」では、タイマー精度を1ミリ秒に変更し、その状態で470行目のOnTimeメソッドでプロシージャ実行予約をしています。実行するプロシージャは以下の内容です。
  1. '========== ⇩(23) 実行されるプロシージャ(タイマー精度復元付き) ============
  2. Public Sub MsgB2(CntStart As Currency)
  3.  Dim CntEnd As Currency    '←カウント数(停止直後)
  4.  Dim freq As Currency     '←周波数(カウント数/秒)
  5.  Call QueryPerformanceCounter(CntEnd)
  6.  Call QueryPerformanceFrequency(freq)
  7.  timeEndPeriod 1
  8.  MsgBox (CntEnd - CntStart) / Freq
  9. End Sub
図40

指定時刻になると図40が実行され、実際の停止時間を485行目「Call QueryPerformanceCounter(CntEnd)」で測定します。測定が完了したら488行目「timeEndPeriod 1」でタイマー精度を復元(→約15ミリ秒)します。
得られる停止時間のデータ分布は以下のようになります。
OnTime+タイマー精度を1msにした条件で1秒停止させた時の実時間分布
図41

結果は、タイマー精度を向上させる前の図38とそれほど変わっていないように見えます。ということはOnTimeメソッドの中では「時刻は約10~15ミリ秒の分解能で監視」していると推定されます。
また大きく外れる場合も同じように存在しました。なお外れた数が増えたのは、今のところ偶然だと考えています。
なお、タイマー精度の変更と復元を「別のプロシージャで実行」しているため、同じプロシージャ内で変更・復元しているよりも安全上のリスクがずっと高く、お勧めできる手法ではないと思います。

5.まとめ

上記で説明した「Waitメソッド」「Do~Loop」「Sleep関数」「OnTimeメソッド」での停止時間機能を簡単な表にまとめました。どれを使うかは場面場面で異なりますので、メリットデメリットを見ながら選択してください。
まとめ表だけでは表せない内容もありますので、詳細については各項目を参照下さい。
WaitDo~LoopSleepOnTime
構文Wait(再開時刻)Do While 再開時刻>Now()
Loop
Win32API宣言
Sleep 停止ミリ秒
Application.OnTime 実行時刻,実行マクロ
標準
偏差
(ms)
秒制御294583(文字列化で286)-326
ミリ秒制御8.16.54.928
タイマ精度1ms5.3(限界2.6)0.4(限界0.1)0.341
停止中の操作×(〇)DoEventsで可×
CPUの負荷
図42

アプリ実例・関連する項目

再計算されたか否かのチェックをイベントで取得
図形も貼り付けられるスケジュール帳
セル色変更の情報をイベント風に受け取る
図形で作るアナログ時計
DVD等の内容・保管場所等管理システム
ラベルカレンダーをクリックし日付入力
OLEObjectのラベルカレンダー(同一ブック内)
自動的に閉じるメッセージ
OnTimeメソッドの第一・第二パラメータ設定方法

サンプルファイル

処理を一定時間停止させる(its-046.xlsm)
セキュリティ向上を目的として「インターネット経由でダウンロードしたOfficeファイル(Excel等)のマクロは、既定でブロック」されるようにOfficeアプリケーションの既定動作が変更になりました。(2022年4月より切替開始)
解除の方法については「ダウンロードファイルのブロック解除方法」を参照下さい。