Logo

C# Threads Basics

21 min read
Lesson slides
1 / 17

Operating Systems I - Lesson 5

C# Threads Basics

Create, coordinate, name, and control threads with the Thread class, and keep a UI responsive while background work runs.

By the end of this lesson you will be able to create, coordinate, name, and control threads with the Thread class, pass data into a thread safely, retrieve a result back from one, and keep a WinForms UI responsive while background work runs.

Objectives

  • Explain the relationship between a program, a process, and a thread.
  • Create, start, and coordinate threads using Thread, ThreadStart, Start, Join, and Sleep.
  • Distinguish foreground and background threads and trace a thread through its lifecycle with ThreadState.
  • Read and set the name of the running thread with Thread.CurrentThread.
  • Control a running thread from the outside: exercise Abort hands on and see how it raises an exception inside the target thread to force it to unwind, and get introduced to Suspend and Resume as the other members of this same family of methods.
  • Pass data into a thread in a type-safe way with ParameterizedThreadStart and a helper class, and retrieve a result back with a callback delegate.
  • Explain why a WinForms window shows "Not Responding" when its UI thread is blocked, and fix it with Control.Invoke and with BackgroundWorker.

Prerequisites

  • C# fundamentals: classes, methods, delegates, lambda expressions.
  • An IDE able to build Console and Windows Forms apps.
  • .NET Core / .NET 5+ for the console exercises; the WinForms exercise needs the Windows desktop workload.

Background

This lesson covers System.Threading.Thread, the class used to create and control a thread directly. The .NET class library also has a higher-level System.Threading.Tasks.Task for running work asynchronously, but that class is out of scope here; this lesson stays with Thread fundamentals. While working through the tasks below, an IDE's thread-inspection views (such as Threads and Parallel Stacks windows while paused at a breakpoint) let you watch each thread you started actually running on its own, which is a useful way to confirm a task's expected behavior is really happening on a separate thread rather than just trusting the console output.

Program, Process, and Thread

A program is a static file on disk. It becomes a process the moment the operating system loads it into memory to run it. A process owns memory space and system resources and always contains at least one thread. A thread is the smallest unit of execution inside a process: it runs your code, has its own program counter and stack, but shares memory and resources with every other thread in the same process.

Every C# program starts with exactly one thread, the main thread, created automatically the instant the process starts. Anything that runs "in parallel" later is a second thread inside that same process, never a separate process. If a method called from Main() takes a long time and the program has only one thread, the whole process is stuck waiting on it, because a single thread executes one instruction at a time. Several running applications means several processes; multithreading means one process deliberately using more than one thread, a choice the programmer makes, not one the operating system makes automatically.

graph TB
    A[Program on disk] -->|Loaded and run| B[Process]
    B -->|Owns memory and resources| C[Main Thread]
    B -->|May create| D[Additional Threads]

A program on disk becomes a process when run, the process is granted a main thread automatically, and any further threads exist only because the code explicitly created them.

When more than one thread is runnable at once, the operating system does not run them at the same literal instant on a single CPU core. Instead it hands each thread a short time slice on the processor; if a thread does not finish its work within that slice, execution switches to another runnable thread, and back again once that thread gets its own turn. This is why running multiple threads is described as simultaneous, meaning threads take turns running one after another in quick rotation, rather than at the same time, meaning truly in parallel. Because the operating system, not the program, decides which thread gets the CPU and for how long, the exact order in which threads interleave their output is not guaranteed to repeat from one run to the next. Running the same multithreaded program twice can print its lines in a different order both times, and that variability is expected, not a bug.

Creating and Starting a Thread

The Thread class, in System.Threading, creates a custom thread. Its constructor takes a ThreadStart delegate (void return, no parameters) pointing at the method the thread should run; without that entry point the thread has nothing telling it where to begin. Building a Thread object does not start it, it only builds the blueprint. Start() begins running its method concurrently with the caller. Join() blocks the caller until the target finishes. Sleep(milliseconds) pauses a thread without spending CPU cycles.

using System;
using System.Threading;
 
class Program
{
    static void Main()
    {
        Console.WriteLine("Main thread started.");
 
        Thread myThread = new Thread(PrintNumbers);
        myThread.Start();
        myThread.Join();
 
        Console.WriteLine("Main thread completed.");
    }
 
    static void PrintNumbers()
    {
        Console.WriteLine("Secondary thread started.");
        for (int i = 1; i <= 5; i++)
        {
            Console.WriteLine($"Number: {i}");
            Thread.Sleep(500);
        }
        Console.WriteLine("Secondary thread completed.");
    }
}

using System.Threading; imports Thread. new Thread(PrintNumbers) builds a Thread whose ThreadStart delegate points at PrintNumbers; the compiler checks that its signature matches. myThread.Start() moves the thread out of its unstarted state and runs PrintNumbers on a separate thread while Main keeps going. myThread.Join() makes the main thread wait there until PrintNumbers finishes. Inside it, Thread.Sleep(500) pauses that thread half a second between prints.

A thread created by another thread, here myThread created by the main thread inside Main, is called a child thread (or worker thread) of its creator.

Thread is a sealed class: it cannot be inherited, and no custom constructor can be added to it. Passing PrintNumbers directly is the plain form; the same entry point can also be built explicitly as a stored delegate instance, as an anonymous method, or as a lambda, and all four are equivalent as long as the target has void return and no parameters:

Thread t1 = new Thread(PrintNumbers);
Thread t2 = new Thread(new ThreadStart(PrintNumbers));
Thread t3 = new Thread(delegate () { PrintNumbers(); });
Thread t4 = new Thread(() => PrintNumbers());

t1 passes the method group straight to the constructor. t2 builds the same ThreadStart delegate explicitly before passing it, which is what t1 does implicitly behind the scenes. t3 uses the delegate keyword to write the entry point as an anonymous method inline. t4 writes the same thing as a lambda expression. Because Thread is sealed, a helper class like the one shown later under "Passing Data to a Thread", not inheritance, is the pattern for giving a thread extra behavior or state.

Expected output:

Main thread started.
Secondary thread started.
Number: 1
Number: 2
Number: 3
Number: 4
Number: 5
Secondary thread completed.
Main thread completed.

"Main thread completed." only appears after the secondary thread finishes, because Join() forces the wait.

Foreground vs Background Threads

Every Thread is a foreground thread by default. In the worked example below, that means the application will not exit until the main thread and the foreground worker thread it started have both finished, even after Main itself has already returned; the same rule extends to any number of foreground threads, not just the one shown here. A background thread, set with IsBackground = true (default false), is instead terminated automatically the moment every remaining foreground thread finishes, whether or not it had completed its own work.

static void Main()
{
    Thread oThread = new Thread(WorkThread);
    oThread.IsBackground = true;
    oThread.Start();
    Console.WriteLine("Main Thread Quits..!");
}
 
static void WorkThread()
{
    for (int i = 1; i <= 5; i++)
    {
        Thread.Sleep(1000);
        Console.WriteLine($"WorkThread still going: {i}");
    }
}

new Thread(WorkThread) builds a thread whose entry point is WorkThread. oThread.IsBackground = true marks it as a background thread before Start() runs it. oThread.Start() begins WorkThread on its own thread while Main continues immediately to the Console.WriteLine. Because IsBackground was set to true, the moment Main() reaches its end (the only foreground thread finishing), the whole process exits immediately, so WorkThread is cut off mid-loop and may never print all five lines. Leaving IsBackground at its default false keeps the whole process alive until WorkThread finishes its loop on its own, even though Main() already returned.

Thread Lifecycle and ThreadState

A thread moves through defined states. Newly created, it is Unstarted. Once Start() runs it becomes Running. Calling Sleep() or Join(), or blocking on I/O, moves it into a waiting state. Once its method returns, it becomes Stopped. The ThreadState property reads this directly instead of guessing.

Thread myThread = new Thread(DoWork);
 
Console.WriteLine(myThread.ThreadState); // Unstarted
myThread.Start();
Console.WriteLine(myThread.ThreadState); // Running
myThread.Join();
Console.WriteLine(myThread.ThreadState); // Stopped

Before Start(), the thread exists but nothing is happening, so its state is Unstarted. Right after Start() it is running. After Join() returns, DoWork has finished, so the state is Stopped.

These are the values ThreadState actually prints in code. Conceptually they line up with a simpler four-state description of the same life cycle: Unstarted stays Unstarted, Running corresponds to a Ready state waiting for a CPU cycle, a thread blocked on Sleep, Wait, or I/O corresponds to a Not Runnable state, and Stopped corresponds to a Dead state, reached either because the thread completed execution or because it was aborted.

stateDiagram-v2
    [*] --> Unstarted: new Thread(...)
    Unstarted --> Running: Start()
    Running --> NotRunnable: Sleep / Join / blocked I-O
    NotRunnable --> Running: resumed
    Running --> Stopped: method returns
    Stopped --> [*]

Thread.CurrentThread and Naming a Thread

Thread.CurrentThread is a static, read-only property of the Thread class that returns the instance of whichever thread is executing the code that reads it. Calling it from Main returns the main thread; calling it from inside a method running on a worker thread returns that worker thread instead. The instance it returns has a Name property with both a getter and a setter, so it can be read or set like any other property; a thread's name is null until something sets it, and by convention a thread is named once, near where it is created, purely to make debugger output and log lines easier to tell apart.

Thread th = Thread.CurrentThread;
th.Name = "MainThread";
 
Console.WriteLine("This is {0}", th.Name);

Thread.CurrentThread fetches the instance representing the thread running this code, here the main thread, and stores it in th. th.Name = "MainThread" sets its name, which prints as null if this line is skipped. Console.WriteLine then reads th.Name back and prints This is MainThread.

Controlling a Thread From the Outside: Suspend, Resume, and Abort

Beyond Start, Join, and Sleep, Thread exposes a further set of methods for acting on a thread instance from outside its own code: Suspend, Resume, and Abort. Suspend() and Resume() are a pair, named for pausing a running thread from the outside and letting it continue again; their exact mechanics are covered later, alongside inter-thread communication. Abort() is the most drastic of the group: on the classic .NET Framework it ends a thread's execution by raising a ThreadAbortException inside it, forcing it to unwind immediately regardless of where it currently is. On .NET Core and .NET 5+, the runtime this lesson's Prerequisites target, Abort() is no longer supported: calling it throws a PlatformNotSupportedException back on the thread that called it, and the target thread is not terminated or unwound at all, it simply keeps running to completion as if Abort() had never been called.

Thread objThread = new Thread(ProcessJoin);
objThread.Start();
Thread.Sleep(50);   // let it get going first
objThread.Abort();

new Thread(ProcessJoin) and objThread.Start() create and run the thread as usual. This snippet uses Thread.Sleep(50) rather than Join() before calling Abort(), a deliberate choice: calling Abort() after Join() has already returned would have nothing left to abort, since the thread has by then already finished, so Sleep(50) instead gives it a moment to actually be running before Abort() reaches it. On the classic .NET Framework, objThread.Abort() would raise ThreadAbortException inside ProcessJoin, from whatever line it happens to be executing, terminating it; on .NET Core / .NET 5+ it instead throws PlatformNotSupportedException back on the caller, leaving ProcessJoin running unaffected. Run this snippet on your own SDK and write down exactly what you observe, since Abort()'s exact effect depends on the runtime and is worth confirming empirically rather than assumed in advance. The full standalone version of this experiment is in the "Complete program listing" section at the end of this lesson.

Passing Data to a Thread

ThreadStart takes no parameters, so it cannot carry data into a thread. ParameterizedThreadStart is used instead: its method takes a single object parameter. Start(object) hands its argument to that delegate. This works but costs type safety, since any type compiles and a wrong type only fails at run time, and it costs a boxing/unboxing conversion for every value type passed through it. Moving the data and method into a small helper class fixes both: the constructor accepts the value with its real type and stores it in a private field, and the thread method, now parameterless, reads that field.

static void DisplayNumbers(object state)
{
    int max = Convert.ToInt32(state);
    for (int i = 1; i <= max; i++)
        Console.WriteLine(i);
}
// ...
Thread t = new Thread(DisplayNumbers);
t.Start(10);

state arrives as object, so Convert.ToInt32(state) is required before it can be used as a loop bound; a non-numeric argument throws a FormatException, but only once the thread actually runs.

class NumberHelper
{
    private readonly int number;
 
    public NumberHelper(int number) => this.number = number;
 
    public void DisplayNumbers()
    {
        for (int i = 1; i <= number; i++)
            Console.WriteLine(i);
    }
}
// ...
NumberHelper helper = new NumberHelper(10);
Thread t = new Thread(helper.DisplayNumbers);
t.Start();

The constructor takes a real int, so new NumberHelper("ten") fails to compile instead of failing at run time. DisplayNumbers takes no parameters, matching plain ThreadStart, and reads number from the field the constructor already validated. No boxing, no unboxing, no run-time type mismatch.

Retrieving a Result From a Thread

Both delegates return void, so a thread's method can never hand a value directly back to its caller. The fix is a callback delegate: a reference supplied by the caller, stored inside the helper class, that the thread method invokes once it has a result. The thread method should not hard-code which method receives the result, since different callers may want it delivered differently; passing the callback in as a constructor parameter keeps that choice with the caller.

sequenceDiagram
    participant Caller as Caller (e.g. Main)
    participant Worker as Worker Thread
    participant Helper as NumberHelper.SumNumbers
    participant Callback as onDone (ShowResult)
 
    Caller->>Helper: new NumberHelper(number, ShowResult)
    Caller->>Worker: new Thread(helper.SumNumbers); Start()
    Worker->>Helper: run SumNumbers()
    Helper->>Helper: compute result
    Helper->>Callback: onDone.Invoke(result)
    Callback-->>Caller: prints the result
public delegate void CallbackDelegate(int result);
 
class NumberHelper
{
    private readonly int number;
    private readonly CallbackDelegate onDone;
 
    public NumberHelper(int number, CallbackDelegate onDone)
    {
        this.number = number;
        this.onDone = onDone;
    }
 
    public void SumNumbers()
    {
        int result = 0;
        for (int i = 1; i <= number; i++)
            result += i;
 
        onDone?.Invoke(result);
    }
}
// ...
NumberHelper helper = new NumberHelper(10, ShowResult);
Thread t = new Thread(helper.SumNumbers);
t.Start();
 
static void ShowResult(int result) => Console.WriteLine($"Result: {result}");

CallbackDelegate fixes the shape every callback must have: void return, one int parameter. The constructor stores both the data and the callback in onDone. Inside SumNumbers, once result is computed, onDone?.Invoke(result) calls back into whichever method was supplied, here ShowResult; the ?. guard skips the call if none was given.

The WinForms Message Pump and a Non-Responsive UI

A Windows Forms application is driven entirely by messages the operating system sends it: mouse moves, key presses, clicks, repaint requests. These sit in a first-in-first-out queue, and the application continuously pulls them out and dispatches each to the right event handler. This loop is the message pump, hidden inside Application.Run(new Form1()) in the application's generated startup Main(), and it runs on the thread that created the form, the UI thread.

The UI thread can only process the next message once it finishes the current one. If a Click handler runs something slow, the UI thread is stuck inside it and cannot go back to pulling messages, so paint and mouse messages pile up unprocessed. After a few seconds of that, Windows decides the application has hung and appends "(Not Responding)" to its title.

sequenceDiagram
    participant OS as Operating System
    participant Queue as Message Queue
    participant UI as UI Thread (message pump)
    participant Handler as Click Handler
 
    OS->>Queue: mouse move, click, repaint requests
    loop message pump
        UI->>Queue: pull next message
        UI->>Handler: dispatch to event handler
        Handler-->>UI: handler returns
    end
    Note over UI,Handler: a slow handler never returns,<br/>so the pump never pulls the next message
    OS->>OS: no response for a few seconds -> "(Not Responding)"
private void button1_Click(object sender, EventArgs e)
{
    Thread.Sleep(20 * 1000);
}

This stands in for any slow operation, a long database query or a web request. As soon as the user clicks, the UI thread enters this handler and blocks for twenty seconds. During that time the message pump is not running: the form cannot repaint itself if uncovered, clicks pile up instead of being handled, and the process is marked not responding well before the twenty seconds are up.

Fixing the Frozen UI: Control.Invoke and BackgroundWorker

WinForms controls may only be read or written from the thread that created them, normally the UI thread; touching one from another thread throws InvalidOperationException, "Cross-thread operation not valid." Control.Invoke marshals a call back onto the owning UI thread: a background Thread wraps any control update in a delegate and passes it to control.Invoke(...), which pauses the background thread, runs the delegate on the UI thread through the message pump, then lets the background thread continue. BackgroundWorker is a higher-level component that runs code on a background thread while reporting progress and completion back on the UI thread automatically, with no Invoke calls of your own.

sequenceDiagram
    participant BG as Background Thread
    participant Invoke as control.Invoke
    participant Pump as UI Thread (message pump)
 
    BG->>BG: computing, loop running
    BG->>Invoke: pass a delegate wrapping the control update
    Invoke->>Pump: marshal the delegate onto the UI thread
    Note over BG: background thread pauses here
    Pump->>Pump: run the delegate (update textBox1/button1)
    Pump-->>Invoke: delegate finished
    Invoke-->>BG: Invoke() returns, background thread resumes

This overload of Control.Invoke takes an actual Delegate instance plus its arguments, not a lambda, and that instance's signature must match the target method exactly; this is why the code below declares DisplayCountDelegate and EnableButtonDelegate as two separate delegate types, one matching DisplayCount(int) and one matching the parameterless EnableButton(), instead of a single shared type.

private delegate void DisplayCountDelegate(int i);
private delegate void EnableButtonDelegate();
 
private void button1_Click(object sender, EventArgs e)
{
    var thread = new Thread(StartCounting);
    thread.IsBackground = true;
    thread.Start();
    button1.Enabled = false;
}
 
private void StartCounting()
{
    for (var i = 0; i < 10; i++)
    {
        textBox1.Invoke(new DisplayCountDelegate(DisplayCount), i);
        Thread.Sleep(1000);
    }
    button1.Invoke(new EnableButtonDelegate(EnableButton));
}
 
private void DisplayCount(int i) => textBox1.Text = i.ToString();
private void EnableButton() => button1.Enabled = true;

button1_Click starts a background thread and disables the button, stopping a second counting thread from starting while the first runs; thread.IsBackground = true marks that counting thread as a background thread, so it does not by itself keep the process alive if every foreground thread has already finished. Inside StartCounting, textBox1.Invoke(new DisplayCountDelegate(DisplayCount), i) marshals the call to DisplayCount(i) onto the UI thread instead of setting textBox1.Text directly, avoiding the cross-thread exception; DisplayCount(int i) is the method actually reached through that call, and it sets textBox1.Text = i.ToString(). button1.Invoke(...) re-enables the button the same way once the loop ends, reaching EnableButton(), which sets button1.Enabled = true.

BackgroundWorker wraps this into three events, all subscribed to on the UI thread: DoWork runs on a background thread and its handler receives a DoWorkEventArgs, ReportProgress(percentage) inside it raises ProgressChanged on the UI thread automatically and hands its handler a ProgressChangedEventArgs whose ProgressPercentage field carries the value passed to ReportProgress, and once DoWork returns, RunWorkerCompleted fires there too with a RunWorkerCompletedEventArgs. Each handler follows the same (object sender, EventArgs e) shape used throughout .NET's event model, the same way CallbackDelegate was defined before it was used earlier in this lesson.

private readonly BackgroundWorker worker;
 
public Form1()
{
    InitializeComponent();
    worker = new BackgroundWorker { WorkerReportsProgress = true };
    worker.DoWork += StartCounting;
    worker.ProgressChanged += Worker_ProgressChanged;
    worker.RunWorkerCompleted += Worker_RunWorkerCompleted;
}
 
private void button1_Click(object sender, EventArgs e)
{
    worker.RunWorkerAsync();
    button1.Enabled = false;
}
 
private void StartCounting(object sender, DoWorkEventArgs e)
{
    var bgWorker = (BackgroundWorker)sender;
    for (var i = 0; i < 10; i++)
    {
        bgWorker.ReportProgress(i);
        Thread.Sleep(1000);
    }
}
 
private void Worker_ProgressChanged(object sender, ProgressChangedEventArgs e)
    => textBox1.Text = e.ProgressPercentage.ToString();
 
private void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
    => button1.Enabled = true;

In the constructor, worker = new BackgroundWorker { WorkerReportsProgress = true } builds the worker once, and the three += lines subscribe StartCounting, Worker_ProgressChanged, and Worker_RunWorkerCompleted to its DoWork, ProgressChanged, and RunWorkerCompleted events respectively, so each runs automatically when its event fires rather than being called directly anywhere in the code. The DoWork event's delegate shape is fixed to (object sender, DoWorkEventArgs e), which is why StartCounting here takes those exact parameters instead of being parameterless as it was in the Control.Invoke version above; inside it, var bgWorker = (BackgroundWorker)sender casts sender back to the concrete BackgroundWorker type so ReportProgress can be called on it, since the event signature only guarantees sender as the general object type. WorkerReportsProgress = true is required before ReportProgress may be called. worker.RunWorkerAsync() starts DoWork on a pool thread. bgWorker.ReportProgress(i) is called from the background thread, but the ProgressChanged event it raises is delivered on the UI thread, so Worker_ProgressChanged sets textBox1.Text directly with no Invoke; the same is true of Worker_RunWorkerCompleted, which re-enables the button once StartCounting returns.

Lab Tasks

Task 1: Program, Process, and Your First Thread

  1. Create a new C# console project.
  2. Write LongOperation(): loop 1 to 5, printing each number with a one-second Thread.Sleep between prints.
  3. In Main: print "Main thread started.", start a Thread on LongOperation, immediately print "Main thread is doing other work.", call Join(), then print "Main thread completed."
  4. While LongOperation is looping, pause execution with a breakpoint placed inside it, then open your IDE's thread-inspection views (such as Threads and Parallel Stacks) to see myThread listed as its own thread of execution, separate from the main thread.

Expected output: "Main thread started." and "Main thread is doing other work." print immediately, one right after the other, then the five numbers print one per second, and only after the fifth does "Main thread completed." appear, proving the main thread kept going until Join() forced it to wait. In step 4, the thread views list at least two rows, the main thread and myThread, and draw them as separate call stacks, confirming visually that LongOperation really is running on a separate thread rather than only appearing to from the console output.

Task 2: Thread Lifecycle, Naming, and Trying to Abort a Thread

  1. Extend Task 1. Print myThread.ThreadState before Start(), right after Start(), and again after Join().
  2. Inside LongOperation, print Thread.CurrentThread.Name ?? "unnamed", then set Thread.CurrentThread.Name = "Worker" and print it again. LongOperation is now running on myThread, not on Main's thread, so Thread.CurrentThread here returns myThread itself; the naming pattern from the concept above carries over unchanged.
  3. Before calling Join(), add Thread.Sleep(50) on the main thread followed by myThread.Abort(), wrapped in a try/catch that prints whichever exception, if any, it catches. The "Complete program listing" section at the end of this lesson runs the same experiment standalone if you want to try it in isolation first.

Expected output: the state prints show Unstarted, Running, Stopped; the name prints show unnamed then Worker; for step 3, on .NET Core / .NET 5+ (this lesson's Prerequisites), expect the try/catch to print a caught PlatformNotSupportedException rather than a ThreadAbortException, and LongOperation to keep running to completion in the background, completely unaffected by the failed Abort() call, exactly as the concept above describes for this runtime. Run it and write down exactly what you observe to confirm this for yourself rather than taking it on faith.

Task 3: Passing Data to a Thread

  1. Write DisplayNumbers(object state): wrap the Convert.ToInt32(state) conversion itself in a try/catch that prints whichever exception it catches, and only loop from 1 through the converted value when the conversion succeeds. Create one Thread and call .Start(10), then Join() it before continuing; only after it has printed do the same with a second, separate Thread on the same method calling .Start("ten"), then Join() it too.
  2. Create NumberHelper with a constructor taking an int and a parameterless DisplayNumbers(). Construct it and start a thread on it, then separately try new NumberHelper("ten").

Expected output: the object version prints 1 to 10 for the thread started with 10; the thread started with "ten" prints a caught FormatException message instead of numbers, because the try/catch sits inside DisplayNumbers itself, on the very thread where Convert.ToInt32 throws. Running the two threads one after another here is only to keep the printed output easy to read, not what makes the exception catchable. The helper-class version prints 1 to 10 the same way, but new NumberHelper("ten") fails to compile, proving it is type-safe where the object version was not.

Task 4: Retrieving a Result From a Thread

  1. Add CallbackDelegate and change NumberHelper to sum 1 through its number instead of printing, invoking the callback with the sum.
  2. Write ShowResult(int result) and ShowResultAgain(int result), each printing the same result value through its own message text, without altering the number itself.
  3. Start two NumberHelper instances with the same number but a different callback passed to each constructor.

Expected output: both threads compute the same sum and print the same number, but each through its own callback's message text, proving NumberHelper never decides where its result goes or how it is worded.

Task 5: WinForms, Keeping the UI Responsive

  1. Create a Windows Forms App with one Button (button1) and one TextBox (textBox1).
  2. Wire button1's Click to call Thread.Sleep(20000) directly on the UI thread; run it, click the button, and try moving or resizing the window while it sleeps.
  3. Replace that handler with the StartCounting background-thread version from the Control.Invoke concept, updating textBox1 via Invoke and disabling/re-enabling button1 around the loop.
  4. Convert step 3 to a BackgroundWorker with DoWork, ProgressChanged, and RunWorkerCompleted, using the DoWork/ProgressChanged/RunWorkerCompleted wiring from the BackgroundWorker half of the "Fixing the Frozen UI" concept above.

Expected output: step 2 leaves the window unresponsive, unable to redraw or resize, labeled "(Not Responding)" for the full twenty seconds. Step 3 keeps it responsive while textBox1 counts 0 to 9 one second at a time and button1 disables then re-enables automatically. Step 4 produces the same visible behavior with no Invoke calls of your own.

Summary

  • A process owns memory and resources; a thread is the unit that executes code inside a process, and every C# program starts with one main thread already running.
  • Thread plus ThreadStart creates a custom thread; Start() runs it, Join() waits for it, Sleep() pauses a thread without spending CPU time.
  • Foreground threads keep the application alive; background threads (IsBackground = true) are cut off the moment the last foreground thread exits.
  • ThreadState moves from Unstarted, to Running, through waiting states like Sleep or Join, to Stopped, corresponding to the Ready, Not Runnable, and Dead states described conceptually; Thread.CurrentThread returns the running thread's own instance, and its Name labels a thread for easier debugging.
  • Suspend() and Resume() are named for pausing and continuing a thread from the outside, with their exact mechanics covered later; Abort() raises ThreadAbortException inside a thread to force it to unwind on the classic .NET Framework, but on .NET Core / .NET 5+ it throws PlatformNotSupportedException back on the caller instead and leaves the target thread running unaffected, so its exact effect is worth confirming empirically on your own SDK rather than assumed in advance.
  • ParameterizedThreadStart passes one object parameter into a thread, costing boxing and compile-time type safety; wrapping the data and method in a helper class restores type safety, and doing so also works around Thread being a sealed class that cannot be subclassed or given a custom constructor.
  • Threads cannot return a value directly; a callback delegate, supplied by the caller and invoked by the thread method when it finishes, delivers a result back.
  • A WinForms application is driven by a message pump inside Application.Run(); blocking the UI thread stops that pump, which is why a slow event handler freezes the window and the operating system reports "(Not Responding)."
  • WinForms controls can only be touched from the thread that created them; Control.Invoke marshals a call back to that thread, and BackgroundWorker (DoWork, ReportProgress/ProgressChanged, RunWorkerCompleted) achieves the same result without hand-written Invoke calls.

Complete program listing

This is the standalone experiment for Task 2, step 3 (Trying to Abort a Thread), referenced above. It runs the same Sleep/Abort/try-catch pattern in isolation before it gets wired into the Task 1/2 program. On the classic .NET Framework, Abort() raises a ThreadAbortException inside the target thread to force it to unwind. On .NET Core / .NET 5+ (the runtime named in this lesson's Prerequisites), Abort() is not supported: it throws a PlatformNotSupportedException back on the caller instead, and the worker thread is left completely unaffected. Run this on your own SDK and write down exactly what you observe rather than assuming the exception type in advance.

using System;
using System.Threading;
 
class Program
{
    static void Main()
    {
        Thread worker = new Thread(ProcessJoin);
        worker.Start();
 
        Thread.Sleep(50); // let the worker get going before we touch it
 
        try
        {
            worker.Abort();
            Console.WriteLine("Abort() returned with no exception thrown.");
        }
        catch (Exception ex)
        {
            // Write down the exact exception type and message you see here,
            // and compare it against what the concept above describes.
            Console.WriteLine($"Abort() raised: {ex.GetType().Name} - {ex.Message}");
        }
 
        worker.Join();
        Console.WriteLine("Main thread completed.");
    }
 
    static void ProcessJoin()
    {
        for (int i = 1; i <= 10; i++)
        {
            Console.WriteLine($"Worker thread: {i}");
            Thread.Sleep(200);
        }
    }
}