multithreading
thread synchronization
concurrent programming
child threads
thread management

How to make main thread wait for all child threads finish?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Making the main thread wait for worker threads is a standard synchronization task. The simplest version is to start several threads, let them do their work, and then block the main thread until they all finish, but the best implementation depends on whether you created raw threads directly or you are using a thread pool or task framework.

The Classic Answer: join()

When you own the Thread objects directly, the usual solution is join(). It blocks the calling thread until the target thread completes.

Here is a basic Java example:

java
1public class JoinExample {
2    public static void main(String[] args) throws InterruptedException {
3        Thread t1 = new Thread(() -> doWork("A"));
4        Thread t2 = new Thread(() -> doWork("B"));
5        Thread t3 = new Thread(() -> doWork("C"));
6
7        t1.start();
8        t2.start();
9        t3.start();
10
11        t1.join();
12        t2.join();
13        t3.join();
14
15        System.out.println("All child threads finished.");
16    }
17
18    private static void doWork(String name) {
19        try {
20            Thread.sleep(500);
21            System.out.println("Worker " + name + " done");
22        } catch (InterruptedException e) {
23            Thread.currentThread().interrupt();
24        }
25    }
26}

The main thread reaches the final print only after all three workers are done.

Why join() Works Well

join() is straightforward when:

  • the number of child threads is small
  • you created them manually
  • the control flow is simple

It is easy to read and perfectly appropriate for many programs. The main limitation is that it does not scale gracefully to more complex orchestration patterns.

Waiting for Many Tasks with Executors

If you are using a thread pool, waiting on raw thread objects is often the wrong abstraction. A better pattern is to submit tasks and wait on Future objects:

java
1import java.util.List;
2import java.util.concurrent.Callable;
3import java.util.concurrent.ExecutorService;
4import java.util.concurrent.Executors;
5import java.util.concurrent.Future;
6
7public class FutureExample {
8    public static void main(String[] args) throws Exception {
9        ExecutorService pool = Executors.newFixedThreadPool(3);
10
11        List<Callable<String>> tasks = List.of(
12            () -> "task 1",
13            () -> "task 2",
14            () -> "task 3"
15        );
16
17        List<Future<String>> futures = pool.invokeAll(tasks);
18
19        for (Future<String> future : futures) {
20            System.out.println(future.get());
21        }
22
23        pool.shutdown();
24        System.out.println("All tasks finished.");
25    }
26}

This is often cleaner because the waiting logic matches the task abstraction already used by the codebase.

Coordination Tools Beyond join()

Other synchronization tools exist for more complex cases:

  • 'CountDownLatch when several workers must signal completion'
  • 'CyclicBarrier when threads must meet at a common phase'
  • 'CompletableFuture.allOf() for asynchronous task composition'

For example, CountDownLatch is useful when child threads are created in different parts of the program and the main thread needs a single "all done" signal.

Timeouts and Failure Handling

Waiting forever is often a bad default. Threads can deadlock, block on I/O, or fail silently. If the application cannot tolerate indefinite blocking, use timeouts:

java
t1.join(1000);

For executor-based code, use bounded waits or cancellation-aware patterns. Also decide what "finished" means when a child thread throws an exception. Completion and success are not the same thing.

Common Pitfalls

The biggest mistake is starting threads and then assuming they will finish before the main thread exits. Without explicit waiting, the program may terminate early or continue before results are ready.

Another mistake is joining from the wrong place. If a UI thread or request thread blocks unnecessarily, the application can become unresponsive.

People also forget interruption handling. If join() is interrupted, the code should usually restore the interrupt status or handle shutdown deliberately.

Finally, if you are already using a high-level concurrency API, avoid dropping down to raw thread management unless there is a real reason. Mixing abstractions often makes code harder to maintain.

Summary

  • Use join() when you created raw child threads and want the main thread to wait for them.
  • Use executor or task-based waiting when the program already uses a thread pool.
  • Consider latches or futures for more complex coordination.
  • Prefer bounded waits when indefinite blocking is risky.
  • Waiting for completion is simple, but handling failure and cancellation correctly is just as important.

Course illustration
Course illustration

All Rights Reserved.