Debouncing asynchronous operations

With the introduction of the async and await keywords, asynchronous programming has finally become achievable by any .Net developer.  Building on the TPL, async and await extend the C# language specification to make task based programming relatively straight forward, and is a huge step forward from APM.

However, being able to write code that runs asynchronously is only the first step, thinking asynchronously is a whole new set of skills that developers have to master.  For example, how should you access a shared resource from asynchronous code?

There are some great resources out there and one of my favourite blogs is produced by Microsoft’s Stephen Toub who works with the PFXTeam.  A while ago I came across a real gem in this blog, the AsyncSemaphore, which allows us to create an AsyncLock; which, in turn, helps you answer the question above.

But what else can we use an AsyncLock for?  Lots of stuff!

One particularly useful use is to debounce an asynchronous function.  Imagine we have a long running asynchronous function that goes and gets the latest stock price from a web service, if we allow multiple threads to execute the method then it would result in many calls to the same web service, all of which would return the same answer.  A better approach is to run the method once and allow anyone else that asks for the result to wait for the first execution to complete, and return the same result.

Using AsyncLock we can create a class for that:

#region © Copyright Web Applications (UK) Ltd, 2013.  All rights reserved.
// Copyright (c) 2013, Web Applications UK Ltd
// All rights reserved.
// 
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//     * Redistributions of source code must retain the above copyright
//       notice, this list of conditions and the following disclaimer.
//     * Redistributions in binary form must reproduce the above copyright
//       notice, this list of conditions and the following disclaimer in the
//       documentation and/or other materials provided with the distribution.
//     * Neither the name of Web Applications UK Ltd nor the
//       names of its contributors may be used to endorse or promote products
//       derived from this software without specific prior written permission.
// 
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL WEB APPLICATIONS UK LTD BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#endregion

using System;
using System.Diagnostics.Contracts;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;

namespace WebApplications.Utilities.Threading
{
    /// 
    /// Debounces an asynchronous function.
    /// 
    public class AsyncDebouncedFunction
    {
        /// 
        /// The semaphore controls concurrent access to function execution.
        /// 
        private readonly AsyncLock _lock = new AsyncLock();

        /// 
        /// The asynchronous function to run.
        /// 
        private readonly Func<cancellationtoken, task<tresult="">> _function;

        /// 
        /// The duration ticks is the number of ticks to leave after a successfully completed function invocation, from the start of the last successful invocation.
        /// 
        private readonly long _durationTicks;

        /// 
        /// The gap ticks is the number of ticks to leave after a successfully completed function invocation, from the end of the last successful invocation.
        /// 
        private readonly long _minimumGapTicks;

        /// 
        /// The next run is the earliest tick that the function can be run again.
        /// 
        private long _nextRun;

        /// 
        /// The last result.
        /// 
        private TResult _lastResult;

        /// 
        /// Initializes a new instance of the  class.
        /// 
        ///The function.
        ///The duration is the amount of time the result of a successful execution is held, after the point a successful request was made.
        ///The minimum gap, is the time left after a successful execution before the function can be run again.
        public AsyncDebouncedFunction(
            Func function,
            TimeSpan duration = default(TimeSpan),
            TimeSpan minimumGap = default(TimeSpan))
            : this(token => function(), duration, minimumGap)
        {
            Contract.Requires(function != null);
            Contract.Requires(duration >= TimeSpan.Zero);
            Contract.Requires(minimumGap >= TimeSpan.Zero);
        }

        /// 
        /// Initializes a new instance of the  class.
        /// 
        ///The function.
        ///The duration is the amount of time the result of a successful execution is held, after the point a successful request was made.
        ///The minimum gap, is the time left after a successful execution before the function can be run again.
        public AsyncDebouncedFunction(
            Func<cancellationtoken, task<tresult="">> function,
            TimeSpan duration = default(TimeSpan),
            TimeSpan minimumGap = default(TimeSpan))
        {
            Contract.Requires(function != null);
            Contract.Requires(duration >= TimeSpan.Zero);
            Contract.Requires(minimumGap >= TimeSpan.Zero);

            // Calculate the gap ticks, based on stopwatch frequency.
            _durationTicks = duration.Ticks;
            _minimumGapTicks = minimumGap.Ticks;
            _function = function;
        }

        /// 
        /// Runs the function asynchronously.
        /// 
        ///The token.
        /// An awaitable task, that completes with the debounced result.
        /// If the function is currently running, this will wait until it completes (or
        ///  has been cancelled),
        /// but won't run the function again.  However, if the original function is cancelled, or errors,
        /// then it will run the function again immediately.
        /// 
        public async Task Run(CancellationToken token = default (CancellationToken))
        {
            // Record when the request was made.
            long requested = DateTime.UtcNow.Ticks;

            // Wait until the semaphore says we can go.
            using (await _lock.LockAsync(token))
            {
                // If we're cancelled, or we were requested before the next run date we're done.
                token.ThrowIfCancellationRequested();

                if (requested <= _nextRun)
                    return _lastResult;

                // Await on a task.
                TResult lastResult = await _function(token);

                // If we're cancelled we don't update our next run as we were unsuccessful.
                token.ThrowIfCancellationRequested();

                // Set the next time you can run the function.
                _lastResult = lastResult;
                _nextRun = Math.Max(requested + _durationTicks, DateTime.UtcNow.Ticks + _minimumGapTicks);
                return lastResult;
            }
        }
    }
}

This allows you to create an asynchronous function that will only have one instance running at any time, any multiple calls to the same function will all wait for the result of the currently executing function.  You can also use the

minimumGap

 and

duration

 parameters to cache the result of a successful execution for a period, this is great when you know that an expensive operation’s result doesn’t change too frequently, or that it’s not necessary to have real time, ‘fresh’ results (e.g. when serving a web page).

For example, the code below:

int a = 0;
Stopwatch s = Stopwatch.StartNew();
AsyncDebouncedFunction adf = new AsyncDebouncedFunction(
	async token => {
		int run = Interlocked.Increment(ref a);
		Console.WriteLine("{0}: Started {1}", s.Elapsed, run);
		await Task.Delay(1000, token);

		Console.WriteLine("{0}: Completed {1}", s.Elapsed, run);
		return run;
	});

try {
	await Task.WhenAll(
		adf.Run(new CancellationTokenSource(TimeSpan.FromSeconds(0.5)).Token)
			.ContinueWith(i => Console.WriteLine("{0}: Result {1}", s.Elapsed, i.Result), TaskContinuationOptions.OnlyOnRanToCompletion),
		adf.Run()
			.ContinueWith(i => Console.WriteLine("{0}: Result {1}", s.Elapsed, i.Result), TaskContinuationOptions.OnlyOnRanToCompletion),
		adf.Run()
			.ContinueWith(i => Console.WriteLine("{0}: Result {1}", s.Elapsed, i.Result), TaskContinuationOptions.OnlyOnRanToCompletion));
} catch (TaskCanceledException e) {
	Console.WriteLine(e.Message);
}

Produces the following output:

00:00:00.0007214: Started 1
00:00:00.5006508: Started 2
00:00:01.5147577: Completed 2
00:00:01.5151857: Result 2
00:00:01.5152295: Result 2
A task was canceled.

The code tries to run the function three times at once.  The first execution is set to timeout after 0.5s, whereas the second and third executions don’t have a timeout.  The function itself is set to take 1s, therefore the first task will be cancelled before it get’s the opportunity to complete (after 0.5s), which is OK, because the 2nd task will now start immediately, as the first task failed and there is no result yet.  The second task runs to completion, which means the third task (which was also waiting can make use of the result without running the function again.

Debouncing operations can have a huge impact on the performance of real world systems, so I hope you find this piece of code as useful as I do!

 

Related Images:

Comments 1