1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フォームタイマーとDoEvents

Last updated at Posted at 2024-12-28

まず最初に「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++コア機能からインストールできます。

1

インストール後はVisualStudioのツールにリンクが作成されます。
ただし、このツールは32bitプロセス用。
一見きちんと動くがメッセージがキャプチャされないのでハマりやすいです。
2

64bitプロセス用を使いたい場合、外部ツールとして別途登録しておくことをお勧めします。
3

※エラー検索が2つあるのはVisualStudio2022のバグっぽい

Spy++を起動したら次の作業を行います

  • 双眼鏡アイコンをクリックする
  • ファインダーツールの的を監視したいアプリにドラッグオンドロップする
  • メッセージをチェックする
  • OKを押す

4

監視するメッセージの設定を行う
今回はWM_TIMERメッセージのみを拾う設定にする

  • メッセージタブからログオプションを選択する
  • すべてクリアを選択後、WM_TIMERを追加する
  • ウィンドウタブに移動し、同じプロセスウィンドウにチェックを入れる
  • OKを押す

5

6

これで目的のWindowsメッセージが確認できるようになった
7

対策方法

[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の興味深いケース

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?