まず最初に「DoEventsは使うな」という意見は多数派であることを理解しており、私もそれには賛成の立場です。
その前提でDoEventsを使っているシステムに関わる現場で働いているため、問題点を記録しておきます。
UIスレッドで動くタイマーとDoEventsが組み合わさることで発生する予期しない動作を
レガシーシステムで何度も目にしています。
どういうことか、実際に例で動作確認してみます
DoEventsを使っているのは大体VB6からマイグレしたVB.NETが体感多いため、ここではVB.NETで記載しています。
[動作例]
Public Class Form1
Private WithEvents timer As Timer = New Timer()
Private loaded As Boolean = False
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
timer.Enabled = True
End Sub
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
If (loaded) Then Return
loaded = True
timer.Interval = 1000
timer.Enabled = False
End Sub
Private Sub timer_Tick(sender As Object, e As EventArgs) Handles timer.Tick
Debug.WriteLine("開始")
System.Threading.Thread.Sleep(2000) '何らかの重い処理
Application.DoEvents()
Debug.WriteLine("終了")
End Sub
End Class
このコードを実行すると次のような出力になります
開始
開始
開始
開始
開始
重い処理(2秒)>タイマー発火間隔(1秒) のため、一生Windowsメッセージキューを処理し続けます。
アプリが終了信号を受け取ると、溜まっていた終了処理が一気に処理されます
終了
終了
終了
終了
終了
終了
再入を考慮せず、グローバル変数を扱っていたり外部インターフェースと連携している場合に
このような事象が発生すると非常に怖いです。
根本原因
色々あると思いますが、DoEventsやWindowsメッセージキューに対する理解が浅いことだと思います。
規模が大きいシステムで乱用していると、もはや制御不可能です。
DoEventsの実装は次のコメントにある通り、全てのWindowsメッセージキューを処理します
/// <include file='doc\Application.uex' path='docs/doc[@for="Application.DoEvents"]/*' />
/// <devdoc>
/// <para>Processes
/// all Windows messages currently in the message queue.</para>
/// </devdoc>
public static void DoEvents() {
ThreadContext.FromCurrent().RunMessageLoop(NativeMethods.MSOCM.msoloopDoEvents, null);
}
フォームタイマーのソースを見るとウィンドウに送信されたメッセージを処理するコールバック関数であるWndProc
でWindowsメッセージを拾い、タイマー処理をしていることが分かります
protected override void WndProc(ref Message m) {
Debug.Assert(m.HWnd == Handle && Handle != IntPtr.Zero, "Timer getting messages for other windows?");
// for timer messages, make sure they're ours (it'll be wierd if they aren't)
// and call the timer event.
//
if (m.Msg == NativeMethods.WM_TIMER) {
//Debug.Assert((int)m.WParam == _timerID, "Why are we getting a timer message that isn't ours?");
if (unchecked( (int) (long)m.WParam) == _timerID) {
_owner.OnTick(EventArgs.Empty);
return;
}
}
else if (m.Msg == NativeMethods.WM_CLOSE) {
// this is a posted method from another thread that tells us we need
// to kill the timer. The handle may already be gone, so we specify it here.
//
StopTimer(true, m.HWnd);
return;
}
base.WndProc(ref m);
}
つまり、DoEvents実行と同時にWindowsメッセージキューにたまった
タイマーメッセージは全て処理が開始されることになります
問題箇所の特定
巨大なシステムだと、このような問題を見つけるのは
中々大変なのでSpy++を使います。
Spy++はVisualStudioInstallerでC++コア機能からインストールできます。
インストール後はVisualStudioのツールにリンクが作成されます。
ただし、このツールは32bitプロセス用。
一見きちんと動くがメッセージがキャプチャされないのでハマりやすいです。
64bitプロセス用を使いたい場合、外部ツールとして別途登録しておくことをお勧めします。
※エラー検索が2つあるのはVisualStudio2022のバグっぽい
Spy++を起動したら次の作業を行います
- 双眼鏡アイコンをクリックする
- ファインダーツールの的を監視したいアプリにドラッグオンドロップする
- メッセージをチェックする
- OKを押す
監視するメッセージの設定を行う
今回はWM_TIMERメッセージのみを拾う設定にする
- メッセージタブからログオプションを選択する
- すべてクリアを選択後、WM_TIMERを追加する
- ウィンドウタブに移動し、同じプロセスウィンドウにチェックを入れる
- OKを押す
これで目的のWindowsメッセージが確認できるようになった
対策方法
[1]と[2]は必須で対応し、他は可能な範囲で全てやるのが良さそうです
[1]を行うことで再入による予期せぬ動作を防止します
[2]により、元々DoEventsを入れた動機である、UIスレッド制御をOSに返す時間を早めます
- [1] 再入を防ぐ。例えば、タイマー処理実行中フラグを用意したり、タイマー処理の入口でタイマーを止め、出口で動かす。この場合、タイマーのインターバルがタイマー処理終了後からの起算となる(※タイマーではなく独自のフラグで再入を防ぐ場合はスレッドセーフな方法をとる必要がある)
Private Sub uiTimer_Tick(sender As Object, e As EventArgs) Handles uiTimer.Tick
Try
uiTimer.Enabled = False
Debug.WriteLine("開始" + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString())
System.Threading.Thread.Sleep(2000)
Application.DoEvents()
Debug.WriteLine("終了" + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString())
Finally
uiTimer.Enabled = True
End Try
End Sub
-
[2] DoEventsを使わず、重い処理を非同期処理に切り出す
とはいえ、非同期処理がタイマー発火より時間が掛かる場合、非同期処理が溜まり続けていき動作重くなりそう。ごくまれに遅い処理があるとかなら問題なし。
また許容できる程度の負荷で済むならあり。 -
[3] インターバルを必要最低限になるように見直す(不具合発生頻度を減らすだけで根本解決にならない場合も多い)
-
[4] 重い処理を速くする(不具合発生頻度を減らすだけで根本解決にならない場合も多い)
補足
- フォームタイマー以外はWindowsメッセージキューを使用しません
DoEventsによる再入はありませんが、複数スレッドで同時実行される可能性はあります - Timers.Timerの興味深いケース