Fun with async: A Useful Approach

I recently wrote my first Windows App/WinUI 3 application, to start learning what will hopefully be Microsoft’s core desktop framework for some time. Because you can never have enough programming challenges I also decided to dive into using async methods to keep the UI snappy.

That was a much bigger step than I thought it would be. I must’ve stumbled through half a dozen different approaches for architecting async methods before I found one that seems generally useful. This post shares what I learned. Caveat: everything you read here may be a less than optimal approach. But it worked for me and, for lack of a better term, it feels robust.

Events Are Your Friend

There are multiple ways to return results from async methods and use them in your code. The obvious one is to just assign it to a variable and move on. That certainly works well for simple/local cases.

But I found it led me to write complex, hard-to-comprehend (let alone debug) code using the result when multiple parts of the app needed to use those results. What I found to be more intuitive was to use events which were processed by event handlers in the receiving code. The basic pattern looks like this:

// in a class defining a service to be consumed
//
public bool BeginUpdate()
{
    if (!_appConfig.IsValid)
    {
        _logger.Error("Configuration is invalid, cannot retrieve locations");
        return false;
    }

    if( _updating )
    {
        _logger.Warning("Location retrieval underway, cannot begin a new one");
        return false;
    }

    var request = new HistoryRequest<Location>(_appConfig, _logger)
    {
        Start = StartDate.UtcDateTime,
        End = EndDate.UtcDateTime
    };

    request.Status += OnRequestStatusChanged;

    Task.Run(async () =>
        {
            await request.ExecuteAsync();
            request.Status -= OnRequestStatusChanged;
        });

    return true;
}

private void OnRequestStatusChanged( object? sender, RequestEventArgs<History<Location>> args )
{
    _dQueue.TryEnqueue( () =>
    {
        switch( args.RequestEvent )
        {
            case RequestEvent.Started:
                OnStarted();
                break;

            case RequestEvent.Succeeded:
                OnSucceeded( args );
                break;

            case RequestEvent.Aborted:
                OnAborted( args );
                break;

            default:
                throw new InvalidEnumArgumentException( $"Unsupported {typeof( RequestEvent )} '{args.RequestEvent}'" );
        }
    } );
}

// in a class using the service
//
public SelectablePointViewModel(
    RetrievedPoints displayedPoints,
    BaseAppViewModel<AppConfig> appViewModel,
    CachedLocations cachedLocations,
    StatusMessage.StatusMessages statusMessages,
    IJ4JLogger logger
)
    : base(displayedPoints, appViewModel, statusMessages, logger)
{
    CachedLocations = cachedLocations;
    CachedLocations.CacheChanged += CachedLocationsOnCacheChanged;
    CachedLocations.TimeSpanChanged += CachedLocationsOnTimeSpanChanged;

    RefreshCommand = new RelayCommand(RefreshHandler);
    SetMapPoint = new RelayCommand<MapPoint>( SetMapPointHandler );

    if( !CachedLocations.Executed )
        DaysBack = AppViewModel.Configuration.DefaultDaysBack;
}

private void CachedLocationsOnCacheChanged(object? sender, CachedLocationEventArgs e)
{
    switch (e.Phase)
    {
        case RequestEvent.Started:
            StatusMessages.Message("Beginning locations retrieval").Indeterminate().Display();
            break;

        case RequestEvent.Aborted:
            StatusMessages.Message("Locations retrieval failed").Urgent().Enqueue();
            StatusMessages.DisplayReady();

            break;

        case RequestEvent.Succeeded:
            OnCacheUpdated();
            break;

        default:
            Logger.Error("Unsupported RequestEvent value '{0}'", e.Phase);
            break;
    }
}

The basic flow is define a method to launch the async activity (BeginUpdate) and then return. The async Task launched in the method will continue to do its thing in the background and raise events along the way, when it completes, when it aborts, etc.

The consuming class listens to those events and uses the provided results to update the UI.

Always Pay Attention to the Thread

An important part of this pattern is managing what’s done on which thread. Remember, the entire Windows App/WinUI user interface, like WPF (and I think UWP) before it, runs on a single thread. If you do too much work on that thread you’ll make your UI unresponsive.

But the flipside of that is that if you return results, via events, from a different thread you can’t directly update anything in the UI. If you try you’ll get an exception. Updates to the UI must be done from the UI thread alone.

Managing this requires shifting results from the “other” thread to the UI thread. You can do that either in the service class when it raises events or on the consuming side in response to an event raised on the “other” thread.

I’ve done it both ways, and generally found it better to switch things to the UI thread from within the service class when it raises events. That’s based on the “fire and forget” principle: once you fling a result out there — which you know will be used to update the UI — launch it from the UI thread. That way you won’t have to think about what thread your event handler is running on.

Mechanically this is simple to do. You just wrap the service methods in a call to the TryEnqueue method of an instance of DispatcherQueue (lines 36 – 55). You get that instance by calling DispatcherQueue.GetForCurrentThread(). I generally do this in the constructor of my service class and assign the value to a readonly variable.

All Those Visual Studio Windows

A side note related to threads: it’s easy to be ignorant of what thread any section of code is running on. In fact, I think it’s possible for the same code to run on different threads depending on how it’s invoked. But there’s nothing in the debugger display (that I’m aware of) to tell you which thread you’re on as you walk through your code.

Now, Visual Studio comes with so many windows that I can’t keep track of them. In fact, it’s fair to say there are quite a few that I’ve never had occasion to open, or don’t even have a clue what they’re used for. Which led me to stick with my tried and true subset of windows…and stop looking for “new” windows to help solve problems. Thread problems were definitely an example of this.

The answer is to pop open the Threads window in Visual Studio. It’s accessible from the Debug -> Windows menu (but only while actually debugging). This displays all the threads your app is using, highlighting the currently active one. You can then tell, at a glance, if you’re running on the UI thread (which is labeled as such).

Leave a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Archives
Categories