Watch out for this (async) generator cleanup pitfall in Python
Key Takeaways
The video discusses a common pitfall in Python async generators, where the cleanup code may not run as expected if the generator is not exhausted, and provides solutions such as manual cleanup using the `close` method and `contextlib.closing`.
Full Transcript
hello and welcome back to M coding I'm James Murphy today we're talking about generators specifically generators and async generators that used the width statement or a try finally and a common Pitfall that you might fall into suppose we have a generator that needs some kind of resource it could be something like talking to a database where you need to start a transaction and then commit at the end of it or you need to grab a lock and then release the lock at the end of it or you need to open a file read some data from the file and then close the file at the end of it for rtion purposes this resource is just a dummy resource that has a name and prints out its own name on cleanup so that we can see when the cleanup happens but in real life it could be basically anything the normal way that we would use a generator like this is just to Loop over it so we would see yield zero and then got zero yield one and then got one yield two and then got two then we would exit the loop we would see the resource cleanup happen and then we would see the print statement after the loop and if we actually run it that's indeed what we see 0 1 2 clean up and then the stuff after the loop in this case the resource got cleaned up because we ran the generator past the point of when resource clean up would happen within the generator we exited the for Loop we exited the width block and we reached the end of the generator but here's a question for you does the resource still get cleaned up if we don't run the generator to completion we either stop it with a break or there's an exception that gets thrown and we never actually run the generator to the point where it would exit the WID block take a moment to think about it will this code still print the cleanup message answer coming in 3 2 1 interestingly yes the cleanup still happens we never got to iteration two in our for Loop but there's the cleanup message this behavior is possible because when a generator is garbage collected it automatically has its close method called the close method actually throws an exception inside the generator and then continues to run it until completion so instead of continuing with the for Loop it will exit the for Loop and exit the width statement which is why the cleanup happens we can actually observe this Behavior by surrounding the yield statement in a tri accept that catches a base exception and then printing it out before re raising it this allows us to kind of spy on the exception being raised but still raisee it so that it can do its job now when we run the code we see Loop zero Loop one and then indeed our Tri accept catches this generator exit exception but here's the tricky part and the source of the common Pitfall remember what I said causes this cleanup to actually happen it's the garbage collection of the generator object that triggers the cleanup in cpython if there are no more references to an object it'll get cleaned up immediately so in this case because we know garbage collection happens here we know that our resource will be cleaned up before anything that happens after the loop but what if we did some refactoring and pulled the generator out into a variable since we still maintain a reference to it even after the loop the generator won't be garbage collected here but rather at the end of the main function therefore our resource doesn't get cleaned up at the same place it gets cleaned up at the end of main this could already be a problem if the stuff after the loop expected that resource to have been cleaned up like if it was holding a lock and the stuff afterwards also needs to use the lock even if we didn't Factor things out into a separate variable like we did here other implementations of python don't guarantee the same behavior with garbage collection and they may not garbage collect something immediately even if its ref count is zero this non-determinism in generator resource cleanup is especially bad when you have nested resources just the way the code looks programmers generally tend to assume that inner resources will get cleaned up before outer resources but in this case that's not true the outer resource is cleaned up at the end of the width block but the inner resource isn't cleaned up until the generator is garbage collected which happens after the WID block at the end of main you can guarantee the cleanup happens earlier by manually triggering it by calling the generator's close function technically though this still isn't good enough because something in here could throw an exception and we'd miss that close call in order to guarantee clean up of the inner resources before the outer ones you can use something like contextlib closing which is a context manager that will just call that generator close function with this approach the place where the generator is garbage collected becomes irrelevant to the cleanup of the resources but be warned code in the wild very rarely actually does this it's super annoying to have an extra width statement around every for Loop that uses a generator so in the wild you typically just see people depending on the garbage collection behavior of cpython remember in cpython which is the python that 99% of people people are using the one that you download from python.org if you just don't keep a reference to this generator then it will be garbage collected and the cleanup of the inner thing will happen before the outer thing it's just very delicate and Flaky code where if someone comes along and does a slight refactor it might break and for normal generators that's kind of a good enough solution and the end of the story just don't hold on to a reference to a generator or if you do use context li. closing to ensure it gets cleaned up but if you happen to be using async generators then the problem gets much worse let's go back up to our generator and make this an async def function we could be using an async width block or width block or Tri finally it doesn't really matter here whether the resource is async or not what's going to make this worse is just that the generator is async and then back in main let's go ahead and make main an async depth function and use an async for Loop to Loop over the elements in the generator and we now run our main function using async i.run so this is the async equivalent of the problem we had before and the generator is garbage collected at this point but when we run the code we unfortunately see that the outer resource is cleaned up before the inner one the inner one does still get cleaned up in a similar way as in the synchronous case a special exception gets thrown inside the generator and then it's run until completion which causes the WID block to eventually exit but for some reason in this async case the garbage collection isn't triggering the resource to be cleaned up just after the for Loop like it was in the synchronous case and as you could probably guess the delaying has something to do with it being async we can get a little hint about what's really going on here by reaching into the internals of the event Loop that's running and looking at what the ready tasks are this print statement just before the break is sort of our Baseline then right after the break it will go to this print statement which is outside the for Loop the first one is just before the generator is garbage collected and the second one is just after the generator is garbage collected when we run the code and see the before and after we see that there's this extra thing in the after and it doesn't take a huge leap to figure out that yes this is the task that closes the generator and there in lies the Crux of the problem an async generator is async but the garbage collector is not async so even though the generator is garbage collected here the best the garbage collector could do is schedule the task that causes the generator to be closed it can't actually await that task though if we manually sleep a few times we give the event Loop enough time to run any pending tasks now when we run the code we see that indeed the inner one gets cleaned up before the outer one which is what we want so how do we get around this obviously I'm not suggesting that you add sleep statements after every for Loop similar to the synchronous case the most correct thing to do that almost nobody actually does is to surround any async for Loop using a generator in an async with block where you use context Libs a closing all this does is await the generator's a close method on the way out just like with the synchronous case this makes the point where the generator is garbage collected irrelevant to when the cleanup happens but this is kind of unsatisfying for a number of reasons first off I mean good luck convinc everyone to do this in the first place but it's also unsatisfying because it breaks a level of abstraction a closing only works for async generators not arbitrary async iterables so immediately I now have to keep track of whether this is an async generator or just an async iterable that's something else and second since the pragmatic thing to do is not to put an async width around every async for Loop but only to put it around the ones that need it then in addition to keeping track of whether or not this is an async generator you also need to know about the implementation of the generator and whether or not that implementation uses any Tri finales width statements or async width statements not to mention the additional refactoring cost because remember whenever a generator uses a tri finally a width statement or an asyn width statement then you need to wrap all of its usages in an async WID block but now all those callers have an async WID block so if any of them are generators you now have to wrap all their colors in asyn blocks and this process can get out of hand very quickly the real solution is of course to completely ban asnc for just kidding but yeah unfortunately this is sort of just the State of Affairs right now if you care about asnc generator clean up you just have to use a closing there is a pep Number 533 that proposes a solution to this though the idea is pretty simple basically just add two new dunders a iter close and iter close and change the meaning of for Loops have an async for Loop await a it close at the end of the for loop after a regular exit breaking or an exception and have an normal for Loop call inter close again when you either Exit normally exit with a break or exit with an exception this is a pretty good idea and if python was being written for the very first time then this might have made it in but as you can imagine changing the semantics of iteration is probably a no-go at this point not to mention how hard it would be to implement and it would be a breaking change to any code that intends to use the current semantics where it has a break statement but then continues to iterate over the same generator or iterable afterwards in this case we open a file process some header lines in the file and then process the rest of the lines in a different way so we would break and then continue processing the rest of the lines technically open doesn't return a generator so lines in this case is not a generator but just imagine you've written your own kind of open wrapper that uses a generator in that case it's perfectly reasonable that the developer intended it to be this way that they didn't want lines to be closed after the first for Loop so anyway I don't think this a itose and itose pep will ever be added into python aside from outright Banning the use of async generators in your code which I guess technically is a fine solution the best thing to do is to just be aware of it and add this to your list of quirks of python that you need to watch out for once again I'm James Murphy and this is M coding I'm available for code reviews Contracting and Consulting check out my company at M coding. and thank you to my patrons and donors for helping to support the channel don't forget to leave a comment subscribe and slap that like button in odd number times see you in the next one
Original Description
Watch out for this common generator pitfall.
What happens to a try/finally, with block, or async with block inside a generator if the generator isn't exhausted? Does the cleanup code still run? When and how does it run? In this video we take a look at the answers to these questions and learn how to avoid a common situation where cleanup code doesn't run when you want it to.
― mCoding with James Murphy (https://mcoding.io)
PEP 533: https://peps.python.org/pep-0533/
Discussion on GH issue: https://github.com/python/cpython/issues/88684
Source code: https://github.com/mCodingLLC/VideosSampleCode
SUPPORT ME ⭐
---------------------------------------------------
Sign up on Patreon to get your donor role and early access to videos!
https://patreon.com/mCoding
Feeling generous but don't have a Patreon? Donate via PayPal! (No sign up needed.)
https://www.paypal.com/donate/?hosted_button_id=VJY5SLZ8BJHEE
Want to donate crypto? Check out the rest of my supported donations on my website!
https://mcoding.io/donate
Top patrons and donors: Laura M, Neel R, Dragos C, Jameson, Matt R, Pi, Vahnekie, Johan A, Mark M, Mutual Information
BE ACTIVE IN MY COMMUNITY 😄
---------------------------------------------------
Discord: https://discord.gg/Ye9yJtZQuN
Github: https://github.com/mCodingLLC/
Reddit: https://www.reddit.com/r/mCoding/
Facebook: https://www.facebook.com/james.mcoding
CHAPTERS
---------------------------------------------------
0:00 Intro
1:21 The cleanup question
5:30 The async case
Watch on YouTube ↗
(saves to browser)
Sign in to unlock AI tutor explanation · ⚡30
Playlist
Uploads from mCoding · mCoding · 0 of 60
← Previous
Next →
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
Goodbye, List! Type hinting standard collections - New in Python 3.9
mCoding
Python's comma equals ,= operator?
mCoding
Finding Primes in Python with the Sieve of Eratosthenes
mCoding
Find the First Missing Positive Int | Hard Interview Question on LeetCode
mCoding
JSON Tutorial Python | Basic Python Recipes
mCoding
Simulating Brownian Motion in Python
mCoding
The Single Most Useful Decorator in Python
mCoding
The Fastest Way to Loop in Python - An Unfortunate Truth
mCoding
Numpy Array Broadcasting In Python Explained
mCoding
Brownian Motion Single Path Zoom
mCoding
Brownian Motion Fractal Zoom
mCoding
Magic Methods - Making Python builtins work with your classes
mCoding
50 Million Primes In 5 Seconds - Segmented Sieve of Eratosthenes
mCoding
The Hottest New Feature Coming In Python 3.10 - Structural Pattern Matching / Match Statement
mCoding
How Fast is Python's Sort? Performance Testing
mCoding
C++ First Missing Int, faster than 100%!
mCoding
[April Fools 2021] Python 4.0! New old print, mandatory static typing, StackOverflow integration
mCoding
Python dataclasses will save you HOURS, also featuring attrs
mCoding
C++ Sudoku Solver in 7 minutes using Recursive Backtracking
mCoding
Every PROOF you've seen that .999... = 1 is WRONG
mCoding
Python's sharpest corner is ... plus equals? (+=)
mCoding
Binary Search - A Different Perspective | Python Algorithms
mCoding
The Best Way to Check for Optional Arguments in Python
mCoding
Local and Global Variable Lookup Weirdness in Python
mCoding
Efficient Exponentiation
mCoding
How To Install Python for Data Science
mCoding
0.1 + 0.2 is NOT 0.3 in Most Programming Languages
mCoding
Python 3.10's new type hinting features
mCoding
Python 3.10's Quality of Life improvements
mCoding
Introducing mZips! Python Zip and Zip Longest
mCoding
Match statement tips
mCoding
Using except: is a HUGE mistake
mCoding
Python + YouTube API | Automating descriptions
mCoding
Anaphones, phonetic anagrams
mCoding
Cracking passwords using ONLY response times | Secure Python
mCoding
Python f-strings can do more than you thought. f'{val=}', f'{val!r}', f'{dt:%Y-%m-%d}'
mCoding
Diagnose slow Python code. (Feat. async/await)
mCoding
Python MD5 implementation
mCoding
Salting, peppering, and hashing passwords
mCoding
x to bool conversion in Python, C++, and C
mCoding
You should put this in all your Python scripts | if __name__ == '__main__': ...
mCoding
Find the Skyline Problem with C++ Solution Explained
mCoding
The ONLY C keyword with no C++ equivalent
mCoding
Should you use "not not x" instead of "bool(x)" in Python? (NO!)
mCoding
Multiple Assignments in Python
mCoding
Why I don't like Python's chained comparisons
mCoding
Automated Testing in Python with pytest, tox, and GitHub Actions
mCoding
You can pip install directly from GitHub
mCoding
__new__ vs __init__ in Python
mCoding
Metaclasses in Python
mCoding
The easy way to keep your repos tidy.
mCoding
Which Python @dataclass is best? Feat. Pydantic, NamedTuple, attrs...
mCoding
Python __slots__ and object layout explained
mCoding
C++ cache locality and branch predictability
mCoding
Avoiding import loops in Python
mCoding
25 nooby Python habits you need to ditch
mCoding
Python staticmethod and classmethod
mCoding
Building a Python app with Anvil to email me if my website goes down (includes paid features)
mCoding
31 nooby C++ habits you need to ditch
mCoding
Interviewing the creator of C++, Bjarne Stroustrup
mCoding
More on: RAG Basics
View skill →Related Reads
📰
📰
📰
📰
A Production RAG Pipeline for PDFs: Relational Parsing, TOC Retrieval, Typed Answers
Towards Data Science
How to Cut RAG Token Costs 90% by Caching the Prefix
Medium · AI
How to Cut RAG Token Costs 90% by Caching the Prefix
Medium · Programming
Why Your Chunking Strategy Matters More Than Your Model
Medium · RAG
Chapters (3)
Intro
1:21
The cleanup question
5:30
The async case
🎓
Tutor Explanation
DeepCamp AI