๐Ÿ‘ป Pattern 8: Daemon Threads & Lifecycle

What states a thread moves through, and what happens to your background threads when main walks out the door.

The Thread Lifecycle

A thread isn't just "running" or "not". It moves through a handful of states, and knowing them makes sense of start(), join(), and why a thread can be "alive" while doing absolutely nothing (blocked on a lock or sleeping).

stateDiagram-v2 [*] --> New: Thread(target=fn) New --> Runnable: start() Runnable --> Running: OS scheduler picks it Running --> Runnable: time slice ends / GIL yield Running --> Blocked: waits on lock / I/O / sleep Blocked --> Runnable: resource ready Running --> Terminated: fn returns or raises Terminated --> [*]
You can't restart a thread. Once terminated, calling start() again raises RuntimeError. Threads are single-use โ€” build a new one (or use a pool).

Daemon vs Non-Daemon: Who Keeps the Program Alive

Every thread is either a daemon or not. The rule is simple and it's the whole point of the flag:

The program exits when only daemon threads remain. Python waits for all non-daemon threads to finish before the process exits. It does not wait for daemon threads โ€” when the last non-daemon thread ends, any surviving daemons are killed on the spot.
import threading, time

def background():
    while True:
        print("daemon tick")
        time.sleep(0.5)

t = threading.Thread(target=background, daemon=True)  # set BEFORE start()
t.start()

time.sleep(1.2)
print("main is done")
# Process exits here. The daemon is killed mid-loop โ€” no more ticks, no cleanup.
The catch โ€” daemons die abruptly. A killed daemon does not get to run finally blocks, flush buffers, close files, or commit transactions. Whatever it was in the middle of is simply abandoned.

So never use a daemon for work that must finish. If losing the work mid-flight would corrupt data or drop a write, it must be a non-daemon thread you explicitly join().
Set daemon before start(). Either Thread(target=fn, daemon=True) or t.daemon = True before starting. Setting it after the thread has started raises RuntimeError. A thread also inherits its parent's daemon status by default.

Why You Normally join() Non-Daemon Threads

A non-daemon thread keeps the process alive on its own โ€” so you don't have to join it to avoid killing it. You join() for a different reason: to know when it's actually done and to surface its outcome before you move on (or before the interpreter tears down and starts closing things out from under it).

import threading, time

def do_work(name):
    time.sleep(1)
    print(f"{name} finished writing its file")

t = threading.Thread(target=do_work, args=("writer",))   # non-daemon (default)
t.start()

# ... do other main-thread work here ...

t.join()          # block until the writer is truly done
print("safe to continue โ€” the file is fully written")
Daemon vs non-daemon, in one line: non-daemon + join() = "wait for this, it matters." Daemon = "background chore; I don't care if it's cut off when we exit."

Inspecting Threads at Runtime

The threading module gives you a small toolbox for introspection โ€” invaluable for logging, debugging hangs, and health checks.

import threading, time

def worker():
    print("in:", threading.current_thread().name)  # the thread running THIS code
    time.sleep(0.5)

t = threading.Thread(target=worker, name="Heartbeat")  # name it โ€” shows up in logs & tracebacks
t.start()

print("alive?      ", t.is_alive())          # True while running, False once terminated
print("this thread:", threading.current_thread().name)  # "MainThread"
print("active:     ", threading.active_count())         # how many threads are alive right now
print("all threads:", [th.name for th in threading.enumerate()])  # list every alive thread

t.join()
print("alive now?  ", t.is_alive())          # False โ€” terminated

The Main Thread

Your program starts life with one thread already running: the main thread (threading.main_thread(), named "MainThread"). It's non-daemon, it's the one that runs your top-level code, and it's the only thread that can receive signals like KeyboardInterrupt (Ctrl-C).

Consequence: when the main thread finishes your script, the interpreter waits for other non-daemon threads to finish, then kills the daemons and shuts down. Long-lived background daemons only survive as long as the main thread (or another non-daemon) keeps the process alive.

Real-World: When a Daemon Is the Right Call (and When It Isn't)

Good daemon jobs โ€” losing them at exit is fine:
  • A heartbeat that pings a monitor every N seconds.
  • A metrics/stats flusher or in-memory cache refresher.
  • A background log watcher or health-check poller.
If the process dies, there's simply nothing left to monitor โ€” cutting the thread off is harmless.
import threading, time

def heartbeat():
    while True:
        # send_ping()  # cheap, idempotent, safe to be cut off at any moment
        time.sleep(5)

threading.Thread(target=heartbeat, name="Heartbeat", daemon=True).start()
# main thread carries on; heartbeat dies quietly whenever the app exits
Bad daemon jobs โ€” must not be cut off mid-flight: writing a file, flushing a buffer to disk, committing a DB transaction, sending a payment, draining a queue on shutdown. A daemon can be killed between the write and the flush, leaving a half-written file with no error raised. Make these non-daemon and join() them โ€” or coordinate a graceful shutdown (Pattern 9).

Key Takeaways

ConceptTakeaway
Lifecyclenew โ†’ runnable โ†’ running โ†’ blocked/waiting โ†’ terminated
Single-useCan't restart a terminated thread โ€” build a new one
daemon=TrueProgram exits when only daemons remain; they're killed abruptly
Set daemonBefore start() โ€” never after
Never daemon forWork that must finish (writes, flushes, commits)
join()Wait for a non-daemon thread and know it's truly done
Introspectionis_alive(), name=, current_thread(), active_count(), enumerate()