Threads, asyncio and generators¶
executiontimer is safe to use from any number of threads and asyncio tasks, including on
free-threaded Python builds. This page explains how sections from concurrent code nest and
add up.
How it works¶
Two pieces of state are involved:
The registry of recorded timings is shared by the whole process and guarded by a lock. Every thread and task records into it, so everything lands in one report.
The active stack, the list of currently open sections that decides where a new section nests, lives in a
ContextVar. Each thread and each asyncio task has its own, so concurrent code doesn’t nest into each other’s sections.
asyncio tasks¶
Each task nests its sections independently. A task inherits the stack of the code that created it, so its sections appear under the section that was open at that point:
import asyncio
from execution_timer import TimerContext, get_execution_timings
@TimerContext("fetch", category="io")
async def fetch(delay: float) -> None:
await asyncio.sleep(delay)
async def worker(n: int) -> None:
with TimerContext(f"worker{n}"):
await fetch(0.01)
async def main() -> None:
with TimerContext("main"):
await asyncio.gather(worker(0), worker(1))
asyncio.run(main())
assert set(get_execution_timings()) == {
("main",),
("main", "worker0"),
("main", "worker0", "fetch"),
("main", "worker1"),
("main", "worker1", "fetch"),
}
Await child tasks inside the parent section if you want the parent’s time to include them. A task that is still running when its parent section exits is recorded under it all the same.
Threads¶
Whether a new thread starts inside the section that started it depends on the Python build:
Regular builds, including Python 3.14, start each new thread with an empty stack. Sections recorded in the thread appear at the top level of the report, and their time is added to the total alongside the section that started the thread.
Free-threaded builds of Python 3.14 start each new thread with a copy of the context of the code that called
start(). Sections recorded in the thread nest under the section that was open at that moment.
Python’s -X thread_inherit_context option switches between the two on any 3.14 build,
and sys.flags.thread_inherit_context reports which one is in effect.
Thread pools¶
The difference matters most for thread pools, because a pool reuses its threads.
ThreadPoolExecutor starts a worker thread when work is
submitted and keeps it for later work. On a free-threaded build, the worker keeps the
stack it started with for as long as it lives, so work submitted later, from a different
section or from none, still nests under the section that was open when the worker started.
On a regular build, the same work is recorded at the top level.
To get the same report on every build, pick the context for each call yourself. To nest
thread work under the current section, run each call in a fresh copy of the current
context from contextvars.copy_context():
import contextvars
from concurrent.futures import ThreadPoolExecutor
from execution_timer import clear_execution_timings
clear_execution_timings()
@TimerContext("download")
def download(n: int) -> int:
return n
with ThreadPoolExecutor(max_workers=2) as pool:
with TimerContext("batch"):
futures = [pool.submit(contextvars.copy_context().run, download, n) for n in range(4)]
results = [future.result() for future in futures]
with TimerContext("retry"):
pool.submit(contextvars.copy_context().run, download, 0).result()
timings = get_execution_timings()
assert ("batch", "download") in timings
assert ("retry", "download") in timings
assert ("download",) not in timings
Copy the context for every call rather than once per section: a context can’t be entered
by two threads at the same time. asyncio.to_thread() copies the context for you.
To keep thread work at the top level on every build, run each call in a new, empty
Context instead:
clear_execution_timings()
with ThreadPoolExecutor(max_workers=2) as pool, TimerContext("batch"):
pool.submit(contextvars.Context().run, download, 1).result()
timings = get_execution_timings()
assert ("download",) in timings
assert ("batch", "download") not in timings
The same works for a plain thread: pass target=contextvars.copy_context().run or
target=contextvars.Context().run, followed by the function and its arguments in args.
Overlapping calls add up¶
When calls to the same section overlap, from several threads or tasks, each call keeps its
own start time and their durations are added together. Such a total measures accumulated
time and can exceed the wall-clock time. In the asyncio example above, two overlapping
10 ms fetch calls would add up to about 20 ms if they shared a path. Give concurrent work
distinct names, or use counter=, to report each call separately.
Generators¶
A generator runs in the context of whoever is iterating it. A section left open across a
yield therefore also contains whatever the caller times while the generator is paused,
and its time includes the pause. Close sections before yielding:
def rows():
for i in range(3):
with TimerContext("parse_row"):
row = i * 2 # timed
yield row # not timed: the section is closed
with TimerContext("import"):
for row in rows():
pass
If a section is still open in a paused generator when the caller’s section exits, the timer
emits a RuntimeWarning, records the caller’s section, and discards the generator’s
unfinished section so later sections still nest correctly. Closing the generator afterwards
does nothing. The timer never raises from exiting a section, so it can’t replace an
exception that is already propagating. Under warnings-as-errors, the warning is raised only
after this cleanup.