Waiting for Threads to Complete
If you've ever started background threads and needed to wait for them to finish before proceeding, you've needed Thread.Join. Without it, your main thread exits while background work is still running, leaving results incomplete or resources leaked.
Thread.Join blocks the calling thread until the target thread terminates. This gives you a simple way to coordinate thread completion. You start threads, let them run independently, then join them when you need their results or want to ensure cleanup happens.
You'll build a parallel data processor that spawns worker threads and waits for all of them to complete before aggregating results. By the end, you'll know when Join makes sense and when higher-level primitives like Task.WaitAll work better.
Basic Thread.Join Usage
Call Join on a thread object to block until that thread finishes. The calling thread suspends execution and waits. Once the target thread exits, Join returns and execution continues.
using System;
using System.Threading;
var worker = new Thread(() =>
{
Console.WriteLine("Worker thread started");
Thread.Sleep(2000);
Console.WriteLine("Worker thread finished");
});
Console.WriteLine("Starting worker");
worker.Start();
Console.WriteLine("Main thread continues...");
worker.Join();
Console.WriteLine("Main thread resumes after worker completes");
The main thread starts the worker, continues with other work, then blocks at Join until the worker completes. This ensures the worker finishes before the main thread proceeds. Without Join, the main thread would exit immediately and the worker might not complete.
Using Join with Timeouts
Pass a timeout to Join to avoid blocking forever if a thread hangs. Join returns true if the thread finished or false if the timeout expired. This lets you detect problems and decide how to handle them.
using System;
using System.Threading;
var worker = new Thread(() =>
{
Console.WriteLine("Worker processing...");
Thread.Sleep(5000);
Console.WriteLine("Worker done");
});
worker.Start();
if (worker.Join(TimeSpan.FromSeconds(2)))
{
Console.WriteLine("Worker finished within timeout");
}
else
{
Console.WriteLine("Worker exceeded timeout, still running");
worker.Join();
Console.WriteLine("Worker eventually completed");
}
The first Join times out after two seconds because the worker needs five. The main thread detects this and handles it. The second Join waits indefinitely for the worker to actually finish. Timeouts prevent your application from hanging when threads misbehave.
Joining Multiple Threads
When you start multiple threads, call Join on each one to wait for all of them to complete. This is a common pattern for parallel processing where you need all results before continuing.
using System;
using System.Collections.Generic;
using System.Threading;
var results = new List<int>();
var lockObj = new object();
var threads = new List<Thread>();
for (int i = 0; i < 5; i++)
{
int taskId = i;
var thread = new Thread(() =>
{
Thread.Sleep(Random.Shared.Next(100, 500));
var result = taskId * taskId;
lock (lockObj)
{
results.Add(result);
}
Console.WriteLine($"Thread {taskId} computed {result}");
});
threads.Add(thread);
thread.Start();
}
Console.WriteLine("All threads started, waiting for completion...");
foreach (var thread in threads)
{
thread.Join();
}
Console.WriteLine($"All threads completed. Results: {string.Join(", ", results)}");
The main thread starts five workers, then joins each one in order. Join blocks until that specific thread finishes, then moves to the next. Once all Joins complete, you know all threads are done and results are safe to access.
Try It Yourself
Build a parallel file processor that counts lines in multiple files using separate threads. Join ensures all counts finish before summing the total.
Steps
- Create:
dotnet new console -n ThreadJoinDemo
- Navigate:
cd ThreadJoinDemo
- Replace Program.cs
- Update .csproj
- Run:
dotnet run
using System;
using System.Collections.Generic;
using System.Threading;
var files = new[] { "file1", "file2", "file3" };
var counts = new Dictionary<string, int>();
var lockObj = new object();
var threads = new List<Thread>();
foreach (var file in files)
{
var thread = new Thread(() =>
{
Thread.Sleep(Random.Shared.Next(100, 300));
var lineCount = Random.Shared.Next(10, 100);
lock (lockObj)
{
counts[file] = lineCount;
}
Console.WriteLine($"{file}: {lineCount} lines");
});
threads.Add(thread);
thread.Start();
}
foreach (var thread in threads)
{
thread.Join();
}
var total = 0;
foreach (var count in counts.Values)
{
total += count;
}
Console.WriteLine($"Total lines across all files: {total}");
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
What you'll see
file2: 47 lines
file1: 82 lines
file3: 35 lines
Total lines across all files: 164
Mistakes to Avoid
Joining from the wrong thread: If thread A calls Join on thread B, while B is waiting for A, you'll deadlock. Both threads block waiting for each other. Use timeouts to detect this or restructure your coordination. Avoid circular dependencies between thread joins.
Not handling Join exceptions: If the joined thread throws an unhandled exception, it terminates but Join won't throw. The exception is lost unless you capture it in the thread's code. Use try-catch inside thread delegates and store exceptions to check after Join.
Joining already-finished threads: Calling Join on a completed thread returns immediately. This is safe but might hide bugs if you expect the thread to still be running. Check Thread.IsAlive before Join if you need to verify the thread was actually running.
Better Alternatives
Prefer Task and async/await for new code. Tasks integrate with the thread pool, support cancellation, and propagate exceptions cleanly. Use Task.WaitAll or await Task.WhenAll instead of managing threads manually. Tasks give you better control and clearer error handling.
For complex coordination, use higher-level primitives like CountdownEvent or Barrier. These provide semantic coordination patterns without manually tracking threads. CountdownEvent lets multiple threads signal completion while one thread waits. Barrier synchronizes threads at specific points in a workflow.
Only use Thread.Join when you're working with threads directly and need explicit control over thread lifetime. Legacy code, interop scenarios, or performance-critical sections where you want dedicated threads might justify this. For most application code, tasks and async/await are simpler and safer.