Repository navigation
Avoid reference cycles when a sync() call fails - #2295
spicysharkco wants to merge 1 commit into
Conversation
4fb7112 to
bff21e6
Compare
martindurant
left a comment
There was a problem hiding this comment.
Interesting - and thanks for looking into this.
I wonder, is this pattern known/documented elsewhere:
try:
raise ex
finally:
del ex, ...This allows calling code to still catch the exception (and thereby keep the object alive for debugging). However, descending to the frame with pdb no longer allows inspecting the variable of interest - is there any solution to this?
It is a shame that we need to do this in multiple places, though.
General questions:
is the main problem with allowing cleanup via garbage collection the possibility of that cleanup happening on another thread? Or is it only about the retained data buffers you mention.
| async def _info(self, path, **kwargs): | ||
| raise self._fail(path) | ||
|
|
There was a problem hiding this comment.
Let's just include one method - they all operate the same way.
| finally: | ||
| # the traceback includes this frame, so it must not refer back | ||
| # to the exception: directly or through the finished task | ||
| del first_exc, task |
There was a problem hiding this comment.
Check: exc (maybe cleaned by ending the except-block).
An exception stored in a local variable of a frame that is also in its own traceback is a reference cycle: exception -> __traceback__ -> frame -> local -> exception. sync() held the exception both in ``return_result`` and, through ``result[0]``, in the frame of _runner(), which is part of the exception's traceback. The same happened in _run_coros_in_chunks (``first_exc`` and the finished ``task``), _copy, _cat and _cat_ranges (``ex`` and the list of results). Such a cycle is only collected by the cyclic garbage collector, so every failed call kept its frames, and everything they refer to, alive until the next collection. That includes the data of a failed pipe_file: bytes objects are not tracked by the collector, so 20 failed calls of 8 MiB each kept about 160 MiB allocated, and none after this change. Clear the locals before the exception propagates, in the same way as concurrent.futures.Future.__get_result does. Add a regression test that checks that the exception is freed by reference counting alone.
bff21e6 to
20fe6e9
Compare
|
Thanks for the review. Rebased on master (changelog conflict only). test_async.py:777: now one method, asyn.py:444: unchanged. The It is documented: the language reference says pdb still shows the exception, its traceback and every frame. Only the cleared names are missing ( A helper can't clear the caller's locals, so cleanup must run in the raising frame. GC: mostly memory. Until a full collection, frames and buffers stay alive (20 failed 8 MiB calls held 160 MiB). Finalizers also run then, on whichever thread triggers it; in my test that was the IO thread. |
When a coroutine run by
sync()fails, the exception refers back to itself: its traceback holds the frame of_runner(), which holdsresult, which holds the exception, and the frame ofsync()also keeps it inreturn_result._run_coros_in_chunks,_copy,_catand_cat_rangesdo the same withfirst_exc/exand their result lists.So each failed call keeps its frames, and everything they refer to, until the cyclic garbage collector runs. For
pipe_filethat includes the data, and bytes don't count towards triggering a collection: 20 failed calls of 8 MiB each kept about 160 MiB with the collector disabled, and none with this change.The fix clears those locals before the exception leaves the frame, as
concurrent.futures.Futuredoes. The new test checks that the exception of each failing call is freed by reference counting alone; it fails on master.The same pattern is in
generic.copy_file_op,SimpleCacheFileSystem.ls,WholeFileCacheFileSystem._cat_rangesandReferenceFileSystem.cat. I can send those in a follow-up.