Asynchronous infinite loops instead of timers


Did it occur to you that an infinite loop, with async/await inside it, isn't really an infinite loop? It looks like one (which is usually bad) but because of the asynchrony, we know that it isn't executing the entire method at one time. Part executes now, then sometime later it periodically resumes – that sounds kind of like a timer!

Let’s say I have some UI work I need to do on a periodic basis. Normally I have two options

  1. Spin up a new Task which has a loop which does a bit of work, updates the UI, then sleeps for a period of time.
  2. Create a DispatchTimer, configure it properly and do the work in the Tick event

The first option gives us code with clear intent (“loop and update forever!”), but it introduces multiple delegates, potential multithreading complexities depending on the work being done and we still have to get back onto the UI thread to update the UI.

void DoWorkPollingTask()
{
    Task.Run(async () =>
    {
        while (true)
        {
            // do the work in the loop
            string newData = DateTime.Now.ToLongTimeString();

            // update the UI on the UI thread
            Dispatcher.Invoke(() => txtTicks.Text = "TASK - " + newData);

            // don't run again for at least 200 milliseconds
            await Task.Delay(200);
        }
    });
}

whereas the second option, while keeping us on a single thread (the UI thread) spreads our intent across multiple methods because the "repeat" behavior is separate from the "work" behavior (i.e. the only way we know this is a periodic activity is that the method is named _timer_Tick) and may make for more difficult maintenance if it gets more complicated.

DispatcherTimer _timer = new DispatcherTimer();

void DoWorkTimer()
{
    _timer.Interval = TimeSpan.FromMilliseconds(200);
    _timer.Tick += _timer_Tick;
    _timer.IsEnabled = true;
}

void _timer_Tick(object sender, EventArgs e)
{
    // do the work in the loop
    string newData = DateTime.Now.ToLongTimeString();

    // update the UI on the UI thread
    txtTicks.Text = "TIMER - " + newData;
}

There is a third option. Use an asynchronous loop. Even though this looks like an infinite loop, its really not. Because of the await of the delay Task, this method yields control back to the its caller periodically. If this weren’t an async method with a valid asynchronous operation, it would be a synchronous method and would deadlock the UI when we called it.

But since it is not, it works as needed and gives us both a very clear intent of what the code is supposed to do as well as keeps us on the UI thread the whole time (the magic of await!) so we don't have multithreaded issues or multiple delegates/callbacks.

private async Task DoWorkAsyncInfiniteLoop()
{
    while (true)
    {
        // do the work in the loop
        string newData = DateTime.Now.ToLongTimeString();

        // update the UI
        txtTicks.Text = "ASYNC LOOP - " + newData;

        // don't run again for at least 200 milliseconds
        await Task.Delay(200);
    }
}

The real trick to making this work though, is not in the DoWorkAsyncInfiniteLoop method. Its how we call it. You do not await the call to it.

private void bttnStart_Click(object sender, RoutedEventArgs e)
{
    // invoke loop method but DO NOT await it
    DoWorkAsyncInfiniteLoop();
}

This will start the async loop but we do not want our code to await here for it to finish (because it won’t). We treat it as “fire and forget.”

This will also cause a green squiggle in Visual Studio because “Hey you forget to await this obviously async method!!!” We can ignore that, or just stash the returned task into a local variable so it doesn’t complain. What I will not do to “fix” the green squiggle is make the looping method return void instead of Task. Always return a Task, you never know who might want it in the future (maybe a future change also introduces a cancellation feature).

Other thoughts:

  • Should I write code this way? As always, it depends. But having one more option doesn't hurt.
  • Of course these don’t have to be infinite loops, we could institute some kind of flag or cancellation mechanism, but this way makes for simpler example code and a more interesting blog title!
  • Don’t do this if you have a large amount of work to do. This is all happening on the UI thread so each time around the loop, its grabbing that UI thread and using it (which means the UI is not responsive at that time). If the work is significant, then Task.Run is a probably better option to offload the work onto another thread.
  • The simple example here doesn't prevent reentrancy. If something calls the method again, you will get a second  loop running in addition to the original loop. It won't be parallel (still only on the UI thread) but will be interleaved with the rest of the UI thread work.

The above sample source is available on my GitHub.

Comments (2)

  1. A couple of comments:

    1) With “fire and forget” work, any exceptions are ignored. So, for “top-level” loops like this, it’s a good policy to always have a try/catch around the entire method.

    2) Bear in mind that a timer fires on a strictly periodic basis and resets immediately, so if you’re doing 50ms of work every 200ms, with a timer your work would be executed at t+0, t+200, t+400, t+600, etc. However, the basic async loop waits *between* work, so in the same scenario with an async loop, your work would be executed at t+0, t+250, t+500, t+750, etc.

    1. BenWilli says:

      Yes, those are important points on both counts.

      For the timing, I’ve noticed an interesting trend in the code I’ve reviewed from customers. They often will disable the timer when they enter the event handler and re-enable the timer at the end of the delegate. I’m not sure why this is so (its also just my own anecdotal experience) but that does create the “start the next work 200ms after I’m done working now” behavior instead of the “try to start my work every 200ms.” Either way it is an important logical difference, especially if your work is a significant fraction of 200ms.

      thanks!

Skip to main content