You can't kill a thread โ you ask it to stop. Everything about shutdown flows from that one hard truth.
Coming from other runtimes you might look for thread.kill() or thread.terminate().
They do not exist in Python โ deliberately. There is no safe, supported way to forcibly stop a
running thread from the outside.
Lock (now never released โ deadlock), halfway through a file write
(corrupt data), or mutating a shared dict (leaving it in a broken state). Killing a thread means
leaving the whole process in an unknown state.
(You may find ctypes tricks that inject an exception into another thread. They are
unreliable โ the exception only fires when that thread runs Python bytecode, never while it's blocked
in C โ and they can corrupt state. Do not use them in real code.)
threading.Event
The canonical cooperative-stop tool is threading.Event: a thread-safe boolean. The main
thread sets it to signal "please stop"; workers loop while not stop_event.is_set()
and exit the loop the next time they check.
Event to every worker. Workers check it at the
top of each loop iteration. To shut down, the main thread calls stop_event.set() and then
join()s the workers.
import threading
stop_event = threading.Event()
def worker(name):
while not stop_event.is_set(): # check the flag every iteration
do_one_unit_of_work(name)
print(f"{name} stopping cleanly") # runs after the flag is seen
t = threading.Thread(target=worker, args=("A",))
t.start()
# ... later, to shut down:
stop_event.set() # ask it to stop
t.join() # wait for it to actually finish
Event.wait(), Never time.sleep()
The naive version above spins as fast as it can. Real workers usually pause between units of work
(poll every 5 seconds, retry after a delay). The instinct is time.sleep(5) โ but that's a
trap for shutdown.
# โ Bad: a thread asleep in time.sleep(5) can't see the flag for up to 5s
def worker():
while not stop_event.is_set():
poll()
time.sleep(5) # shutdown blocks here โ unresponsive
# โ
Good: Event.wait IS the sleep, and it wakes early when the flag is set
def worker():
while not stop_event.is_set():
poll()
stop_event.wait(5) # sleeps up to 5s, OR returns instantly on set()
stop_event.wait(timeout) blocks up to
timeout seconds but returns immediately the moment the event is set. So
set() both stops the loop and interrupts the current sleep. Your worker reacts to
shutdown in milliseconds instead of after a full sleep interval. Use Event.wait() anywhere
you'd otherwise time.sleep() inside a stoppable loop.
Here's the complete pattern: a pool of workers, a shared stop flag, a responsive wait, and a clean
set() โ join() shutdown driven from the main thread.
import threading
import time
stop_event = threading.Event()
def worker(name):
print(f"{name} started")
while not stop_event.is_set():
print(f"{name} working...")
# Do a chunk of work, then wait โ but wake early on shutdown.
# wait() returns True if the event was set, False on timeout.
if stop_event.wait(timeout=1.0):
break # optional: bail out mid-cycle too
print(f"{name} shut down")
workers = [
threading.Thread(target=worker, args=(f"worker-{i}",))
for i in range(3)
]
for t in workers:
t.start()
try:
time.sleep(3) # let them run for a bit
finally:
print("main: signalling shutdown")
stop_event.set() # ask everyone to stop
for t in workers:
t.join(timeout=5) # wait, but don't hang forever
print("main: all workers joined")
try/finally. Whatever happens in the main thread โ normal exit
or an exception โ the finally guarantees you signal the workers and join them. That's how
you avoid orphaned threads keeping the process alive.
Graceful shutdown is a three-beat handshake between the main thread and its workers. No worker is ever forced โ each one notices the flag, finishes its current unit, drains, and returns.
Workers pulling from a queue.Queue have a subtler problem: they're
usually blocked in queue.get(), so an Event alone won't wake them. The clean
solution is a sentinel โ a special "poison pill" value that means "no more work, shut
down." Put one sentinel per worker so each consumer receives exactly one.
import threading
import queue
work_queue = queue.Queue()
SENTINEL = None # our "stop now" marker
def consumer():
while True:
item = work_queue.get() # blocks until an item arrives
if item is SENTINEL: # got the poison pill
work_queue.task_done()
break # exit the loop โ thread ends
handle(item)
work_queue.task_done()
workers = [threading.Thread(target=consumer) for _ in range(4)]
for t in workers:
t.start()
for job in jobs:
work_queue.put(job)
# Shutdown: one sentinel per worker so every consumer unblocks and exits.
for _ in workers:
work_queue.put(SENTINEL)
for t in workers:
t.join()
get().
One sentinel only stops one worker; the rest stay blocked forever. Enqueue len(workers)
sentinels. The sentinels sit behind the real work, so every real job is processed first.
KeyboardInterrupt and Signals on the Main Thread
When a user hits Ctrl+C, Python raises KeyboardInterrupt โ but only in the
main thread. Worker threads never see it. That's actually convenient: catch it in
main() and turn it into a normal stop signal.
def main():
for t in workers:
t.start()
try:
while any(t.is_alive() for t in workers):
time.sleep(0.5) # main stays alive, watching
except KeyboardInterrupt:
print("\nCtrl+C โ shutting down gracefully")
finally:
stop_event.set() # translate the interrupt into a stop
for t in workers:
t.join()
SIGTERM (what kill and Docker
send on shutdown), register a handler with signal.signal(signal.SIGTERM, handler) in the
main thread that simply calls stop_event.set(). Signal handlers, like
KeyboardInterrupt, only run in the main thread โ so keep your main thread free to receive
them, and let it drive the same cooperative shutdown.
ThreadPoolExecutor
If you're using a ThreadPoolExecutor (and you usually should),
shutdown() is your handshake. The context manager calls it for you on exit.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as pool:
futures = [pool.submit(task, x) for x in items]
# __exit__ calls pool.shutdown(wait=True): block until all tasks finish
# Explicit control:
pool.shutdown(wait=True) # wait for running + queued tasks
pool.shutdown(wait=True, cancel_futures=True) # Py 3.9+: drop not-yet-started
# tasks, still wait for running ones
cancel_futures=True (Python 3.9+): cancels every task still sitting in
the queue that hasn't started. It cannot cancel tasks already running โ same rule as always,
you can't interrupt a running thread. Those still run to completion. If your tasks are long-running and
need to stop early, they must also check a stop Event internally.
t.join(timeout) waits at most timeout seconds, then returns whether or not
the thread finished. Check t.is_alive() afterwards to know which happened.
stop_event.set()
for t in workers:
t.join(timeout=5)
if t.is_alive():
print(f"WARNING: {t.name} did not stop in time โ likely stuck in a "
f"blocking call or ignoring the stop flag")
main() until every non-daemon thread has finished. So a
worker stuck in a blocking call it never checks out of โ an un-interruptible socket.recv(),
a time.sleep(3600), a C call that ignores the GIL โ will hang your entire program on exit.
The fix is design: make blocking calls have timeouts, and check the stop flag between them.
A daemon thread (Thread(target=..., daemon=True)) is one the interpreter
will not wait for. When all non-daemon threads finish, the process exits and daemon threads
are killed abruptly โ mid-execution, no cleanup.
t = threading.Thread(target=worker, daemon=True)
t.start()
# When main() returns, this thread is terminated instantly โ no finally, no cleanup.
Event
handshake first; use daemon=True only when abrupt death is genuinely harmless.
| Concept | Takeaway |
|---|---|
No kill() | You can't force-stop a thread โ shutdown is always cooperative |
threading.Event | Shared stop flag; workers loop while not stop_event.is_set() |
Event.wait(t) | Use instead of time.sleep(t) โ wakes instantly on set() |
| Sentinel | Poison-pill a queue: one put(None) per consumer |
KeyboardInterrupt | Only the main thread gets it โ catch it, then set() the flag |
shutdown(cancel_futures=True) | Drops queued tasks (3.9+); can't stop running ones |
join(timeout) | Bounded wait; check is_alive() to detect a stuck thread |
daemon=True | Last resort โ killed on exit with zero cleanup |