Async Tasks and cancellation – Not always so easy

A while ago, I explained briefly how I asynchronously populate and cancel thumbnails in the file browser form of my application. But cancellation turned out to be more difficult than I initially thought. That is, if I changed the current directory multiple times in very rapid succession, things would get messy: Cancellation didn’t happen fast enough and thumbnails for the wrong directory might show up; then the code that was supposed to clear the thumbnails from the previous directory would finally end up clearing all the thumbnails, even for the current directory. While much of the issues are specific to my application, the basic process of cancelling Tasks is not, so I present my solution to the cancellation problems of my application here, as well as the way I thought through it, in the hope that it may help others.

Again, the source, for this and a whole bunch of other stuff, is here: RomyView.zip

The gist of the thumbnail populating design is this:

  1. Thumbnails for a number of supported files (images, videos, text files, zip files and directories) are always displayed for the current directory.
  2. Thumbnails are added asynchronously in batches.
  3. Each thumbnail control is responsible for fetching it’s own image. To do this, it’s Name is set initially to the file path, then later, when the batch is added, it’s Path is set, triggering the asynchronous loading of the image.
  4. Changing the directory at any point must cancel all the thumbnails (from loading their images) and remove them from the container, into an object pool for reuse (unless a max allowed count is reached, in which case they are disposed). At this point, thumbnails for the newly selected directory must be shown, starting the whole process again.

My first mistake (not shown in the code here) was always clearing all the current thumbnails on a directory change. Now it clears only the thumbnails for the current file paths. This happens immediately on changing the directory, before adding the new thumbnails.

My next mistake was that each thumbnail control had its own unique CancellationTokenSource. Instead, when the batches are added, all the thumbnails added for that directory now get passed the same CancellationTokenSource.

Here is how the browser form adds thumbnails:

Browser.BatchAddThumbnailsAsync
  1. /// <summary>Add all the <b>Thumbnail</b> instances for the current directory, in batches.</summary>
  2. private async Task BatchAddThumbnailsAsync(string directory, params string[] files)
  3. {
  4.     var filesList = files.ToList();
  5.  
  6.     Cursor.Current = Cursors.WaitCursor;
  7.     try
  8.     {
  9.         /* Group the thumbnails in batches, where the batch size is approximately
  10.          * the number of thumbnails that can be visible on the panel at one time. */
  11.         var thumsPerRow = (thumbnailPanel.Width – thumbnailPanel.Margin.Left – thumbnailPanel.Margin.Right) / (thumbSize.Width + 18); // 2 * (internalPadding + internalSpacing + margin.Left + margin.Right)
  12.         var thumsPerColumn = (thumbnailPanel.Height – thumbnailPanel.Margin.Top – thumbnailPanel.Margin.Bottom) / (thumbSize.Height + 34); // 1 line textHeight + 2 * (internalPadding + internalSpacing + margin.Top + margin.Bottom)
  13.         var maxBatchItems = thumsPerRow * thumsPerColumn;
  14.         var batchSize = Math.Min(maxBatchItems, filesList.Count);
  15.  
  16.         var batchQuery = from file in filesList
  17.                          group file by filesList.IndexOf(file) / batchSize;
  18.  
  19.         /* Use the same CancellationTokenSource for all thumbnails per directory,
  20.          * so that canceling it for a directory change cancels it for all. */
  21.         var cts = new CancellationTokenSource();
  22.  
  23.         try
  24.         {
  25.             foreach (var batch in batchQuery)
  26.             {
  27.                 var thumbBatch = Task.Factory.StartNew(() =>
  28.                 {
  29.                     return batch.Select<string, Thumbnail>(f =>
  30.                     {
  31.                         var fileInfo = IO.Exists(f);
  32.  
  33.                         if (!Path.GetDirectoryName(f).EqualsIgnoreCultureAndCase(IO.CurrentDirectory))
  34.                             cts.Cancel();
  35.  
  36.                         cts.Token.ThrowIfCancellationRequested();
  37.  
  38.                         if (fileInfo.Item1)
  39.                         {
  40.                             var thumb = thumbnailPanel.CurrentDirectoryThumbnailFromObjectPool();
  41.                             thumb.CancellationTokenSource = cts;
  42.                             thumb.FileSystemType = fileInfo.Item2 ? FileSystemType.Directory : FileSystemType.File;
  43.                             thumb.Name = f;
  44.                             InitializeThumbnailEvents(thumb);
  45.                             return thumb;
  46.                         }
  47.                         return null;
  48.                     }).Where(t => t != null);
  49.                 }, cts.Token, TaskCreationOptions.None, IO.TaskSchedulers.WorkStealingTaskScheduler);
  50.  
  51.                 await thumbBatch.YieldTo(UiTaskScheduler);
  52.                 thumbnailPanel.ThumbnailControls.AddRange(await thumbBatch);
  53.  
  54.                 await default(IdleAwaiter);
  55.             }
  56.         }
  57.         catch (OperationCanceledException) { }
  58.     }
  59.     finally { Cursor.Current = Cursors.Default; }
  60. }

 

Now, if the directory is changed while this method runs, it may cancel and abort adding thumbnails, while theoretically also cancelling each and every thumbnail already added thus far. But realistically, this hardly ever happens. So we look at the next relevant bit of code, which is ThumbnailControlCollection.AddRange. (ThumbnailControlCollection is a custom collection that wraps the ControlCollection of my custom FlowLayoutPanel.)

ThumbnailControlCollection.AddRange
  1. /// <summary>Adds a collection of thumbnails to the collection, in sorted order.</summary>
  2. public void AddRange(IEnumerable<Thumbnail> thumbnails)
  3. {
  4.     var thumbs = thumbnails.ToArray();
  5.  
  6.     if (thumbs.Length > 0)
  7.     {
  8.         panel.SuspendLayout();
  9.         try
  10.         {
  11.             panel.Controls.AddRange(thumbs);
  12.  
  13.             // Add these to the inherited collection as well; otherwise it will behave strangely.
  14.             this.Items.AddRange<Thumbnail>(thumbs);
  15.  
  16.             foreach (var thumb in thumbs)
  17.             {
  18.                 if (thumb.RequiresCurrentDirectory && !Path.GetDirectoryName(thumb.Name).EqualsIgnoreCultureAndCase(IO.CurrentDirectory) && thumb.CancellationTokenSource.Token.CanBeCanceled)
  19.                     thumb.CancellationTokenSource.Cancel();
  20.  
  21.                 thumb.CancellationTokenSource.Token.ThrowIfCancellationRequested();
  22.  
  23.                 /* Make sure the thumbs are sorted correctly. The files were sorted as they
  24.                  * were found, so we just use the order according to the file collection. */
  25.                 var index = 0;
  26.                 try
  27.                 {
  28.                     if (IO.Paths.Count > 0 && IO.Paths.Contains(thumb.Name) &&
  29.                         panel.Controls.GetChildIndex(thumb, false) != (index = IO.Paths.IndexOf(thumb.Name)))
  30.                     {
  31.                         SetChildIndex(thumb, index);
  32.                     }
  33.                     thumb.Path = thumb.Name;
  34.                 }
  35.                 catch (OperationCanceledException) { throw; }
  36.                 catch (Exception ex) { ex.Log(); }
  37.             }
  38.         }
  39.         catch (OperationCanceledException) { RemoveRange(thumbs); }
  40.         catch (Exception ex) { ex.Log(); }
  41.         finally
  42.         {
  43.             panel.ResumeLayout();
  44.             panel.Invalidate();
  45.         }
  46.     }
  47. }

 

Unfortunately, this turns out not to be the right place to detect that the directory has changed. Even with the check in the foreach loop that cancels on a directory change, I have never seen it get called. (I’ve left it in anyway though. It can’t do any harm, and if the directory change is ever detected at this point, it can then avoid adding the thumbnails to the panel, and short-circuit the code.) By this point I was starting to lose hope. I click to change the directory, and the application freezes for 2 minutes, before eventually cancelling the thumbnail image loading and populating the new ones. And then it occurred to me: Do the sensible thing – debug – find where the code is getting stuck. And in retrospect it was obvious.

Recall point 3 of my thumbnail image loading design: Setting the Path property (above) triggers the loading of the image. Like so:

Thumbnail.Path and Thumbnail.LoadImage
  1. public string Path
  2. {
  3.     get { return path; }
  4.     set
  5.     {
  6.         path = value;
  7.  
  8.         if (!string.IsNullOrEmpty(path))
  9.         {
  10.             InitializeSize();
  11.             LoadImage();
  12.         }
  13.         else // Reset
  14.         {
  15.             ClearImage();
  16.  
  17.             displayText = null;
  18.             CacheItem = null;
  19.             Valid = true; // Only set false for invalid thumbnails. This resets it to the initial state.
  20.         }
  21.     }
  22. }
  23.  
  24. private async void LoadImage()
  25. {
  26.     if (Parent == null) // Async method called after this instance was already removed from container.
  27.         return;
  28.     try
  29.     {
  30.         /* Detect if this image is being loaded (by the Browser form’s BatchAddThumbnailsAsync method) and the user
  31.          * just changed directories. If so, cancel (Quietly. This thumbnail will be returned to the object pool). */
  32.         if (RequiresCurrentDirectory &&
  33.         (path == null || !System.IO.Path.GetDirectoryName(path).EqualsIgnoreCultureAndCase(IO.CurrentDirectory)))
  34.             CancellationTokenSource.Cancel();
  35.  
  36.         CancellationTokenSource.Token.ThrowIfCancellationRequested();
  37.  
  38.         if (IsCached)
  39.             LoadCachedImage();
  40.         else
  41.         {
  42.             if (Parent == null)
  43.                 return;
  44.  
  45.             CacheItem = await ThumbnailCache.LoadAndCacheThumbnailAsync(path, CancellationTokenSource.Token, RequiresCurrentDirectory);
  46.  
  47.             IsSmallImage = CacheItem.IsSmallImage;
  48.             Valid = CacheItem.IsValid();
  49.  
  50.             if (Valid)
  51.             {
  52.                 if (IsSmallImage)
  53.                     CacheItem.Image.MakeTransparent();
  54.  
  55.                 Image = CacheItem.Image;
  56.             }
  57.             else
  58.             {
  59.                 ClearImage();
  60.                 RemoveFromContainer();
  61.             }
  62.         }
  63.     }
  64.     catch (OperationCanceledException)
  65.     {
  66.         ClearImage();
  67.         RemoveFromContainer();
  68.     }
  69.     catch (Exception ex)
  70.     {
  71.         Valid = false;
  72.         ClearImage();
  73.         ex.Log();
  74.  
  75.         RemoveFromContainer();
  76.     }
  77. }

 

So each thumbnail is somewhere in the call to load its image. Once again, I added in the check for the directory changing, and cancel the Task if it is. But guess what? That code hardly ever runs.

Aside: Just in case the ThumbnailControlCollection.AddRange method does manage to cancel, this method checks for Parent being null, which would indicate this thumbnail has already been removed from it’s container. (I didn’t include that code here. Parent is set when getting the thumbnail from the object pool, and cleared when removing it from the container and putting it back in the object pool.)

Actually, what happens is, each thumbnail is calling the method that (gets its thumbnail image – possibly from the Windows Shell, and) caches its image, which can be very long-running, and then only getting cancelled after loading the image. That is, the call to my ThumbnailCache type is running for a long time. The directory changes while this code runs, but after the above code already ran, at which point the directory was just fine, thank-you.

Even though the method, ThumbnailCache.LoadAndCacheThumbnailAsync is passed our trusty CancellationToken, that doesn’t help, because the token it gets passed will never be cancelled, and that method does not have access to cancel it. *Fuck!

So now we’re screwed, right? Actually, no. It turns out there is a static method on CancellationTokenSource, called CreateLinkedTokenSource, that solves my problem. When calling it, you get a new CancellationTokenSource, linked to an external one, which is exactly what I need.

ThumbnailCache.LoadAndCacheThumbnailAsync
  1. /// <summary>Load a Shell thumbnail image and add it to the cache.</summary>
  2. public static async Task<ThumbnailCacheItem> LoadAndCacheThumbnailAsync(string path, CancellationToken token, bool requiresCurrentDirectory = false)
  3. {
  4.     var item = ThumbnailCacheItem.Invalid;
  5.     var tcs = new TaskCompletionSource<ThumbnailCacheItem>();
  6.     var isSmallImage = false;
  7.     var failed = false;
  8.  
  9.     try
  10.     {
  11.         using (CancellationTokenSource cts = new CancellationTokenSource(),
  12.             linked = CancellationTokenSource.CreateLinkedTokenSource(token, cts.Token))
  13.         {
  14.             Action checkCancelled = () =>
  15.             {
  16.                 if (requiresCurrentDirectory && !Path.GetDirectoryName(path).EqualsIgnoreCultureAndCase(IO.CurrentDirectory))
  17.                     linked.Cancel();
  18.  
  19.                 linked.Token.ThrowIfCancellationRequested();
  20.             };
  21.  
  22.             await Task.Factory.StartNew(() =>
  23.             {
  24.                 try
  25.                 {
  26.                     checkCancelled();
  27.  
  28.                     /* Some long-running code. */
  29.  
  30.                     checkCancelled();
  31.                    
  32.                     /* Some long-running code. */
  33.  
  34.                     checkCancelled();
  35.                 }
  36.                 catch (Exception ex)
  37.                 {
  38.                     ex.Log();
  39.                     failed = tcs.TrySetException(ex);
  40.                 }
  41.                 finally
  42.                 {
  43.                     if (fullsizeImage != null)
  44.                         fullsizeImage.Dispose();
  45.                 }
  46.             }, token, TaskCreationOptions.HideScheduler, IO.TaskSchedulers.HighPriorityScheduler);
  47.  
  48.             if (!failed)
  49.                 tcs.TrySetResult(item);
  50.         }
  51.     }
  52.     catch (OperationCanceledException) { tcs.TrySetCanceled(); }
  53.  
  54.     return await tcs.Task;
  55. }

 

ThumbnailCache is a class derived from the abstract System.RunTime.Caching.ObjectCache class, which I use to cache the thumbnails by serializing them in binary to the file system. It was introduced to the .Net Framework in version 4, and I have never seen anybody else actually use it, which I find very strange. It uses a very recognizable pattern, similar to ASP.Net’s caching, and makes it very easy to define your own custom cache, with your own custom underlying cache implementation.

In summary, handling cancellation properly is simply a matter of passing the CancellationToken type to the Tasks that need them. Things can get complicated when all or most of your code is async, but if you just follow the call stack, and make sure you detect the conditions that should initiate cancellation at the appropriate time, then handling cancellation properly may not always be as easy as you’d like, but it’s not too difficult either.

* Excuse the language. This is a personal blog, not a professional one, and I may occasionally resort to expletives where their usage seems pertinent.

Advertisements

About Jerome

I am a senior C# developer in Johannesburg, South Africa. I am also a recovering addict, who spent nearly eight years using methamphetamine. I write on my recovery blog about my lessons learned and sometimes give advice to others who have made similar mistakes, often from my viewpoint as an atheist, and I also write some C# programming articles on my programming blog.
This entry was posted in Programming and tagged , , , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s