How/Why does KillTimer invalidate existing WM_TIME

2020-07-27 06:29发布

问题:

I was reading this answer to a question about how to schedule a TIMERPROC to run immediately. I was interested in this as I need to run a method immediately after the caller has finished executing its code synchronously.

So I came up with this skeleton:

Private Sub asyncProc(ByVal HWnd As LongPtr, ByVal message As WindowsMessage, ByVal timerID As LongPtr, ByVal tickCount As Long)
    Debug.Print "asyncProc called (should be called second)"
End Sub

Private Sub syncProc()
    Debug.Print "syncProc called (should be called first)"
End Sub

Sub test()
    If tryScheduleProc(AddressOf asyncProc, New Collection) Then
        syncProc
    Else
        Debug.Print "Unable to schedule proc"
    End If
End Sub

... and of course the next step was to design the tryScheduleProc function, which would setup a call to asyncProc with a unique id provided by the address of the New Collection Object (in practice this would be the callback's arguments).

Tests

My first approach was to copy the method set out in the linked answer; namely to post a WM_TIMER message to the Application.HWnd's message queue:

Private Function tryScheduleProc(byVal timerProc As LongPtr, ByVal arg As Object) As Boolean
    Debug.Print "Scheduling..."
    tryScheduleProc = PostMessage(Application.HWnd, WM_TIMER, objPtr(arg), timerProc)
End Function

However that prints

Scheduling...
syncProc called (should be called first)

i.e. the asyncProc is never called.


Thinking there might be something wrong with the message I switched to the traditional way of generating timer messages, SetTimer, only this time with a very short delay:

Private Function tryScheduleProc(ByVal timerProc As LongPtr, ByVal arg As Object) As Boolean
    Debug.Print "Scheduling..."
    Debug.Print "Create Timer:"; SetTimer(Application.HWnd, objPtr(arg), 0, timerProc)
    tightLoopDelay 100 'wait 100ms for timer to have elapsed definitely
    
    Dim tempMsg As tagMSG
    tryScheduleProc = PeekMessage(tempMsg, Application.HWnd, WM_TIMER, WM_TIMER, PM_NOREMOVE) 'force message to be posted
    KillTimer Application.HWnd, objPtr(arg)
End Function

Here, SetTimer creates a flag on the queue once it elapses, and PeekMessage forces this flag to be evaluated - this lets me destroy the timer, as KillTimer does not remove existing messages (and I confirmed this was the case with another peek after the KillTimer call - the message was there in the queue)

However once again the output in the immediate window shows the message has not caused the asyncProc to run.

Scheduling...
Create Timer: 482245352 
syncProc called (should be called first)

Where my confusion lies is in the following: if I put the call to KillTimer in the callback proc instead then everything works fine:

Private Sub asyncProc(ByVal HWnd As LongPtr, ByVal message As WindowsMessage, ByVal timerID As LongPtr, ByVal tickCount As Long)
    Debug.Print "asyncProc called (should be called second)"
    KillTimer HWnd, timerID
End Sub

outputs

Scheduling...
Create Timer: 477790208 
syncProc called (should be called first)
asyncProc called (should be called second)

Now as I understand it, the state of the thread's message queue should not change based on when I kill the timer (perhaps the timer will have elapsed again depending on how quickly my code executes, but there is no opportunity for other processes to alter the queue while the thread is busy is there?). Printing the queue confirms it contains one timer message as expected anyway.


So what I think is happening is that the WM_TIMER message is somehow being marked as invalid when the timerID it is associated with no-longer corresponds to a live timer. To confirm this I tried the original PostMessage approach, but with a temporary timer to validate the ID of the message:

Private Function tryScheduleProc(ByVal timerProc As LongPtr, ByVal arg As Object) As Boolean
    Debug.Print "Scheduling..."
    'make a validation timer - this won't expire for a long time
    Debug.Print "Create a validation timer:"; SetTimer(Application.HWnd, objPtr(arg), &H7FFFFFFF, timerProc) 'max interval time
    tryScheduleProc = PostMessage(Application.HWnd, WM_TIMER, objPtr(arg), timerProc)
End Function

This works as before

Scheduling...
Create a validation timer: 482246472 
syncProc called (should be called first)
asyncProc called (should be called second)

...but it is not a great solution as I still need to remember to kill the timer in the callback proc, even though it's not being used to generate messages!

Conclusion

I assume my issue is with the implementation of the message loop in Excel, or the DispatchMessage function. I don't know what either of these looks like internally or if they're documented anywhere - but I think Excel's implementation of the message loop is probably to blame because the Raymond Chen article on pretty much exactly this subject (orphaned timer messages) doesn't mention this issue. Also the MSDN documentation on DispatchMessage says

The MSG structure must contain valid message values

but I don't think that is saying the timerID must still be around, only that the tagMSG.message must have a valid value (that may be an incorrect interpretation ofc). Also the answer to that other question would have to be wrong if DispatchMessage was to blame. But perhaps the observed behaviour comes from somewhere else entirely - I am very new to WinApi & messages and so am not confident in the conclusions I've drawn.

Anyway, I'm wondering if anyone can shed some light on what's causing this behaviour, and indeed suggest some nice workaround for calling methods immediately after the code has run without too many limitations on what the async or sync methods must look like?