โฐ Pattern 10: Timers & Periodic Tasks

Run something once after a delay, or over and over on a schedule โ€” background work that fires on the clock, not on demand.

The Problem

Scenario: you need work to happen later, not now. "Send a reminder in 30 seconds." "Refresh this cache every 5 minutes." "Flush the metrics buffer every second." The main thread must keep doing its job, so the delayed/repeating work has to run in the background.

Two flavors: a one-shot delayed call, and a repeating periodic task. The stdlib gives you threading.Timer for the first; the second you build yourself (and the way you build it matters a lot for clean shutdown).

One-Shot: threading.Timer

threading.Timer(interval, function) is a Thread subclass that sleeps for interval seconds and then calls function โ€” exactly once. You .start() it like any thread, and you can .cancel() it before it fires.

import threading

def remind():
    print("โฐ time's up!")

t = threading.Timer(5.0, remind)   # fire once, 5 seconds from now
t.start()                          # returns immediately; countdown runs in a thread

# Change your mind before it fires:
t.cancel()                         # no-op if it has already run
Each Timer is a whole OS thread that spends its life asleep. Fine for a handful of delayed callbacks; wasteful if you're spawning thousands. .cancel() only works if the timer hasn't fired yet โ€” it interrupts the internal wait. Passing args? Timer(5.0, fn, args=[x], kwargs={...}).

Repeating, Attempt #1: Re-arm Inside the Callback

A Timer fires once. The obvious way to make it repeat is to schedule a fresh Timer at the end of each callback.

import threading

def tick():
    print("tick")
    global timer
    timer = threading.Timer(1.0, tick)   # re-arm for the next second
    timer.start()

timer = threading.Timer(1.0, tick)
timer.start()

# To stop: timer.cancel()  โ€” but you can only cancel the CURRENTLY pending one.
Why this is fragile: every tick spawns a brand-new thread, so cancellation is racy โ€” you have to hold a reference to the latest timer, and if you cancel between one firing and the next being armed, you can miss it. It works, but the loop-thread approach below is cleaner, cheaper, and trivially cancellable.

Repeating, The Preferred Way: One Loop Thread + Event.wait()

Run a single thread that loops: do the work, then stop_event.wait(interval). That wait is the delay and the cancel โ€” it sleeps for the interval but returns instantly if someone sets the event. One thread, one clean stop signal. (This is the same responsive shutdown trick from Pattern 9.)

Pattern: while not stop_event.wait(interval): ... The loop runs the body every interval seconds, and calling stop_event.set() ends it immediately โ€” no waiting out the current interval, no leftover armed timers.
import threading
import time

class RepeatingTimer:
    """Call `function` every `interval` seconds until stopped. Cancellable
    instantly via a threading.Event."""

    def __init__(self, interval: float, function):
        self.interval = interval
        self.function = function
        self._stop = threading.Event()
        self._thread = threading.Thread(target=self._run, daemon=True)

    def _run(self):
        # wait() returns True the moment the event is set, else False on timeout.
        # So the loop ends promptly on stop() instead of after a full interval.
        while not self._stop.wait(self.interval):
            self.function()

    def start(self):
        self._thread.start()

    def stop(self):
        self._stop.set()          # wake the wait() immediately
        self._thread.join()       # and wait for the loop to actually exit


def flush_metrics():
    print(f"flushing metrics at {time.strftime('%X')}")

timer = RepeatingTimer(1.0, flush_metrics)
timer.start()

time.sleep(3.5)                   # let it fire ~3 times
timer.stop()                      # stops within milliseconds, cleanly
print("stopped")
Why wait() beats time.sleep() here: a thread parked in time.sleep(60) can't be woken โ€” stop() would block up to a full minute. Event.wait(60) returns the instant you call set(). Same reason, same fix as the shutdown pattern.

The Periodic Loop, Visualized

flowchart TD S["start()"] --> W["stop_event.wait(interval)"] W -->|"timed out
(False)"| F["run function()"] F --> W W -->|"event set
(True)"| E["exit loop
thread ends"]

The loop lives entirely in wait() between runs. Every iteration either times out (do the work, loop again) or sees the stop event (fall out, thread ends). There's exactly one thread and one decision point.

Drift: Fixed-Delay vs Fixed-Rate

The loop above waits interval seconds after each run finishes. If the work takes 200ms, the real period is interval + 0.2s. Over many iterations this drifts. That's fixed-delay scheduling.

Fixed-delayFixed-rate
Waitsinterval after work endsuntil the next interval boundary
Drifts?Yes โ€” accumulates work durationNo โ€” anchored to a schedule
Good for"at least N seconds between runs""fire ON the clock, e.g. every :00"

For fixed-rate, compute the next deadline and wait until it โ€” subtracting the time already spent:

import threading
import time

def run_fixed_rate(interval, function, stop_event):
    next_run = time.monotonic()
    while not stop_event.is_set():
        function()
        next_run += interval                       # anchor to the schedule, not to "now"
        delay = next_run - time.monotonic()        # time left until the next tick
        if delay > 0:
            stop_event.wait(delay)                  # cancellable sleep
        # if delay <= 0 we're behind schedule โ†’ run again immediately (no sleep)
Use time.monotonic(), not time.time(), for scheduling. monotonic() never jumps backwards; wall-clock time can (NTP corrections, DST) and would wreck your interval math.

The Stdlib Alternative: sched

For running many callbacks at different future times from a single thread, the stdlib sched module is a small event scheduler โ€” no thread-per-timer.

import sched
import time

scheduler = sched.scheduler(time.monotonic, time.sleep)
scheduler.enter(2.0, priority=1, action=print, argument=("2s later",))
scheduler.enter(1.0, priority=1, action=print, argument=("1s later",))
scheduler.run()   # blocks, firing each event at its time, in order
When to reach for it: sched is handy for many one-shot events on one thread. But scheduler.run() blocks and its default time.sleep delay isn't cancellable, so it's awkward for clean shutdown. For repeating background work with responsive stop, the Event.wait() loop above is usually the better fit. For anything cron-like across a real app, use a library such as APScheduler.

Cleaning Up Timers on Shutdown

A pending Timer or a running loop thread is a live thread โ€” a non-daemon one will keep the process from exiting. Always tear timers down.

# One-shot Timer: cancel it if it hasn't fired.
t = threading.Timer(30.0, cleanup)
t.start()
# ... on shutdown:
t.cancel()          # cancels the pending wait; harmless if already fired

# Loop-thread timer: set the event and join.
timer = RepeatingTimer(5.0, poll)
timer.start()
# ... on shutdown:
timer.stop()        # set() + join() โ†’ exits within milliseconds
Two clean strategies:
  • Explicit: keep references to your timers and cancel() / stop() them in your shutdown path (ideally a try/finally or signal handler). Best โ€” everything gets to clean up.
  • Daemon: mark the loop thread daemon=True (as in RepeatingTimer above) so a forgotten timer never hangs exit. Convenience only โ€” it dies abruptly with no cleanup, so don't rely on it to flush state.

Key Takeaways

ConceptTakeaway
threading.TimerOne-shot delayed call; start() then optionally cancel()
RepeatingPrefer one loop thread over re-arming a Timer each fire
Event.wait(interval)The delay and the cancel โ€” the heart of a clean periodic loop
Fixed-delaySimple, but drifts by the work's duration
Fixed-rateAnchor to time.monotonic() deadlines to avoid drift
schedMany one-shot events, one thread; blocks, not easily cancellable
ShutdownAlways cancel() / stop() timers, or make them daemon