Search code examples
c#windows-store-apps

Why Windows.System.Threading.ThreadPoolTimer.Cancel() doesn't work


UPDATE: This works in Windows 10 properly.

Here is a simple example:

    void testcase()
    {
         if (myTimer != null)
             myTimer.Cancel();

         myTimer = ThreadPoolTimer.CreateTimer(
             t => myMethod(),
             TimeSpan.FromMilliseconds(4000)
         );
    }

    void myMethod()
    {
         myTimer = null;
         //some work
    }

What it should do is ensure that myMethod cannot be called more frequent than once in 4s and that myMethod shouldn't be called if there is already a newer call to testcase. Something similar with .net timers on desktop was possible. However, new call to testcase doesn't prevent previously scheduled myMethods from running. I have a simple workaround by adding integer callid parameter to myMethod and keeping track of it. But this above should work and it doesn't.

Am I doing something wrong? Does anyone have also any better idea on how to do this?


Solution

  • What you're looking for is called debouncing, at least in javascript.

    A simple way to achieve it is to use the System.Threading.Timer instead, which has a handy Change used to reset it.

    If you want to abstract it into your own timer class, it would look something like:

    public class DebounceTimer : IDisposable
    {
        private readonly System.Threading.Timer _timer;
        private readonly int _delayInMs;
    
        public DebounceTimer(Action callback, int delayInMs)
        {
            _delayInMs = delayInMs;
    
            // the timer is initially stopped
            _timer = new System.Threading.Timer(
                callback: _ => callback(),
                state: null,
                dueTime: System.Threading.Timeout.Infinite, 
                period: System.Threading.Timeout.Infinite);
        }
    
        public void Reset()
        {
            // each call to Reset() resets the timer
            _timer.Change(
                dueTime: _delayInMs,
                period: System.Threading.Timeout.Infinite);
        }
    
        public void Dispose()
        {
            // timers should be disposed when you're done using them
            _timer.Dispose();
        }
    }
    

    Your test case would then become:

    private DebounceTimer _timer;
    
    void Init()
    {
        // myMethod will be called 4000ms after the
        // last call to _timer.Reset()
    
        _timer = new DebounceTimer(myMethod, 4000);
    }
    
    void testcase()
    {
        _timer.Reset();
    }
    
    void myMethod()
    {
        //some work
    }
    
    public void Dispose()
    {
        // don't forget to cleanup when you're finished testing
        _timer.Dispose();
    }
    

    [Update]

    From your comments, it seems like you'd like to change the callback method with each reset, and only have the last one invoked. If that's the case, you can change the code to something like:

    class DebounceTimer : IDisposable
    {
        private readonly System.Threading.Timer _timer;
        private readonly int _delayInMs;
        private Action _lastCallback = () => { };
    
        public DebounceTimer(int delayInMs)
        {
            _delayInMs = delayInMs;
    
            // the timer is initially stopped
            _timer = new System.Threading.Timer(
                callback: _ => _lastCallback(),
                state: null,
                dueTime: System.Threading.Timeout.Infinite, 
                period: System.Threading.Timeout.Infinite);
        }
    
        public void Reset(Action callback)
        {
            _timer.Change(dueTime: _delayInMs, period: System.Threading.Timeout.Infinite);
    
            // note: no thread synchronization is taken into account here,
            // a race condition might occur where the same callback would
            // be executed twice
            _lastCallback = callback;
        }
    
        public void Dispose()
        {
            _timer.Dispose();
        }
    }
    

    When calling the Reset method, you can use a lambda to capture various method calls (not only Action methods):

    void testcase()
    {
        _timer.Reset(() => myMethod());
    }
    
    void othertestcase()
    {
        // it's still a parameterless action, but it
        // calls another method with two parameters
        _timer.Reset(() => someOtherMethod(x, y));
    }
    

    As stated in the comments for the second timer snippet, the code is not thread safe, because the timer handler may already be running (or just about to run) on a separate thread while the callback reference is being changed inside the Reset method, meaning that the same callback would be executed twice.

    A slightly more complex solution would be to lock while changing the callback, and make an additional check if enough time has elapsed since the last call to reset. The final code would then look like this (there might be other ways to synchronize, but this one is pretty straightforward imho):

    class DebounceTimer : IDisposable
    {
        private readonly System.Threading.Timer _timer;
        private readonly int _delayInMs;
        private readonly object _lock = new object();
        private DateTime _lastResetTime = DateTime.MinValue;
    
        private Action _lastCallback = () => { };
    
        public DebounceTimer(int delayInMs)
        {
            _delayInMs = delayInMs;
    
            // the timer is initially stopped
            _timer = new System.Threading.Timer(
                callback: _ => InvokeIfTimeElapsed(),
                state: null,
                dueTime: System.Threading.Timeout.Infinite, 
                period: System.Threading.Timeout.Infinite);
        }
    
        private void InvokeIfTimeElapsed()
        {
            Action callback;
            lock (_lock)
            {
                // if reset just happened, skip the whole thing
                if ((DateTime.UtcNow - _lastResetTime).TotalMilliseconds < _delayInMs)
                    return;
                else
                    callback = _lastCallback;
            }
    
            // if we're here, we are sure we've got the right callback - invoke it.
            // (even if reset happens now, we captured the previous callback 
            // inside the lock)
    
            callback();
        }
    
        public void Reset(Action callback)
        {
            lock (_lock)
            {
                // reset timer
                _timer.Change(
                    dueTime: _delayInMs,
                    period: System.Threading.Timeout.Infinite);
    
                // save last reset timestamp
                _lastResetTime = DateTime.UtcNow;
    
                // set the new callback
                _lastCallback = callback;
            }
        }
    
        public void Dispose()
        {
            _timer.Dispose();
        }
    }