Python Thread Join: How to Wait for Threads
Learn how python thread join blocks the calling thread until a target thread completes, with practical examples, timeout handling, and common pitfalls.
When you start a thread in Python, the main program continues immediately without waiting for that thread to finish. To make the main program pause until a specific thread completes, you call join() on the Thread object. This is the essence of python thread join: it provides a simple synchronization point between the caller and the background work.
What Does Thread.join() Actually Do?
The join() method blocks the calling thread until the thread whose join() method is called terminates. It returns None once the target thread has finished. If the the target thread is already finished when join() is called, it returns immediately. If the target thread is never started, calling join() raises a RuntimeError.
This behavior is useful when you need to ensure that a background task has completed before you proceed, for example when collecting results from worker threads or when shutting down an application cleanly.
Basic Usage: Joining a Single Thread
The simplest pattern is to create a Thread, start it, and then call join() on it:
import threading import time def worker(): print("Worker started") time.sleep(2) print("Worker finished") thread = threading.Thread(target=worker) thread.start() print("Main is waiting for worker...") thread.join() print("Worker has completed, main continues")
In this example, thread.join() blocks the main thread until the worker thread finishes its time.sleep(2) and prints its final message. Without the join(), the main program would print "Worker has completed" immediately after starting the thread, which is rarely what you want when you depend on the thread's side effects.
Joining Multiple Threads: Order Matters
When you have several threads, you typically start them all first, then join them one by one. The order in which you call join() affects how long the main thread waits, but it does not affect the execution of the threads themselves. For example:
import threading import time def worker(name, delay): time.sleep(delay) print(f"{name} finished") threads = [] for i, delay in enumerate([1, 2, 3]): t = threading.Thread(target=worker, args=(f"Thread-{i}", delay)) t.start() threads.append(t) for t in threads: t.join() print("All threads finished")
Here, the main thread calls join() on each thread in the order they were started. The total waiting time is roughly the maximum of the delays, not the sum, because the threads run concurrently. Joining in a different order would still wait for all threads to finish, but the main thread would block until the first joined thread completes, then the next, and so on.
Using timeout to Avoid Indefinite Blocking
A thread might hang due to a bug, an external resource, or a deadlock. To avoid blocking forever, join() accepts a timeout argument, measured in seconds. If the thread does not finish within the timeout, join() returns anyway, and you can check whether the thread is still alive with is_alive().
import threading import time def slow_worker(): time.sleep(10) thread = threading.Thread(target=slow_worker) thread.start() thread.join(timeout=2) nif thread.is_alive(): n print("Thread is still running after 2 seconds") else: print("Thread finished within 2 seconds")
nThe timeout parameter does not kill the thread; it only limits how long the caller waits. The thread continues to run in the background. This is useful for graceful shutdown procedures where you give threads a chance to finish, but you do not want to hang forever.
Daemon Threads and join()
Daemon threads are threads that do not prevent the program from exiting when only daemon threads remain. Calling join() on a daemon thread still works exactly the same way: it blocks until the thread finishes or the timeout expires. However, if the main program exits before the daemon thread completes, the daemon thread is abruptly stopped. This means that joining a daemon thread is only meaningful if you actually wait for it before the program exits.
import threading import time def daemon_worker(): while True: print("Daemon running") time.sleep(1) thread = threading.Thread(target=daemon_worker, daemon=True) thread.start() # Give the daemon a moment to run thread.join(timeout=3) print("Main is exiting; daemon will be killed")
In this example, the daemon thread runs for three seconds while the main thread waits, then the program exits and the daemon thread is terminated. Without the join(), the main thread would exit immediately and the daemon would never get a chance to run.
Common Pitfalls: Deadlock and Calling join() on the Current Thread
Calling join() on the current thread is a common mistake. For example, if a thread calls self.join() inside its own target function, it will raise a RuntimeError because a thread cannot wait for itself to finish. This often happens when you accidentally reuse a Thread object inside its own run method.
Another pitfall is joining a thread that is waiting for another thread to finish, creating a deadlock. Consider two threads that each call join() on the other. Neither can proceed because each is waiting for the other to finish. This is a classic deadlock scenario in multithreaded programming.
To avoid these issues, always call join() from a different thread (usually the main thread), and never create circular join dependencies. If you need to coordinate multiple threads, consider using Event, Lock, or Queue instead of relying solely on join().
When Not to Use join()
join() is a blocking call, so it is not suitable for event-driven or asynchronous code where blocking the main thread is undesirable. Inor GUI applications, you should not call join() on the main thread because it freezes the interface. Instead, use a non-blocking mechanism such as a callback, a queue.Queue, or a threading.Event to signal completion.
Similarly, if you have a long-running thread that is supposed to keep working in the background, you should not join it at all. The main program should continue running and let the background thread do its work. join() is meant for scenarios where you explicitly need to wait for a thread to finish before proceeding.
Thread.join() vs. Other Synchronization Primitives
join() is a coarse-grained synchronization tool: it waits for the entire thread to terminate. If you need to coordinate at a finer granularity, such as waiting for a specific condition or exchanging data, other primitives are more appropriate.
| Primitive | Purpose | When to Use |
|---|---|---|
join() | Wait for thread termination | When you need the thread to be completely done before continuing |
Event | Signal that a condition occurred | When you need to wait for a specific state, not necessarily thread termination |
Lock | Protect shared resources | When multiple threads access shared data and need mutual exclusion |
Queue | Pass data between threads | When you need to send results or tasks between threads without manual locking |
Using join() when you actually need an Event can lead to unnecessary blocking and slower response times. Conversely, using an Event when you need to ensure all threads have finished can be error-prone because you have to set the event at the end of each thread, which is easy to miss.
Practical Advice for Production Code
In production code, always consider what happens if a thread never finishes. A bare join() without a timeout can hang your program indefinitely. A common pattern is to use a timeout and then decide whether to force-exit or log a warning. Also, remember that join() only waits for the thread to finish; it does not propagate exceptions raised inside the thread. If your thread can raise an exception, you need to catch it inside the thread and store it in a shared variable or a queue, then check it after joining.
Another production concern is the order of joins during shutdown. If you have multiple threads that depend on each other, joining them in the wrong order can cause deadlocks. Design your shutdown sequence carefully, and test it under load. In some cases, using a ThreadPoolExecutor with shutdown(wait=True) provides a higher-level abstraction that handles joining and exception propagation more gracefully.
Finally, remember that join() is not a replacement for proper synchronization of shared data. Even if you join a thread, you still need locks or other mechanisms to avoid race conditions if multiple threads modify the same data before they finish. join() only guarantees that the thread has finished, not that its writes are visible to other threads in a predictable order—though in CPython, the GIL often makes writes visible, but relying on that is not portable across Python implementations.