Skip to content

Fix event loop memory leak from cached root task (#1203)#1229

Closed
uttam12331 wants to merge 2 commits into
agronholm:masterfrom
uttam12331:fix/run-vars-root-task-leak-1203
Closed

Fix event loop memory leak from cached root task (#1203)#1229
uttam12331 wants to merge 2 commits into
agronholm:masterfrom
uttam12331:fix/run-vars-root-task-leak-1203

Conversation

@uttam12331

Copy link
Copy Markdown

Fixes #1203

Problem

find_root_task() (asyncio backend) caches the root task via
_root_task.set(task). That RunVar lives in the per-loop dict of
_run_vars, which is a WeakKeyDictionary keyed by the event loop.

Because the cached Task holds a strong reference back to its loop, the loop
is kept alive by a value stored under its own weak key — so the weak key never
clears. Every anyio.run() that goes through find_root_task() (e.g. anything
using to_thread.run_sync()) permanently leaks its event loop, root task and
the coroutine's result. In a loop this grows memory without bound and
gc.collect() cannot reclaim it.

Reproduction:

import gc, asyncio, anyio
from anyio import to_thread

async def main():
    await to_thread.run_sync(lambda: None)

def loops():
    gc.collect()
    return sum(1 for o in gc.get_objects() if isinstance(o, asyncio.AbstractEventLoop))

before = loops()
for _ in range(5):
    anyio.run(main)
print(loops() - before)   # 5 before this change, 0 after

Fix

Cache a weakref to the root task instead of a strong reference, so the
cached value no longer pins the loop. While the loop is running the root task
is alive, so the weakref resolves; once the loop is done, the weakref clears
and everything is collected.

Verification

  • The reproduction above goes from 5 leaked loops to 0.
  • Added test_anyio_run_does_not_leak_event_loop (uses clear_cache-free gc
    check), which fails on master and passes with this change.
  • Full test suite passes (the only failures on my machine are pre-existing,
    unrelated Windows/psutil env issues that also fail on an unmodified tree).
  • ruff clean; changelog updated.

uttam12331 and others added 2 commits July 17, 2026 10:16
find_root_task() cached the root asyncio.Task with a strong reference in the
per-loop _run_vars dict. Since _run_vars is a WeakKeyDictionary keyed by the
event loop, and the cached task references that same loop, the loop was kept
alive by its own dict value and never garbage collected. Every anyio.run()
that used to_thread.run_sync() (which calls find_root_task) therefore leaked
its event loop, root task and result, growing memory without bound.

Cache a weakref to the root task instead, so it no longer pins the loop.

Fixes agronholm#1203
@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator

Looks like there is already a PR, @uttam12331 did you take a look at #1211?

@agronholm

Copy link
Copy Markdown
Owner

The bot also deleted the PR template which is grounds for dismissal anyway.

@agronholm agronholm closed this Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_run_vars memory leak

3 participants