Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bpo-41229: Update docs for explicit aclose()-required cases and add contextlib.aclosing() method #21545

Merged
merged 11 commits into from Nov 2, 2020

Conversation

achimnol
Copy link
Contributor

@achimnol achimnol commented Jul 19, 2020

This is a PR to:

  • Add contextlib.aclosing which ia analogous to contextlib.closing but for async-generators with an explicit test case for bpo-41229
  • Update the docs to describe when we need explicit aclose() invocation.

which are motivated by the following issues, articles, and examples:

Particuarly regarding PEP-533, its acceptance (__aiterclose__()) would make this little addition of contextlib.aclosing() unnecessary for most use cases, but until then this could serve as a good counterpart and analogy to contextlib.closing(). The same applies for contextlib.closing with __iterclose__().
Also, still there are other use cases, e.g., when working with non-generator objects with aclose() methods.

https://bugs.python.org/issue41229

Automerge-Triggered-By: GH:aeros

@achimnol
Copy link
Contributor Author

achimnol commented Jul 19, 2020

@njsmith I just read your comments and resolution of bpo-41229, and I also see that async-gens in the original leak.py were not being gc'ed because the event loop is indefinitely blocked by input().
Still, I think it's worth to add this PR, so please let me know what you think about this. :)

@corona10 corona10 requested a review from njsmith July 19, 2020 16:48
@aeros aeros added DO-NOT-MERGE topic-asyncio type-feature A feature request or enhancement labels Jul 19, 2020
@aeros
Copy link
Contributor

aeros commented Jul 19, 2020

Adding DO-NOT-MERGE for now, so it's not merged prior to approval of the contextlib.aclosing() feature. Without an actual memory leak occurring in async generators, this is now a more subjective enhancement to be argued for, rather than a bugfix.

(I'm not currently +1 or -1 for the feature, I have to spend some more time considering the use cases.)

@achimnol
Copy link
Contributor Author

achimnol commented Jul 20, 2020

I think the major issue for contextlib.aclosing would be whether people will use the name "aclose" in their objects to async-cleanup resources.

There are currently the following approaches I see:

  1. obj.close() – e.g., zmq.asyncio.Socket
  2. await obj.close() – e.g., aiohttp.WebSocketResponse, aiofiles.AIOFile
  3. obj.close() followed by await obj.wait_closed() – e.g., asyncio.StreamWriter, aioredis.RedisConnection
  4. await obj.aclose() – e.g., async generator, httpx.AsyncClient, trio.SocketStream

So, from the perspective of naming, adding contextlib.aclosing to the stdlib might be promoting the last case for non-generator use cases as well. Supporting all 2nd, 3rd, 4th cases above in contextlib.aclosing would make things complicated.

Still, I believe that we need a better approach so that people don't be confused about resource cleanup upon generator termination/interruption, and currently the only reliable and predictable way is to explicitly close the generator (for both sync/async generators). Having both contextlib.closing and contextlib.aclosing provides a consistent, idiomatic way to achieve explicit cleanup of both sync/async generators, and that's the main reason why I made this PR.

For the @njsmith's response to the original issue, I'd like to say relying on the event loop's behavior or GC is counter-intuitive. For instance, even an experienced developer like him was surprised at his first glance on this issue and after some examination he concluded that this was "actually" an expected behavior.

I don't know how things are going on with PEP-533, but if it's going to be accepted in the future, I can agree with skipping contextlib.aclosing because it defines the resource cleanup behavior clearly for sync/async generators (the most motivating issue here) and I have no strong opinion to insist that everybody should use "aclose" instead of "close".

@1st1
Copy link
Member

1st1 commented Jul 23, 2020

I think the major issue for contextlib.aclosing would be whether people will use the name "aclose" in their objects to async-cleanup resources.

We're going to start prompting aclose() in asyncio APIs soon.

Copy link
Member

@1st1 1st1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 from me to add this.

Copy link
Member

@corona10 corona10 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aeros
Anything more stuff is needed before merging this PR?

Copy link
Contributor

@aeros aeros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly LGTM. A few nits though:

  1. The docs mention aclosing as a function, when in Lib/contextlib.py it's implemented as a class. As a result, it should use the .. class:: reST role instead of .. function::. Also, instead of "return an async context manager", it would be more correct to directly call it an async context manager [1].

  2. In the docstring for aclosing(), I would suggest for it to be changed to something like: "Async context manager for safely finalizing an async generator using its aclose() method". I think the current version of the docstring may cause readers to miss the main point of it, and potentially make incorrect assumptions about its usage. Also, the "end of a block" part is a bit vague [2]. Instead, it makes more sense to me to just refer to it as a context manager.

  3. I think this would also benefit from a "What's New" entry in 3.10 for contextlib to briefly mention the addition of contextlib.aclosing(), as it is a new public member.

Other than the above points, I think it's good to go. Feel free to @ mention me once the changes are made, and I can proceed with merging it (unless @1st1 is 100% content with the current state, and decides to merge it as is).


[1] - Any class that implements __aenter__ and __aexit__ is considered an async context manager, or at the very least is usable as one.

[2] - I get that you're referring to the block/scope within the with statement, but I don't think that will be immediately clear to all readers; especially if they're not intimately familiar with context managers in general. By simply referring to it as an "async context manager", readers can much more easily look up how context managers work rather than trying to guess what "context" and "block" are referring to within the docstring.

@bedevere-bot
Copy link

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

@achimnol
Copy link
Contributor Author

achimnol commented Oct 1, 2020

@aeros Thanks for your feedback. Updated the PR, but I have some questions regarding existing docs/docstrings for closing, to make it consistent or not.

Mostly LGTM. A few nits though:

  1. The docs mention aclosing as a function, when in Lib/contextlib.py it's implemented as a class. As a result, it should use the .. class:: reST role instead of .. function::. Also, instead of "return an async context manager", it would be more correct to directly call it an async context manager [1].

I updated the docs to use .. class::. However, I see the same issue on the existing docs for closing, because I copy-and-pasted it to start with. Shall I update it as well?

  1. In the docstring for aclosing(), I would suggest for it to be changed to something like: "Async context manager for safely finalizing an async generator using its aclose() method". I think the current version of the docstring may cause readers to miss the main point of it, and potentially make incorrect assumptions about its usage. Also, the "end of a block" part is a bit vague [2]. Instead, it makes more sense to me to just refer to it as a context manager.

Agreed and applied your suggestion, but the same question: shall I update the docstring for existing closing() to be consistent?

  1. I think this would also benefit from a "What's New" entry in 3.10 for contextlib to briefly mention the addition of contextlib.aclosing(), as it is a new public member.

I have already added the news fragment using blurb.
Please let me know if there are something I've missed.

Other than the above points, I think it's good to go. Feel free to @ mention me once the changes are made, and I can proceed with merging it (unless @1st1 is 100% content with the current state, and decides to merge it as is).

[1] - Any class that implements __aenter__ and __aexit__ is considered an async context manager, or at the very least is usable as one.

[2] - I get that you're referring to the block/scope within the with statement, but I don't think that will be immediately clear to all readers; especially if they're not intimately familiar with context managers in general. By simply referring to it as an "async context manager", readers can much more easily look up how context managers work rather than trying to guess what "context" and "block" are referring to within the docstring.

@belm0
Copy link
Contributor

belm0 commented Oct 10, 2020

Neglecting to mention https://bugs.python.org/issue40213 in the PR description has cost me several hours of duplicated effort in #22640 😦

Anyway I'll add comments here based on thought I've put into it.

from contextlib import asynccontextmanager

@asynccontextmanager
def aclosing(thing):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be async def

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

Comment on lines 171 to 184
And lets you write code like this::

async def ticker(delay, to):
for i in range(to):
yield i
await asyncio.sleep(delay)

with aclosing(ticker(10)) as ticks:
async for tick in ticks:
print(tick)

without needing to explicitly call the ``aclose()`` method of ``ticks``.
Even if an error occurs, ``ticks.aclose()`` will be called when
the :keyword:`async with` block is exited.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think rather than some vague example, this is the right place to mention how it's useful for deterministic async generator cleanup, and use that as an example.

See https://github.com/python/cpython/pull/22640/files#diff-aa53aa6efed705fd0589ce0fdac405d7R171-R185

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the example with #22640!

Comment on lines 642 to 648
If an asynchronous generator is not resumed before it reaches the end of function
but the event loop where the generator is bound to continues to run (e.g., when
the caller task is cancelled), the caller must explicitly close the async
generator by calling :meth:`~agen.aclose` method to free the resources allocated
in the async generator function's stack and detach the generator from the event
loop.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be explained more clearly-- most readers won't have in mind how this situation comes up. And the rationale could be more detailed.

If an asynchronous generator happens to exit early by :keyword:break, the caller task being cancelled, or other exception, the generator's async cleanup code will run and possibly raise exceptions or access context variables in an unexpected context-- perhaps after the lifetime of tasks it depends on or the event loop itself. To prevent this, the caller must explicitly close the async generator by calling :meth:~agen.aclose method to finalize the generator and ultimately detach it from the event loop.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtlety: It can't happen after the event loop exits, thanks to the async gen gc hooks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated!


is equivalent to this:

agen = <module>.fetch(<arguments>)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await fetch()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

Comment on lines 1 to 2
Add ``contextlib.aclosing`` analogous to ``contextlib.closing`` but for
async generators and arbitrary objects with an ``aclose`` coroutine method.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be useful to mention the primary motivation (deterministic cleanup of async generators)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated!

@1st1
Copy link
Member

1st1 commented Oct 12, 2020

There's a bunch of good feedback from @belm0 -- please address and I think the PR is OK to be merged now. We can polish it in follow up PRs. @aeros please re-review.

@aeros
Copy link
Contributor

aeros commented Oct 17, 2020

please address and I think the PR is OK to be merged now. We can polish it in follow up PRs. @aeros please re-review. @aeros please re-review.

Sounds good, I'll look through it as soon as I get the chance. If I'm not able to by this weekend, I'll definitely be able to during the core sprint next week.

@aeros aeros self-requested a review October 17, 2020 08:25
@@ -303,6 +303,31 @@ def __exit__(self, *exc_info):
self.thing.close()


class aclosing(AbstractAsyncContextManager):
"""Async context manager for safely finalizing an async generator using its ``aclose()`` method.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this description too specific? aclosing can be used with any object having aclose(), not necessarily a generator.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated a little to embrace other cases.


is equivalent to this:

agen = await <module>.fetch(<arguments>)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I said the await should be here, but that was incorrect.

The object passed to aclosing() is just taken as is, returned by __aenter__, and has aclose() called in __aexit__.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I also missed to check it. Reverted here.

Copy link
Contributor

@belm0 belm0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Copy link
Member

@corona10 corona10 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aeros Gentle ping~

Copy link
Contributor

@aeros aeros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the reminder @corona10, for the suggestions @belm0, and for the changes made @achimnol! LGTM, I'll proceed with merging.

(The only remaining component that would also be nice is a mention of the new contextlib.aclosing() in the 3.10 whatsnew document. But that can be done in a separate PR -- if you open one, I'd suggest linking to it here and requesting a review from me)

@miss-islington miss-islington merged commit 6e8dcda into python:master Nov 2, 2020
@achimnol achimnol deleted the bpo-41229 branch November 10, 2020 07:41
adorilson pushed a commit to adorilson/cpython that referenced this pull request Mar 13, 2021
…ontextlib.aclosing() method (pythonGH-21545)

This is a PR to:

 * Add `contextlib.aclosing` which ia analogous to `contextlib.closing` but for async-generators with an explicit test case for [bpo-41229]()
 * Update the docs to describe when we need explicit `aclose()` invocation.

which are motivated by the following issues, articles, and examples:

 * [bpo-41229]()
 * https://github.com/njsmith/async_generator
 * https://vorpus.org/blog/some-thoughts-on-asynchronous-api-design-in-a-post-asyncawait-world/#cleanup-in-generators-and-async-generators
 * https://www.python.org/dev/peps/pep-0533/
 * https://github.com/achimnol/aiotools/blob/ef7bf0cea7af/src/aiotools/context.py#L152

Particuarly regarding [PEP-533](https://www.python.org/dev/peps/pep-0533/), its acceptance (`__aiterclose__()`) would make this little addition of `contextlib.aclosing()` unnecessary for most use cases, but until then this could serve as a good counterpart and analogy to `contextlib.closing()`. The same applies for `contextlib.closing` with `__iterclose__()`.
Also, still there are other use cases, e.g., when working with non-generator objects with `aclose()` methods.
@uburuntu
Copy link

😒
#21285
https://bugs.python.org/issue41197

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic-asyncio type-feature A feature request or enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

10 participants