.NET threading basics
It’s time to get started by learning about the basics of threading in C# and .NET. We will be covering the managed threading concepts that are available in .NET 6, but many of these features have been part of .NET since the beginning. The System.Threading
namespace has been available since .NET Framework 1.0. In the subsequent 20 years, there have been many useful features added for developers.
In order to responsibly use threading in your applications, you should understand exactly what a thread is and how threads are used by your application’s processes.
Threads and processes
We will start our journey with the basic units of application processing, threads, and processes. A process encapsulates all the execution of an application. This is true for all platforms and frameworks. In .NET, you can think of a process as your .exe
or hosted service.
Note
In .NET Framework, the concept of application domains (or app domains), which create isolation units within a process, was introduced. These app domains provide security and reliability by isolating the execution of code loaded into a new app domain. App domains still exist but are not available for developers to create or unload in .NET Core and modern versions of .NET. To read more about app domains, check out this Microsoft Docs article at https://docs.microsoft.com/dotnet/framework/app-domains/application-domains.
A thread represents a single unit of execution within a process. By default, a .NET application will execute all its logic on a single thread (that is, the primary or main thread). Developers can leverage managed threads and other .NET constructs to move from a single-threaded to a multithreaded world, but how do you know when to take this step?
When should we use multithreading in .NET?
There are multiple factors to consider when deciding whether to introduce threading to an application. These factors are both internal and external to the application. The external factors include the hardware in terms of where the application will be deployed, how powerful the processors are where the application will be running, and what other types of processes will be running on these systems?
If your application will be competing for limited resources, it is best to be judicious with the use of multiple threads. If users get the impression that your application is impacting the performance of their systems, you will need to scale back on the number of threads being consumed by your process. Another factor that comes into play is how critical your application is in relation to others on the system. Mission-critical applications will have more resources allocated to remain responsive when needed.
Other common reasons for introducing threading relate to the application itself. Desktop and mobile applications need to keep the user interface (UI) responsive to user input. If the application needs to process a large amount of data or load it from a database, file, or network resource, executing on the main thread can cause the UI to freeze or lag. Also, executing long-running tasks in parallel on multiple threads can reduce the overall execution time of the task.
These operations can be offloaded to background threads if the execution of the tasks is not critical to the application state. Let’s look at the difference between foreground threads and background threads in .NET.
Background threads
The difference between foreground threads and background threads might not be what you think. A managed thread created as a foreground thread is not the UI thread or the main thread. Foreground threads are threads that will prevent the managed process from terminating if they are running. If an application is terminated, any running background threads will be stopped so that the process can shut down.
By default, newly created threads are foreground threads. To create a new background thread, set the Thread.IsBackground
property to true
before starting the thread. Additionally, you can use the IsBackground
property to determine the background status of an existing thread. Let’s look at an example where you might want to use a background thread in your application.
In this example, we will create a console application in Visual Studio that will continuously check the status of a network connection on a background thread. Create a new .NET 6 console app project, name it BackgroundPingConsoleApp
, and in Program.cs
, enter the following code:
Console.WriteLine("Hello, World!");
var bgThread = new Thread(() =>
{
while (true)
{
bool isNetworkUp = System.Net.NetworkInformation
.NetworkInterface.GetIsNetworkAvailable();
Console.WriteLine($"Is network available? Answer:
{isNetworkUp}");
Thread.Sleep(100);
}
});
bgThread.IsBackground = true;
bgThread.Start();
for (int i = 0; i < 10; i++)
{
Console.WriteLine("Main thread working...");
Task.Delay(500);
}
Console.WriteLine("Done");
Console.ReadKey();
Let’s discuss each part of the preceding code before we run it and examine the output:
- The first
Console.WriteLine
statement was created by the project template. We’ll keep this here to help illustrate the order output in the console. - Next, we’re creating a new
Thread
type namedbgThread
. Inside the body of the thread, there is awhile
loop that will execute continuously until the thread is terminated. Inside the loop, we’re calling theGetIsNetworkAvailable
method and outputting the result of that call to the console. Before starting over again, we’re usingThread.Sleep
to inject a 100-millisecond delay. - The next line after creating the thread is the key part of this lesson:
bgThread.IsBackground = true;
Setting the IsBackground
property to true
is what makes our new thread a background thread. This tells our application that the code executing inside the thread is not critical to the application, and the process can terminate without needing to wait for the thread to complete its work. That is a good thing here because the while
loop we created will never be complete.
- On the next line, we start the thread with the
Start
method. - Next, the application kicks off some work inside the application’s primary thread. A
for
loop will execute 10 times and output"Main thread working..."
to the console. At the end of each iteration of the loop,Task.Delay
is used to wait 500 milliseconds, hopefully providing some time for the background thread to perform some work. - After the
for
loop, the application will output"Done"
to the console and wait for the user input to terminate the application with theConsole.ReadKey
method.
Now, run the application and examine the console output. You can press any key to stop the application when you feel you have let it run for long enough:
Figure 1.1 – Viewing the threaded console application output
The result might not be what you expected. You can see that the program executed all the logic on the primary thread before starting any of the background thread work. Later, we’ll see how to change the priority of the threads to manipulate which work will be processed first.
What is important to understand, in this example, is that we were able to stop the console application by pressing a key to execute the Console.ReadKey
command. Even though the background thread is still running, the process does not consider the thread to be critical to the application. If you comment out the following line, the application will no longer terminate by pressing a key:
bgThread.IsBackground = true;
The application will have to be stopped by closing the command window or using the Debug | Stop Debugging menu item in Visual Studio. Later, in the Scheduling and canceling work section, we will learn how to cancel work in a managed thread.
Before we look at any more examples of using managed threads, we will take some time to learn exactly what they are.
What is managed threading?
In .NET, managed threading is implemented by the System.Threading.Thread
class that we used in the previous example. The managed execution environment for the current process monitors all the threads that have been run as part of the process. Unmanaged threading is how threads are managed when programming in C++ with native Win32 threading elements. It is possible for unmanaged threads to enter a managed process through COM interop or through platform invoke (PInvoke
) calls from .NET code. If this thread is entering the managed environment for the first time, .NET will create a new Thread
object to be managed by the execution environment.
A managed thread can be uniquely identified using the ManagedThreadId
property of the Thread
object. This property is an integer that is guaranteed to be unique across all threads and will not change over time.
The ThreadState
property is a read-only property that provides the current execution state of the Thread
object. In the example in the .NET threading basics section, if we had checked the ThreadState
property before calling bgThread.Start()
, it would have been Unstarted
. After calling Start
, the state will change to Background
. If it were not a background thread, calling Start
would change the ThreadState
property to Running
.
Here is a full list of the ThreadState
enum values:
Aborted
: The thread has been aborted.AbortRequested
: An abort has been requested but has not yet been completed.Background
: The thread is running in the background (IsBackground
has been set to true).Running
: The thread is currently running.Stopped
: The thread has been stopped.StopRequested
: A stop has been requested but has not yet been completed.Suspended
: The thread has been suspended.SuspendRequested
: Thread suspension has been requested but has not yet been completed.Unstarted
: The thread has been created but not yet started.WaitSleepJoin
: The thread is currently blocked.
The Thread.IsAlive
property is a less specific property that can tell you whether a thread is currently running. It is a boolean
property that will return true
if the thread has started and has not been stopped or aborted in some way.
Threads also have a Name
property that defaults to null
if they have never been set. Once a Name
property is set on a thread, it cannot be changed. If you attempt to set the Name
property of a thread that is not null, it will throw InvalidOperationException
.
We will cover additional aspects of managed threads in the remainder of this chapter. In the next section, we will dive deeper into the available methods and options for creating and destroying threads in .NET.