Thread Class
The simplest method to create a thread is to use the Thread class. It takes a parameterless function or a function with a single object
parameter in its constructor, which doesn't return anything. After creating an instance for the Thread, two methods need to be called. The first is Start(), which effectively starts the thread of execution and invokes the function in that thread in parallel with the execution of the current thread. After launching the thread, the parallel instruction flow needs to be synchronized back into the main one using the Join() method from the thread that created it. The thread will wait for the other thread to finish and will close it. This way, we can create threads as shown in the following example, using lambda functions to transmit and return data through objects.
/* We use this class to encapsulate the data sent to the Thread
* and the results after processing. This way, we can share data across
* multiple threads and return results.
*/
public class Work
{
// Here, we can add data shared or specific to the thread and results.
public int WorkLoad { get; }
public int Result { get; private set; }
public Work(int workLoad)
{
WorkLoad = workLoad;
Result = 0;
}
// We can also include the processing we want to perform on the data.
public void Execute()
{
for (var i = 0; i < WorkLoad; ++i)
{
/* Perform processing; intentionally, this variable is written
* without protection to demonstrate exclusive access.
* If the current object is shared by multiple threads, writing
* to memory won't be protected, and when one thread reads it,
* another might write to it, leading to incorrect results.
* This region where parallel writing and reading occurs is called
* the critical section.
*/
++Result;
// Simulate intensive computation with a sleep.
Thread.Sleep(10);
}
Console.WriteLine("Thread {0} finished work!", Thread.CurrentThread.Name);
}
// Create a thread-safe version of execution to see differences in results.
public void SafeExecute()
{
for (var i = 0; i < WorkLoad; ++i)
{
/* Use the lock keyword for exclusive access to the critical section.
* Only one thread can enter this section at a time, and it uses
* a reference to an object. In this case, it's 'this' to ensure
* exclusive access to the instance of this class if used by multiple threads.
*/
lock (this)
{
++Result;
}
Thread.Sleep(10);
}
Console.WriteLine("Thread {0} finished work!", Thread.CurrentThread.Name);
}
}
private static void RunSingleThreadExample()
{
var work = new Work(Workload);
Console.WriteLine("Starting the worker on the main thread!");
var sw = Stopwatch.StartNew();
// We can also run on the main thread as a reference.
work.Execute();
sw.Stop();
Console.WriteLine("Work executed (on the main thread) in {0} milliseconds with result: {1}", sw.ElapsedMilliseconds, work.Result);
Console.WriteLine("---------------");
work = new(Workload);
// Create a thread with the function to be executed on that thread.
var workThread = new Thread(() => work.Execute())
{
// We can also add a name to identify the thread.
Name = "Second Thread"
};
Console.WriteLine("Starting the worker thread!");
sw.Restart();
// After creation, call Start() to run the new thread.
workThread.Start();
Console.WriteLine("Waiting for the worker thread!");
/* Every thread must be closed with join.
* The current thread remains in a waiting state until...
*/
workThread.Join();
sw.Stop();
Console.WriteLine("Work executed in {0} milliseconds with result: {1}", sw.ElapsedMilliseconds, work.Result);
Console.WriteLine("---------------");
}
private static void RunUnsafeThreadsExample()
{
// We can also split the work among multiple threads.
var sharedWork = new Work(Workload / NumberOfThreads);
var threads = Enumerable.Range(0, NumberOfThreads).Select(i =>
new Thread(() => sharedWork.Execute())
{
Name = $"Thread {i}"
}).ToList();
Console.WriteLine("Starting the worker threads!");
var sw = Stopwatch.StartNew();
foreach (var thread in threads)
{
thread.Start();
}
Console.WriteLine("Waiting for the worker threads!");
foreach (var thread in threads)
{
thread.Join();
}
sw.Stop();
Console.WriteLine("Work executed (unsafe) in {0} milliseconds with result: {1}", sw.ElapsedMilliseconds, sharedWork.Result);
Console.WriteLine("---------------");
}
private static void RunSafeThreadsExample()
{
// Run another time as a reference for the thread-safe version of the execution function.
var sharedWork = new Work(Workload / NumberOfThreads);
var threads = Enumerable.Range(0, NumberOfThreads).Select(i =>
new Thread(() => sharedWork.SafeExecute())
{
Name = $"Thread {i}"
}).ToList();
Console.WriteLine("Starting the worker threads!");
var sw = Stopwatch.StartNew();
foreach (var thread in threads)
{
thread.Start();
}
Console.WriteLine("Waiting for the worker threads!");
foreach (var thread in threads)
{
thread.Join();
}
sw.Stop();
Console.WriteLine("Work executed (safe) in {0} milliseconds with result: {1}", sw.ElapsedMilliseconds, sharedWork.Result);
Console.WriteLine("---------------");
}
Usage Details for the Thread Class
You need to be cautious when working with threads; there are primarily two risks associated with incorrectly using this class, which applies to other languages as well.
Firstly, if a thread exits forcefully, such as with the Environment.Exit() function or when an exception isn't caught anywhere, the entire program is terminated. These situations need to be preempted. The best practice is to surround the code you intend to execute on a thread with a try-catch block and handle all exception cases.
Secondly, it's worth mentioning that the notion that there are no memory leaks in garbage-collected languages is a myth. In this case, if a thread isn't joined, it remains in the program's memory, and any references it holds won't be released. Consequently, resources won't be freed until a join occurs. You must join all execution threads that have been started to release resources, particularly memory. Additionally, in long-running programs, there might come a point where they can't open more threads.
These are general aspects related to threads. Another concern for programmers is working with shared data. If only reading from a shared memory area occurs, it's not a problem in a multi-threaded scenario. However, if concurrent writes can happen, inconsistencies may arise. One thread could read a value while another writes an old value to memory, resulting in incorrect and non-deterministic final outcomes. That is, running the program again could yield different results.
Sections of code where shared data is read and written in parallel are called critical sections. For this reason, synchronization mechanisms ensure that threads have exclusive access to these critical sections. In C#, to achieve exclusive access to a memory area, the lock
keyword is used on a reference variable, as demonstrated in the example. When locking a reference variable, a thread owns that reference and has exclusive access to that critical code section. Only that thread can execute the corresponding section until it exits the scope and releases the lock, allowing another thread to take over the reference.