Multi-Threading (5), --- Summary

Note: this article is published on 07/08/2024.

This is a series of articles about Multi-threading.

A - Introduction

We already have several articles to discuss the Async programming. This article will be a summary.

In article, Multi-Threading (2), Implementation Overview, we mentioned there are three Async programming patterns, for .NET:

  • Task-based Asynchronous Pattern (TAP).
  • Event-based Asynchronous Pattern (EAP).
  • Asynchronous Programming Model (APM) pattern (also called the IAsyncResult pattern).

In article, Multi-Threading (2-1), Different MultiThreading Topics, we mentioned .Net has three low-level mechanisms to run Async code in parallel: 

  • Thread
  • ThreadPool, and 
  • Task.

These three mechanism serve different purposes.

In this article, we will first briefly discuss the three async low-level machanisms, and conclude that the task machanism is the best choice for the modern programming. In the second section, we will discuss briefly the ways to make the threading programming, while in the last section, we will briefly describe the major features of the task machanism.

This is a summary of the multi-threading issue, will discuss the topics

  • A - Introduction
  • B - Task vs. Threading --- the choice of three async machanism
    • B1 - Thread
    • B2 - ThreadPool
    • B3 - Task
    • B4 - Conclusion
  • C - The ways to implemente Threading --- the traditional way
    • C1 - Asynchronous Programming Model Pattern
    • C2 - Event-Based Asynchronous Pattern
    • C3 - Threading Class
  • D - Task and async/await --- The Current Way
    • D1 - Task
    • D2 - async and asit keywords
  • E - Task Exception Handling
    • E1 - try/catch statement
    • E2 - By Task method: FromException

B - Threads vs. Tasks --- the choice of three async machanism

.Net has three low-level mechanisms to run code in parallel: ThreadThreadPool, and Task. These three mechanism serve different purposes.

B1 - Thread

  • The thread represents an actual OS-level thread with the highest degree of control; you can
    • Abort() or Suspend() or Resume() a thread (though this is a very bad idea),
    • Observe its state,
    • Set thread-level properties like
      • stack size,
      • apartment state, or
      • culture.
  • The problem with Thread is that OS threads are costly. Each thread you have
    • Consumes a non-trivial amount of memory for its stack and
    • Adds additional CPU overhead as the processor context-switch between threads.
    • Developed by Developer from creating to whole manipulating.

B2 - ThreadPool

  • ThreadPool is a wrapper around a pool of threads maintained by the CLR. ThreadPool gives you no control at all; you can
    • Submit work to execute at some point, and you can
    • Control the size of the pool, but you
    • Can't set anything else. You can't even tell when the pool will start running the work you submit to it.
  • Using ThreadPool avoids the overhead of creating too many threads. However,
    • If you submit too many long-running tasks to the threadpool, it can get full, and later work that you submit can end up waiting for the earlier long-running items to finish. In addition,
    • The ThreadPool offers no way to find out when a work item has been completed (unlike Thread.Join()),
    • Nor a way to get the result. Therefore,
  • ThreadPool is best used for short operations where the caller does not need the result.

B3 - Task

  • Finally, the Task class from the Task Parallel Library offers the best of both worlds.
    • Like the ThreadPool, a task does not create its own OS thread. Task runs on the ThreadPool through a TaskScheduler.
    • Unlike the ThreadPool, Task also allows you to find out when it finishes and return a result. 
  • All newer high-level concurrency APIs, including the Parallel.For*() methods, PLINQ, C# 5 await, and modern async methods in the BCL, are all built on Task.

B4 - Conclusion

  • The bottom line is that Task is almost always the best option; it provides a much more powerful API and avoids wasting OS threads.
  • The only reasons to explicitly create your own Threads in modern code are setting per-thread options or maintaining a persistent thread that needs to maintain its own identity.

C - The ways to implemente Threading

The major will be like below:

C1 - Asynchronous Programming Model Pattern

  • A native or raw asynchronous programming model using a delegate to create a thread. such that you define a delegate with the same signature as the method you want to call, the common language runtime automatically defines BeginInvoke and EndInvoke methods for this delegate, with the appropriate signatures. Therefore, we could say APM is a delegate-based Asynchronous Programming model.

APM Pattern (ref)

C2 - Event-Based Asynchronous Pattern

  • The Event-based Asynchronous Pattern has a single MethodNameAsync method and a corresponding MethodNameCompleted event

  • Basically, this pattern enforces a pair of methods and an event to collaborate and help the application execute a thread asynchronously

Event-Based Pattern (ref)

C3 - Threading class:

  • Create a threading class
​Thread t = new Thread(worker);
t.Start(); //start a new thread, call the method WorkerThreadMethod
  • Manage Thread lifetime: We use thread class properties to manage thread lifetime:
    • Start --- Start a thread
    • Sleep(TimeSpan) --- block the thread at a certain time (Static Method, only can be called from within the current thread)
    • Join --- Blocks the calling thread until the called thread terminates
    • Suspend/ Resume --- Suspend/Resume the thread
    • Interrupt --- Interrupts a thread that is in the WaitSleepJoin thread state
    • Abort --- terminate the thread, throwing a ThreadAobrtException

D - Task and async/await

This section will discuss the major features of the task machanism.

D1 - Task

The Task Parallel Library (TPL) is based on the concept of a task, which represents an asynchronous operation. In some ways, a task resembles a thread or ThreadPool work item but at a higher level of abstraction. The term task parallelism refers to one or more independent tasks running concurrently. Tasks provide two primary benefits:

  • More efficient and more scalable use of system resources.

    Behind the scenes, tasks are queued to the ThreadPool, which has been enhanced with algorithms that determine and adjust to the number of threads. These algorithms provide load balancing to maximize throughput. This process makes tasks relatively lightweight, and you can create many of them to enable fine-grained parallelism.

  • More programmatic control than is possible with a thread or work item.

    Tasks and the framework built around them provide a rich set of APIs that support waiting, cancellation, continuations, robust exception handling, detailed status, custom scheduling, and more.

For both reasons, TPL is the preferred API for writing multi-threaded, asynchronous, and parallel code in .NET.

  • Creating and running tasks implicitly

When you create a task, you give it a user delegate that encapsulates the code that the task will execute. The delegate can be expressed as a named delegate, an anonymous method, or a lambda expression. Lambda expressions can contain a call to a named method,

You can use the Task.Start methods to create and start a task in one operation.

You can also use the Task.Run methods to create and start a task in one operation.

Different from using C# async/await keywords to create an async method, we do not need to wait until the await keyword is met the first time to create a new thread and return the original thread back to the calling function. in fact, when Task.Run is call, it is in a new created thread, while the main thread is back to the calling function.

i.e. the behavior of Task.Run is exactly the same as await in an async method is met the first time

  • When Task.Run or Task.Satrt method is called
    • First, the task is actually executed by some random threads obtained from the runtime thread pool.
    • Secondly, the calling method for the Task.Run or Task.Start method is not blocking the main thread; it will return to the first calling code that is not marked as await.

Task Methods:

D2 - async and asit keywords

async and await are two new keywords introduced into C# 5.0 in 2012. The async and await keywords are the heart of async programming. By using those two keywords, one can create an asynchronous method almost as easily as creating a synchronous method, even without really understanding the runtime workflow.

Code sample:

  • async/await:
    • async in C#
      • An asynchronous method is defined by using the async keyword.
      • An async method is usually named with a suffix Async, such as GetStringAsync , but not mandatory. 
    • await in C#
      • In the async method, once the keyword await is met for the first time
        • First, the remaining of task is actually executed by some random threads obtained from the runtime thread pool.
        • Secondly, the calling method to the async method is not blocking the main thread; it will return to the first calling code that is not marked as await.

Note

  • if the "await" keyword is not defined within an asynchronous method, there will be a compiling error. On the other hand, 
  • if there is no "await" keyword defined within an asynchronous method. i.e, a method defined by the keyword "async", the asynchronous method will become a synchronous method, 

E - Task Exception Handling

We have two ways to handle the Task Exceptions:

  • By a try/catch statement:
  • By Task method: FromException

E1 - try/catch statement

When using one of the static or instance Task.Wait methods the exceptions are propagated, and can be handled by enclosing the call in a try/catch statement:

see: Exception handling (Task Parallel Library) - .NET | Microsoft Learn

E2 - By Task method: FromException

See: Task.FromException Method (System.Threading.Tasks) | Microsoft Learn

This method creates a Task object whose Status property is Faulted and whose Exception property contains exception. The method is commonly used when you immediately know that the work that a task performs will throw an exception before executing a longer code path. For an example, see the FromException<TResult>(Exception) overload.

The following example is a command-line utility that calculates the number of bytes in the files in each directory whose name is passed as a command-line argument. Rather than executing a longer code path that instantiates a FileInfo object and retrieves the value of its FileInfo.Length property for each file in the directory, the example simply calls the FromException<TResult>(Exception) method (Line 39) to create a faulted task if a particular subdirectory does not exist.

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

public class Example
{
   public static void Main()
   {
      string[] args = Environment.GetCommandLineArgs();
      if (args.Length > 1) {
         List<Task<long>> tasks = new List<Task<long>>();
         for (int ctr = 1; ctr < args.Length; ctr++)
            tasks.Add(GetFileLengthsAsync(args[ctr]));

         try {
            Task.WaitAll(tasks.ToArray());
         }
         // Ignore exceptions here.
         catch (AggregateException) {}

         for (int ctr = 0 ; ctr < tasks.Count; ctr++) {
            if (tasks[ctr].Status == TaskStatus.Faulted)
               Console.WriteLine("{0} does not exist", args[ctr + 1]);
            else
               Console.WriteLine("{0:N0} bytes in files in '{1}'",
                                 tasks[ctr].Result, args[ctr + 1]);
         }
      }
      else {
         Console.WriteLine("Syntax error: Include one or more file paths.");
      }
   }

   private static Task<long> GetFileLengthsAsync(string filePath)
   {
      if (! Directory.Exists(filePath)) {
         return Task.FromException<long>(
                     new DirectoryNotFoundException("Invalid directory name."));
      }
      else {
         string[] files = Directory.GetFiles(filePath);
         if (files.Length == 0)
            return Task.FromResult(0L);
         else
            return Task.Run( () => { long total = 0;
                                     Parallel.ForEach(files, (fileName) => {
                                                 var fs = new FileStream(fileName, FileMode.Open,
                                                                         FileAccess.Read, FileShare.ReadWrite,
                                                                         256, true);
                                                 long length = fs.Length;
                                                 Interlocked.Add(ref total, length);
                                                 fs.Close(); } );
                                     return total;
                                   } );
      }
   }
}
// When launched with the following command line arguments:
//      subdir . newsubdir
// the example displays output like the following:
//       0 bytes in files in 'subdir'
//       2,059 bytes in files in '.'
//       newsubdir does not exist

The result is as expected (we do not setup the subdir subfolder):

Add one more line code after Line 25 to catch the exception: 

then we have the exception printed as

References: