Friday, November 18, 2016

Using Task Instead of BackgroundWorker to Update UI

The BackgroundWorker component allows for convenient access to a thread. It supports
  • Reporting Progress
  • Error Handling
  • Returning a Result
  • Cancellation
Updating the UI is a common action in GUI applications that perform a time consuming task. A background worker allows for a simple implementation of updating the UI thread and for the UI thread to control the background worker.

A Task is another way to implement a separate worker thread. I will demonstrate how to transform all the actions performed in a background worker into the context of a Task.

I have used the following resources for Tasks
The example I am using is from the Windows Forms 2.0 book by Chris Sells.

Calculate Pi Example

 The calculations for Pi are a mystery, but they take time.

    void CalcPi(int digits) {
      StringBuilder pi = new StringBuilder("3", digits + 2);

      if( digits > 0 ) {
        pi.Append(".");
     
        for( int i = 0; i < digits; i += 9 ) {
          int nineDigits = NineDigitsOfPi.StartingAt(i + 1);
          int digitCount = Math.Min(digits - i, 9);
          string ds = string.Format("{0:D9}", nineDigits);
          pi.Append(ds.Substring(0, digitCount));
      }
    }

A method encapsulates access to the UI.

    void ShowProgress(string pi, int totalDigits, int digitsSoFar) {
      // Display progress in UI
      this.resultsTextBox.Text = pi;
      this.calcToolStripProgressBar.Maximum = totalDigits;
      this.calcToolStripProgressBar.Value = digitsSoFar;
    }
 
A custom class is used to encapsulate data that is passed to the UI.

    class CalcPiUserState {
      public readonly string Pi;
      public readonly int TotalDigits;
      public readonly int DigitsSoFar;

      public CalcPiUserState(string pi, int totalDigits, int digitsSoFar) {
        this.Pi = pi;
        this.TotalDigits = totalDigits;
        this.DigitsSoFar = digitsSoFar;
      }
    }

Helper methods are used to control modify the appearance of the UI.

        void SetUIReady()
        {
            // Reset progress UI
            this.calcToolStripStatusLabel.Text = "Ready";
            this.calcToolStripProgressBar.Visible = false;
        }

        void SetUIBusy()
        {
            // Set calculating UI
            this.calcToolStripProgressBar.Visible = true;
            this.calcToolStripStatusLabel.Text = "Calculating...";
        }

Starting a Worker Thread that Reports Progress

BackgroundWorker

Handle the DoWork event

    void backgroundWorker_DoWork(object sender, DoWorkEventArgs e) {
        CalcPi((int)e.Argument);
    }

Set  WorkerSupportsProgress property
Handle ReportProgress event

    void backgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e) {
      // Show progress
      CalcPiUserState progress = (CalcPiUserState)e.UserState;
      ShowProgress(
        progress.Pi, progress.TotalDigits, progress.DigitsSoFar);
    }

Call RunWorkerAsync

    void calcButton_Click(object sender, EventArgs e) {
      SetUIBusy();

      // Begin calculating pi asynchronously
      this.backgroundWorker.RunWorkerAsync(
        (int)this.decimalPlacesNumericUpDown.Value);
    }

 Handle RunWorkerCompleted event

    void backgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
   {
       SetUIReady();  
   }

Modify the CalcPi method so that it reports progress regularly.

    void CalcPi(int digits) {
      StringBuilder pi = new StringBuilder("3", digits + 2);

      CalcPiUserState state = new CalcPiUserState(pi.ToString(), digits, 0);
      // Report initial progress
      this.backgroundWorker.ReportProgress(0, state);

      if( digits > 0 ) {
        pi.Append(".");

        for( int i = 0; i < digits; i += 9 ) {
          int nineDigits = NineDigitsOfPi.StartingAt(i + 1);
          int digitCount = Math.Min(digits - i, 9);
          string ds = string.Format("{0:D9}", nineDigits);
          pi.Append(ds.Substring(0, digitCount));

          // Report continuing progress
          state = new CalcPiUserState(pi.ToString(), digits, i + digitCount);
          this.backgroundWorker.ReportProgress(0, state);

          // Check for cancelation
          if (this.backgroundWorker.CancellationPending) { return; }         
        }
      }
    }

Task

Create a helper method to update the UI from the custom state class.

        void ShowProgressState(CalcPiUserState state)
        {
            try
            {
                // Display progress in UI
                this.resultsTextBox.Text = state.Pi;
                this.calcToolStripProgressBar.Maximum = state.TotalDigits;
                this.calcToolStripProgressBar.Value = state.DigitsSoFar;
            }
            catch (Exception e)
            {
                Console.WriteLine("{0} in ShowProgressState", e.Message);
                throw e;
            }
        }

Create a method with return type Task that encapsulates the work. In order to report progress, create an Progress delegate with an Action that reports progress. CalcPiWithProgress is CPU-bound, so call it with Task.Run. The async keyword in the signature means that await is a keyword in the body of the method.

        async Task DoWork()
        {
            //Any method that is an Action, has one parameter of the gerenic type and returns void.
            Action actionProgress = ShowProgressState;
            //The Action and the Progress must have the same generic type.
            IProgress progress = new Progress(actionProgress);
            await Task.Run(() => CalcPiWithProgress(
                  (int)this.decimalPlacesNumericUpDown.Value, progress)
                );

        }


Start task. The await keyword causes the method to start asynchronously. When the thread completes, the code after the await will be run in the same thread context. The handler does not block: SetUIReady is called, but only after the thread completes, and well after the handler completes.

        async void calcButton_Click(object sender, EventArgs e)
        {
            SetUIBusy();

            // Begin calculating pi asynchronously
            await DoWork();

            SetUIReady();
        }

A special method is not needed to catch the completion of the thread, as with a background worker.

Modify the CalcPi method so it reports progress.

    void CalcPiWithProgress(int digits, IProgress progress,) {
      StringBuilder pi = new StringBuilder("3", digits + 2);

      CalcPiUserState state = new CalcPiUserState(pi.ToString(), digits, 0);
      // Report initial progress
      if (progress != null)
                progress.Report(state);

      if( digits > 0 ) {
        pi.Append(".");

        for( int i = 0; i < digits; i += 9 ) {
          int nineDigits = NineDigitsOfPi.StartingAt(i + 1);
          int digitCount = Math.Min(digits - i, 9);
          string ds = string.Format("{0:D9}", nineDigits);
          pi.Append(ds.Substring(0, digitCount));

          // Report continuing progress
          state = new CalcPiUserState(pi.ToString(), digits, i + digitCount);
          if (progress != null)
                progress.Report(state);  
        }
      }
    }

Catch Exceptions

Background Worker

Test the Error property in the RunWorkerCompletedEventArgs in the RunWorkerCompleted handler.

void backgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) {
  
      SetUIReady();

      // Was there an error?
      if( e.Error != null ) {
        this.resultsTextBox.Text = e.Error.Message;
        return;
      }

    }

Task 

With Task, the code after the await is run on the UI thread. The code after the await is executed after the thread completes. The handler would have completed long ago.

Catch the exception and reset the UI.

        async void calcButton_Click(object sender, EventArgs e)
        {
            try
            {
                SetUIBusy();

                // Begin calculating pi asynchronously
                await DoWork();

            }
            catch (Exception ex)
            {
                this.resultsTextBox.Text = ex.Message;
            }
            finally
            {
                SetUIReady();
            }
        }

Return a Result from the Thread 

Background Worker

Set the result of the thread in the DoWorkEventArgs in the DoWork handler.

  void backgroundWorker_DoWork(object sender, DoWorkEventArgs e) { 
      // Track start time
      DateTime start = DateTime.Now;

      CalcPi((int)e.Argument);     

      // Return elapsed time
      DateTime end = DateTime.Now;
      TimeSpan elapsed = end - start;
      e.Result = elapsed;
  }

Retrieve the result from the RunWorkerCompletedEventArgs in the RunWorkerCompleted handler.

void backgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
  
      SetUIReady();

      // e.Result will cause an error if the thread threw an exception
      if( e.Error != null ) {
        this.resultsTextBox.Text = e.Error.Message;
        return;
      }

      // Show elapsed time
      TimeSpan elapsed = (TimeSpan) e.Result;
      MessageBox.Show("Elapsed: " + elapsed.ToString());
}

Task 

Instead of using Task, use Task. The async command  changes the meaning of the method signature. It does not mean that the method returns Task, it means that the method has a call to await and returns type T.

        async Task DoWork()
        {
            // Track start time
            DateTime start = DateTime.Now;

            //Any method that is an Action, has one parameter of the gerenic type and returns void.
            Action actionProgress = ShowProgressState;
            //The Action and the Progress must have the same generic type.
            IProgress progress = new Progress(actionProgress);
            await Task.Run(() => CalcPiWithProgress(
                  (int)this.decimalPlacesNumericUpDown.Value, progress)
                );

            // Return elapsed time
            DateTime end = DateTime.Now;
            TimeSpan elapsed = end - start;
            return elapsed;
        }


The result will be assigned from the await command.

        async void calcButton_Click(object sender, EventArgs e)
        {
            try
            {
                SetUIBusy();

                // Begin calculating pi asynchronously
                TimeSpan elapsed = await DoWork();

                // Show elapsed time
                MessageBox.Show("Elapsed: " + elapsed.ToString());

            }
            catch (Exception ex)
            {
                this.resultsTextBox.Text = ex.Message;
            }
            finally
            {
                SetUIReady();
            }
        }

Adding the Ability to Cancel

Background Worker


Set the WorkerSupportsCancellation property

Signal that the thread should stop.

void calcButton_Click(object sender, EventArgs e) {
      // Don't process if cancel request pending
      // (Should not be called, since we disabled the button...)
      if( this.backgroundWorker.CancellationPending ) { return; }

      // If worker thread currently executing, cancel it
      if( this.backgroundWorker.IsBusy ) {
        this.calcButton.Enabled = false;
        this.backgroundWorker.CancelAsync();
        return;
      }

      SetUIBusy();

      // Begin calculating pi asynchronously
      this.backgroundWorker.RunWorkerAsync(
        (int)this.decimalPlacesNumericUpDown.Value);
    }

The thread monitors the flag for cancellation. When requested, the thread stops working.

  void CalcPi(int digits)
  {
      StringBuilder pi = new StringBuilder("3", digits + 2);

      CalcPiUserState state = new CalcPiUserState(pi.ToString(), digits, 0);
      // Report initial progress
      this.backgroundWorker.ReportProgress(0, state);

      // Check for cancelation
      if (this.backgroundWorker.CancellationPending) { return; }

      if( digits > 0 ) {
        pi.Append(".");

        for( int i = 0; i < digits; i += 9 ) {
          int nineDigits = NineDigitsOfPi.StartingAt(i + 1);
          int digitCount = Math.Min(digits - i, 9);
          string ds = string.Format("{0:D9}", nineDigits);
          pi.Append(ds.Substring(0, digitCount));

          // Report continuing progress
          state = new CalcPiUserState(pi.ToString(), digits, i + digitCount);
          this.backgroundWorker.ReportProgress(0, state);

          // Check for cancelation
          if (this.backgroundWorker.CancellationPending) { return; }         
        }
      }
    }

In the RunWorkerCompleted handler check the Cancelled property in the event args.

void backgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) {
      // Reset progress UI
      this.calcButton.Text = "Calculate";
      this.calcButton.Enabled = true;
      this.calcToolStripStatusLabel.Text = "Ready";
      this.calcToolStripProgressBar.Visible = false;

      // Was the worker thread cancelled?
      if (e.Cancelled)
      {
          this.resultsTextBox.Text = "Cancelled";
      }
      else
      {
          // Show elapsed time
          TimeSpan elapsed = (TimeSpan)e.Result;
          MessageBox.Show("Elapsed: " + elapsed.ToString());
      }

Task


As the thread is asked to respond in more ways, it is useful separate the actions performed when the thread ends. As part of the Task class, use the Continue with to specify a method that has the format for Action. Action, Func and Func can also be configured. If the UI will be accessed, then continue from the current context.

        async void calcButton_Click(object sender, EventArgs e)
        {

            SetUIBusy();

            // Begin calculating pi asynchronously
            await DoWork().ContinueWith(WorkCompleted,
                TaskScheduler.FromCurrentSynchronizationContext());

        }




Many states of the task can be inspected. This version does the same as the earlier version of the application. Next, cancellation will be added.

        private void WorkCompleted(Task task)
        {
            switch (task.Status)
            {
                case TaskStatus.Canceled:
                    break;
                case TaskStatus.Faulted:
                    this.resultsTextBox.Text = task.Exception.ToString();
                    break;
                case TaskStatus.RanToCompletion:
                    MessageBox.Show("Elapsed: " + task.Result.ToString());
                    break;
                default:
                    break;
            }
            SetUIReady();
        }


For cancellation, CancellationTaskSource is used. It contains a token that can be set. The token can be monitored by the thread.

        CancellationTokenSource tokenSource;
        CancellationToken token;

        public MainForm() {
            InitializeComponent();
            tokenSource = new CancellationTokenSource();
            token = tokenSource.Token;
            SetUIReady();
        }

A new helper will be added to report progress and check for cancellation, with the ThrowIfCancellationRequested() method of the token.

        void ProgressTestCancel(
            IProgress progress, CancellationToken token, CalcPiUserState state)
        {
            // Report progress
            if (progress != null)
                progress.Report(state);
            token.ThrowIfCancellationRequested();    
        }


The worker thread reports progress and tests for cancellation at regular intervals.

    void CalcPiWithProgress(
          int digits, IProgress progress, CancellationToken token) 
   {
      StringBuilder pi = new StringBuilder("3", digits + 2);

      CalcPiUserState state = new CalcPiUserState(pi.ToString(), digits, 0);
      // Report initial progress
      ProgessTestCancel(progess, token, state);

      if( digits > 0 ) {
        pi.Append(".");

        for( int i = 0; i < digits; i += 9 ) {
          int nineDigits = NineDigitsOfPi.StartingAt(i + 1);
          int digitCount = Math.Min(digits - i, 9);
          string ds = string.Format("{0:D9}", nineDigits);
          pi.Append(ds.Substring(0, digitCount));

          // Report continuing progress
          state = new CalcPiUserState(pi.ToString(), digits, i + digitCount);
          ProgessTestCancel(progess, token, state); 
        }
      }
    }

Pass the token into the method that does the asynchronous work. The task will be needed in other parts of the application to improve the interaction of the UI, so the task is added as an instance variable. Does this remind you of background worker???

         //Needed in order to test if Task is busy in other methods
        Task piTask = null;
        async Task DoWork()
        {
            // Track start time
            DateTime start = DateTime.Now;

            //Any method that is an Action, has one parameter of the gerenic type and returns void.
            Action actionProgress = ShowProgressState;
            //The Action and the Progress must have the same generic type.
            IProgress progress = new Progress(actionProgress);
            piTask = new Task(() =>
                 TimePiThread(
                    (int)this.decimalPlacesNumericUpDown.Value, progress, tokenSource.Token)
                 , tokenSource.Token);
            piTask.Start();
            return await piTask;
         
        }


The button handler will have two possible states: ready to run, ready to cancel. Update the UI based on the state of the task. If the task is running, cancel it on click.

        async void calcButton_Click(object sender, EventArgs e)
        {
            // Don't process if cancel request pending
            // Should not happen, since button will be disabled
            if (this.tokenSource.IsCancellationRequested) { return; }

            if (piTask != null && !piTask.IsCompleted)
            {
                this.calcButton.Enabled = false;
                tokenSource.Cancel();
            }
            else
            {

                SetUIBusy();

                // Begin calculating pi asynchronously
                await DoWork().ContinueWith(WorkCompleted,
                    TaskScheduler.FromCurrentSynchronizationContext());
            }

        }

The helpers for updating the UI need modification, too:

        void SetUIReady()
        {
            // Reset progress UI
            this.calcToolStripStatusLabel.Text = "Ready";
            this.calcToolStripProgressBar.Visible = false;
            this.calcButton.Text = "Calculate";
            this.calcButton.Enabled = true;
        }

        void SetUIBusy()
        {
            // Set calculating UI
            this.calcToolStripProgressBar.Visible = true;
            this.calcButton.Text = "Cancel";
            this.calcToolStripStatusLabel.Text = "Calculating...";
        }

Conclusion

For updating the UI, I still like background worker. In large part, this is due to my understanding of background worker. Task is new to me. It has more power than background worker for other tasks, but background worker is tailored for interacting with the UI. While writing this article, I was frustrated multiple times with the syntax of lambda functions and the .NET delegates. Background worker hides the details, but allows all the necessary features for interacting with the UI.

No comments:

Post a Comment

Followers