Background Agents - Part 2 of 3
In Part 1 we looked at a simple Windows Phone application that used a background agent to periodically show a toast and / or update a tile when a new tweet was found for a particular search term. The code we are discussing in this post can be found at the end of that post.
Part of the contract of a background agent is that you must call NotifyComplete or Abort exactly once when your agent has completed processing. This is pretty easy if you have a sequential set of synchronous steps to complete, like reading and writing from IsoStore, but it is harder when you have one or more asynchronous operations or when you need to throw work over to the Dispatcher thread, since you can't rely on things like try-catch blocks to handle errors.
It's possible (but not desirable) to try and serialize the asynchronous operations with simple WaitHandle operations, but doing that might mean you run out of your agent runtime before all the operations complete. Better to do them in parallel, and then have a simple way of waiting until they have all completed (successfully or otherwise) and then calling NotifyComplete / Abort as necessary.
Such was the case with the Twitter background agent. It performs network downloads to get tweets from twitter.com, and it also needs to dispatch to the UI thread to compose the background tile image. Initially I used a ManualResetEvent and did something like this:
ManualResetEvent evt; // Main agent code void AgentCode() { evt = new ManualResetEvent(false); DoAsyncWork(); evt.WaitOne(); evt.Reset(); DoMoreAsyncWork(); evt.WaitOne(); NotifyComplete(); } void DoAsyncWork() { // Lots of work on another thread... evt.Set(); } void DoMoreAsyncWork() { // Lots of work on another thread... evt.Set(); }
It works, but it's not terribly efficient. We could be doing the second bit of async work at the same time as the first one, especially if they both involve something like network access that spends most of its time waiting for bits anyway. What we need is a helper library to let us queue up a bunch of async work, set it all free, and then wait for them all to report done-ness. Now, you could consider using the Async CTP, but it's still a "technology preview" (ie, not a shipping product) and it has issues with background agents right now because the helper library references XNA (which is a no-no for agents).
Enter the AsyncWorkloadHelper library!
The AsyncWorkloadHelper library
The background agent project contains a library named AsyncWorkloadHelper. It is pretty simple; the public API looks like this:
Both classes are generic and require two types:
- TParam, the type of the parameter that you will pass to your async methods. Use object if you have varying parameter types
- TResult, the type of the return value of the async methods. Again, use object if you have varying return types
Using the library is very simple; here's some code from the twitter agent itself that generates live tile background images:
// Asynchronously generate the bitmap images (front and back) var workManager = new AsyncWorkManager<ExtendedTileData, string>(); var backgroundImageWorker = workManager.AddWorkItem(GetBackgroundImage, tileData); var backBackgroundImageWorker = workManager.AddWorkItem(GetBackBackgroundImage, tileData); // Wait for the async operations to complete workManager.WaitAll(); // Set the front of the tile if (backgroundImageWorker.Error == null) data.BackgroundImage = new Uri(ISOSTORE_SCHEME + backgroundImageWorker.Result); else data.BackgroundImage = EMPTY_URI;
It's pretty self-explanatory:
- Create an instance of the AsyncWorkManager with the appropriate types parameters
- Call AddWorkItem for each work item you intend to execute
- The first parameter is the delegate that will do the work
- The second parameter is the parameter to pass to the method
- The return value is a WorkloadInfo object you will use later
- Call WaitAll to wait for them all to complete (there's also a version with a timeout if you don't want to wait indefinitely)
- You could also listen for the WorkComplete event if you like
- Once work is done, check the Error property of the WorkloadInfo
- If it is null, the result is valid and you can get it via the Result property
- Otherwise, you can do something with the Error itself
That's it! The methods that are called by the worker - in this case, GetBackgroundImage and GetBackBackgroundImage - only have one responsibility: to call NotifySuccess or NotifyFailure when they are done. For example, here's the complete listing for GetBackgroundImage:
/// <summary> /// Asynchronously gets the background image for a specific tweet /// </summary> /// <param name="tileData">The tile data to add to the image for</param> /// <param name="token">The completion token</param> static void GetBackgroundImage(ExtendedTileData tileData, WorkloadInfo<ExtendedTileData, string> token) { // Must run on the dispatcher thread (due to use of BitmapImage) Deployment.Current.Dispatcher.BeginInvoke(delegate { try { BitmapImage bi = new BitmapImage(); bi.CreateOptions = BitmapCreateOptions.None; // Pull the standard background image out of our app package bi.SetSource(Application.GetResourceStream(new Uri("background.png", UriKind.Relative)).Stream); // Compose the image and write to disk string fileName = ComposeTileAndWriteToDisk(bi, FRONT_IMAGE_OPACITY, tileData.FrontText, "frontimage"); // Complete the async operation token.NotifySuccess(fileName); } catch (Exception ex) { token.NotifyFailure(ex); } }); }
This is pretty simple:
- The signature for the method is: TResult MethodName(TParam param, WorkloadInfo<TParam, TResult> token), where the the type params are dictated by the AsyncWorkManager that is being used
- In this case, TParam is ExtendedTileData and TResult is string.
- In this case, the code has to be dispatched to the UI thread since we'll be composing some bitmap images
- The async code is wrapped in a try-catch; if it fails we will call NotifyFailure with the error (which will propagate back to WorkloadInfo.Error)
- If the synchronous code fails, the error is automatically caught by the library
- Assuming nothing goes wrong, we call NotifySuccess, passing in the result of the function
Of course the "interesting" bit is tied up in that ComposeTileAndWriteToDisk method, but it is a synchronous method that, well, composes some content into a tile image and writes it to IsoStore, returning the full filename.
How it works
The implementation of the AsyncWorkManager is pretty basic. Of course you can check out the code for yourself, but in a nut-shell it works like this:
- There is a counter, outstandingItems, that counts how many remaining items there are to complete. This starts at zero.
- There is an event, startEvent, that is used to "hold back" all the work until the client is ready for them to start. This starts non-set.
- There is another event, completionEvent, that is used to wait until all work is complete. This starts non-set.
- Calling AddWorkItem increments outstandingItems and kicks off a ThreadPoolthread that first waits for startEvent to be set, then calls the associated method
- Calling WaitOne implicitly calls Start, then waits for the completionEvent to become set.
- Start sets startEvent, releasing all the threads
- As each work item completes, it calls NotifySuccess or NotifyFailure. These stash the appropriate values into the WorkloadInfo class and then call the internal CompleteWorkItem method
- CompleteWorkItem decrements outstandingItems, and if the value is zero then it sets the completionEvent
- The completionEvent releases the WaitOne call and also raises the WorkComplete event
So no rocket science, just a small set of framework code to make things easy for you.
That said, this code isn't particularly well tested, nor have I really used it outside of this project. For example, I don't really know if making the API generic has value (it did for me, since the methods I wanted to call had the same signature) but maybe that's too messy and object would just work better in general. I also don't know how it really performs in error conditions or with large numbers of work items (more than fit into the thread pool, for example)... use at your own risk!
As a reminder, you can get the project at Part 1. Next up, Part 3.
Comments
Anonymous
July 23, 2011
Love the updates, very much appreciated. :) I think "3. Call WaitOne to wait for them all to complete" should be "3. Call WaitAll to wait for them all to complete" in the explanation for the tile image updating process. Thanks!Anonymous
July 24, 2011
Martin, thanks for pointing out the error - fixed now ;-)