Windows 计时器队列计时器在 .NET 断点后继续

Windows Timer Queue Timer continues after .NET breakpoint

我开发了一个 .NET 应用程序,它必须轮询 120 Hz 的压力设备。标准 .NET 计时器似乎只能达到 60 Hz,因此我决定通过 PInvoke 使用 Win32 CreateTimerQueueTimer API。它工作得很好,但调试体验非常糟糕,因为当我在程序暂停时单步执行程序时甚至会触发计时器。我在 C 和 C# 中写了一个最小的例子,不希望的行为只发生在 C# 上。当调试器暂停程序时,C 程序不会创建计时器回调线程。谁能告诉我,我可以做些什么来在 C# 中实现相同的调试行为?

C代码:

#include <stdio.h>
#include <assert.h>
#include <Windows.h>

int counter = 0;

VOID NTAPI callback(PVOID lpParameter, BOOLEAN TimerOrWaitFired)
{
    printf("Just in time %i\n", counter++);
}

int main()
{
    HANDLE timer;
    BOOL success = CreateTimerQueueTimer(&timer, NULL, callback, NULL, 0, 1000, WT_EXECUTEDEFAULT);
    assert(FALSE != success); // set breakpoint at this line and wait 10 seconds
    Sleep(1000);
    success = DeleteTimerQueueTimer(NULL, timer, NULL); // step to this line
    assert(FALSE != success);
    return 0;
}

C result

等效的 C# 代码:

using System;
using System.Runtime.InteropServices;

class TimerQueue
{
    delegate void WAITORTIMERCALLBACK(IntPtr lpParameter, bool TimerOrWaitFired);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    static extern bool CreateTimerQueueTimer(
        out IntPtr phNewTimer,
        IntPtr TimerQueue,
        WAITORTIMERCALLBACK Callback,
        IntPtr Parameter,
        uint DueTime,
        uint Period,
        uint Flags);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    static extern bool DeleteTimerQueueTimer(
        IntPtr TimerQueue,
        IntPtr Timer,
        IntPtr CompletionEvent);

    static int counter = 0;

    static void Callback(IntPtr lpParameter, bool TimerOrWaitFired)
    {
        Console.WriteLine("Just in time {0}", counter++);
    }

    static void Main(string[] args)
    {
        WAITORTIMERCALLBACK callbackWrapper = Callback;
        IntPtr timer;
        bool success = CreateTimerQueueTimer(out timer, IntPtr.Zero, callbackWrapper, IntPtr.Zero, 0, 1000, 0);
        System.Diagnostics.Debug.Assert(false != success); // set breakpoint at this line and wait 10 seconds
        System.Threading.Thread.Sleep(1000);
        success = DeleteTimerQueueTimer(IntPtr.Zero, timer, IntPtr.Zero); // step to this line
        System.Diagnostics.Debug.Assert(false != success);
    }
}

C# result

顺便说一句,我知道从多个线程使用不受保护的计数器变量时会出现竞争条件,现在这并不重要。

休眠一秒钟意味着与断点命中后的等待无关,这似乎是必要的,因为回调不会立即在进程中排队,即使在调试器中步进程序时也是如此,但只是在短暂的延迟之后。

对 DeleteTimerQueueTimer 的调用并不是显示我的问题所必需的,因为它发生在执行此行之前。

您应该能够进入调试->Windows->线程windows并在单步执行时冻结所有线程。

由于C语言可以直接访问系统(kernel32)的函数,所以相对于C#来说更直接,访问也更不安全。 C# 在 运行 时编译,所有对外界的调用(DLL/COM),它必须使用 OS (Windows).

提供的 Wrapper

正如 PhillipH 所写:在断点期间检查线程 window。在 C# 代码中,您会看到像 RunParkingWindow.NET SystemEvent 这样的线程。

与 C 程序相比,只有 1 个线程 - 您刚刚停止调试的线程。

意识到这一点你会明白,调试中的断点只会停止你当前所在的线程,但其他线程会进一步运行。这也意味着 Wrappers provided by OS 仍在监听任何事件并将它们排入队列。一旦您的应用程序继续 运行,他们将全力以赴,回调方法将完成剩下的工作。

我试图找到任何有数据流的相关文章/图片,但我没有成功。请将以上文字视为意见而非技术段落。

我记得我 4 年前问过这个问题,我想我现在知道答案了:.NET 中的计时器 classes 在内部只使用一次性计时器。因此,当您在断点处暂停程序时,重新设置一次性计时器的代码不会 运行 因此在您让 program/debugger 继续之前不会有回调堆积。

几个月前,我编写了自己的定时器 class,它可以达到高达 1000 Hz 的回调频率。它使用重复设置一次性定时器和忙等待的组合。忙等待会消耗 CPU 个周期,但有助于减少 Windows 线程调度程序引入的抖动。

代码如下:

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;

/// <summary>
/// Executes a function periodically.
/// Timer expirations are not queued during debug breaks or
/// if the function takes longer than the period.
/// </summary>
/// <remarks>
/// Uses a Windows one-shot timer together
/// with busy waiting to achieve good accuracy
/// at reasonable CPU usage.
/// Works even on Windows 10 Version 2004.
/// </remarks>
public sealed class Timer : IDisposable
{
    /// <summary>
    /// Statistics:
    /// How much time was 'wasted' for busy waiting.
    /// Does not include time passed by using a system timer.
    /// </summary>
    public TimeSpan TimeSpentWithBusyWaiting => new TimeSpan(Interlocked.Read(ref timeSpentWithBusyWaiting));
    private long timeSpentWithBusyWaiting = 0;

    private readonly TimeCaps timeCaps;
    private readonly Func<bool> function;
    private readonly TimeSpan period;
    /// <summary>
    /// We must keep a reference to this callback so that it does not get garbage collected.
    /// </summary>
    private readonly TIMECALLBACK callback;
    private readonly Stopwatch stopwatch = new Stopwatch();
    private volatile bool stopTimer = false;
    private ManualResetEvent timerStopped = new ManualResetEvent(false);
    private TimeSpan desiredNextTime;

    /// <summary>
    /// Immediately starts the timer.
    /// </summary>
    /// <param name="function">
    /// What to do after each <paramref name="period"/>.
    /// The timer will stop if the function returns <see langword="false"/>.
    /// </param>
    /// <param name="period">
    /// How long to wait between executing each <paramref name="function"/>.
    /// </param>
    public Timer(Func<bool> function, TimeSpan period)
    {
        uint? timerDelay = TimeSpanToPositiveMillisecondsWithRoundingDown(period);
        if (timerDelay == null)
        {
            throw new ArgumentOutOfRangeException(nameof(period));
        }

        uint timeGetDevCapsErrorCode = TimeGetDevCaps(out timeCaps, (uint)Marshal.SizeOf(typeof(TimeCaps)));
        if (timeGetDevCapsErrorCode != 0)
        {
            throw new Exception($"{nameof(TimeGetDevCaps)} returned error code {timeGetDevCapsErrorCode}");
        }
        Debug.Assert(timeCaps.wPeriodMin >= 1);

        this.function = function ?? throw new ArgumentNullException(nameof(function));
        this.period = period;
        callback = new TIMECALLBACK(OnTimerExpired);

        TimeBeginPeriod(timeCaps.wPeriodMin);

        stopwatch.Start();

        Schedule(desiredNextTime = period, forceTimer: true);
    }

    /// <summary>
    /// Does not cancel the timer and instead
    /// blocks until the function passed to
    /// the constructor returns <see langword="false"/>.
    /// </summary>
    public void WaitUntilFinished()
    {
        if (timerStopped != null)
        {
            timerStopped.WaitOne();
            timerStopped.Dispose();
            timerStopped = null;
            TimeEndPeriod(timeCaps.wPeriodMin);
        }
    }

    /// <summary>
    /// Stops timer and blocks until the
    /// last invocation of the function has finished.
    /// </summary>
    public void Dispose()
    {
        stopTimer = true;
        WaitUntilFinished();
    }

    private void OnTimerExpired(
        uint uTimerID,
        uint uMsg,
        UIntPtr dwUser,
        UIntPtr dw1,
        UIntPtr dw2)
    {
        while (!stopTimer)
        {
            TimeSpan startOfBusyWaiting = stopwatch.Elapsed;
            TimeSpan endOfBusyWaiting = desiredNextTime;
            TimeSpan timeThatWillBeSpentWithBusyWaiting = endOfBusyWaiting - startOfBusyWaiting;
            if (timeThatWillBeSpentWithBusyWaiting > TimeSpan.Zero)
            {
                Interlocked.Add(ref timeSpentWithBusyWaiting, timeThatWillBeSpentWithBusyWaiting.Ticks);
            }

            if (desiredNextTime > stopwatch.Elapsed)
            {
                while (desiredNextTime > stopwatch.Elapsed)
                {
                    // busy waiting until time has arrived
                }
                desiredNextTime += period;
            }
            else
            {
                // we are too slow
                desiredNextTime = stopwatch.Elapsed + period;
            }

            bool continueTimer = function();
            if (continueTimer)
            {
                if (Schedule(desiredNextTime, forceTimer: false))
                {
                    return;
                }
            }
            else
            {
                stopTimer = true;
            }
        }
        timerStopped.Set();
    }

    /// <param name="desiredNextTime">
    /// Desired absolute time for next execution of function.
    /// </param>
    /// <param name="forceTimer">
    /// If <see langword="true"/>, a one-shot timer will be used even if
    /// <paramref name="desiredNextTime"/> is in the past or too close to
    /// the system timer resolution.
    /// </param>
    /// <returns>
    /// <see langword="true"/> if timer was set or <paramref name="forceTimer"/> was <see langword="true"/>.
    /// <see langword="false"/> if <paramref name="desiredNextTime"/> was in the past.
    /// </returns>
    /// <remarks>
    /// Increases accuracy by scheduling the timer a little earlier and
    /// then do busy waiting outside of this function.
    /// </remarks>
    private bool Schedule(TimeSpan desiredNextTime, bool forceTimer)
    {
        TimeSpan currentTime = stopwatch.Elapsed;
        TimeSpan remainingTimeUntilNextExecution = desiredNextTime - currentTime;
        uint? timerDelay = TimeSpanToPositiveMillisecondsWithRoundingDown(remainingTimeUntilNextExecution);
        timerDelay =
            timerDelay < timeCaps.wPeriodMin ? timeCaps.wPeriodMin :
            timerDelay > timeCaps.wPeriodMax ? timeCaps.wPeriodMax :
            timerDelay;
        if (forceTimer && timerDelay == null)
        {
            timerDelay = timeCaps.wPeriodMin;
        }
        if (forceTimer || timerDelay >= timeCaps.wPeriodMin)
        {
            // wait until next execution using a one-shot timer
            uint timerHandle = TimeSetEvent(
                timerDelay.Value,
                timeCaps.wPeriodMin,
                callback,
                UIntPtr.Zero,
                0);
            if (timerHandle == 0)
            {
                throw new Exception($"{nameof(TimeSetEvent)} failed");
            }
            return true;
        }
        else // use busy waiting
        {
            return false;
        }
    }

    /// <returns><see langword="null"/> if <paramref name="timeSpan"/> is negative</returns>
    private static uint? TimeSpanToPositiveMillisecondsWithRoundingDown(TimeSpan timeSpan)
    {
        if (timeSpan.Ticks >= 0)
        {
            long milliseconds = timeSpan.Ticks / TimeSpan.TicksPerMillisecond;
            if (milliseconds <= uint.MaxValue)
            {
                return unchecked((uint)milliseconds);
            }
        }
        return null;
    }

    private delegate void TIMECALLBACK(
        uint uTimerID,
        uint uMsg,
        UIntPtr dwUser,
        UIntPtr dw1,
        UIntPtr dw2);

    // https://docs.microsoft.com/en-us/previous-versions//dd757634(v=vs.85)
    //
    // This is the only timer API that seems to work for frequencies
    // higher than 60 Hz on Windows 10 Version 2004.
    //
    // The uResolution parameter has the same effect as
    // using the timeBeginPeriod API, so it can be observed
    // by entering `powercfg.exe /energy /duration 1` into
    // a command prompt with administrator privileges.
    [DllImport("winmm", EntryPoint = "timeSetEvent")]
    private static extern uint TimeSetEvent(
        uint uDelay,
        uint uResolution,
        TIMECALLBACK lpTimeProc,
        UIntPtr dwUser,
        uint fuEvent);

    [DllImport("winmm", EntryPoint = "timeBeginPeriod")]
    private static extern uint TimeBeginPeriod(
        uint uPeriod);

    [DllImport("winmm", EntryPoint = "timeEndPeriod")]
    private static extern uint TimeEndPeriod(
        uint uPeriod);

    [DllImport("winmm", EntryPoint = "timeGetDevCaps")]
    private static extern uint TimeGetDevCaps(
        out TimeCaps ptc,
        uint cbtc);

    [StructLayout(LayoutKind.Sequential)]
    private struct TimeCaps
    {
        public uint wPeriodMin;
        public uint wPeriodMax;
    }
}