-
Notifications
You must be signed in to change notification settings - Fork 239
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
I think Socket.close should be a regular method, not an async method #70
Comments
Ah. I see that you have fallen into the async close() trap. I was wondering how long it was going to take for someone to explode their head on this... ;-). Let me preface this by saying that I do NOT know the correct answer to the problem, but here are some of the reasons why I defined it as an async method.
Concerning context-managers, I didn't provide sockets with a normal context manager (enter, exit) primarily because of the same issue, but also to prevent possible mistakes. For example, using a synchronous context manager in a context where an asynchronous one should have been used. Again, I don't know the "answer" on this, but this is a weird corner of the whole API. Part of a bigger discussion where async is allowed and not allowed perhaps. |
Would add: If there is the concept of an "Asynchronous Generator", the issue of how to handle GeneratorExit on close() is going to be a bigger issue than curio. For example, are you simply not allowed to perform any async operation at all in a finally block? Yikes. I could see that being a big can of worms. |
Yeah, it's definitely a big can of worms! But it's unavoidable AFAICT. Some facts that are unlikely to change:
Conclusion: every async function MUST have the capability to synchronously unwind and clean up after itself I guess one could try to change those bullet points somehow, but it seems difficult. And AFAICT, once you have those, then the conclusion follows inevitably. When I first read through curio I saw your comment with that rationale for The broader/trickier question is indeed what to do with types like your async file with buffering, or async generators, where there are legitimate reasons why you'd like to be able to do async I/O during cleanup. In these cases you must have some strategy available for doing synchronous cleanup (as per the argument above), but it would kinda suck if we made that the only option available... yet we don't want to trick users into calling the wrong one. One idea: for these cases, create the convention that you get methods:
async def __aexit__(self, exc_type, exc, tb):
if exc_type is GeneratorExit:
# Must be handled synchronously
self.synchronous_close()
else:
await self.aclose() So the net effect of all of the above would be that user code can just do And I guess async def aclose(self):
if not self.gi_running:
# if we haven't started, nothing to do
return
try:
await self.athrow(AsyncGeneratorExit)
except AsyncGeneratorExit:
pass
except:
raise
else:
raise RuntimeError('async generator ignored AsyncGeneratorExit') It mighhht even make sense to do something like this for async functions, not just async generators... not sure. CC: @1st1 |
...I should probably forward this to async-sig or something, huh |
For the sake of argument, I'm going to take an opposing view ;-). With coroutines, they must always execute under the control of some kind of supervisor or manager. In the case of Curio, that's the kernel, but in a different environment such as asyncio, it might be an event loop. I think that one could reasonably claim that simply letting an unfinished coroutine go out of scope and garbage collect without some kind of controlled shutdown is an error. For example, uncontrolled coroutine shutdown makes it impossible to properly use any async-context manager, makes it impossible to use the await statement in finally blocks and has other potentially bad side-effects. For example, independently of Curio, if you had a coroutine like this:
It's understood that the In Curio, I think the solution to this problem is to properly cancel a task using the Task.cancel() method. That is, if a coroutine must terminate early for some reason, you should explicitly cancel it instead of relying on the whims of garbage collection. This forces the task to abandon whatever operation it's waiting for, but it also causes Curio to properly execute all of the appropriate This is the purpose of using the Curio Kernel.run(shutdown=True) method as well. If you do this, all remaining tasks are cancelled, but through an orderly shutdown. There are tricky cases that probably require some thought. For example, what happens if a coroutine raises a SystemExit? I'd claim that such an action should probably cause all coroutines to cancel and terminate in an orderly way. |
Hmm, that's an interesting and plausible argument. I'll buy that, at that least tentatively. I think there are still two questions though. First, we do still need to know what happens if someone breaks your new rule and lets an un-exhausted coroutine be gc'ed. I guess that the coroutine Second, not all references to sockets (or whatever) are coroutine locals, so even if we've made sure that coroutine locals always get cleaned up under the coroutine runner's supervision, we still have to figure out what to do in these other cases. If someone writes code like async def f():
stream = await open_async_stream()
await stream.write(...)
# stream falls out of scope without being closed then, well, they should have wrapped all that in an Similar example (using the async def data_chunks():
async with open_connection(...) as sock:
while True:
chunk = await sock.recv(...)
if not chunk:
break
async yield chunk
async def f():
async for chunk in data_chunks():
if chunk == b"xxx":
return # exit early, without exhausting the async iterator Again, obviously the right solution is to throw in an I think this still ends up leading back to my proposal above, of having a standard cleanup interface that's async (and for async generators I think that means the close method has to be called And the synchronous interface should be public, because (a) this is python, (b) it's easier to write a correct last-ditch cleanup method if you can delegate some of the work. |
On the first question, I don't think an unexpected GC would happen in Curio because the kernel maintains a task table of all actively maintained coroutines. Nothing gets removed from the task table until it has fully run to termination. So, in that context, there is always at least one reference to a coroutine sitting around until Curio thinks it's done. I'd agree that in the general case of an asynchronous generator, the possibility of GC is a real concern though. There's one other subtlety to this as well. Assuming that a coroutine was allowed to perform async operations on shutdown (i.e., GeneratorExit), how do you guarantee that it actually terminates? It's possible that any async operation could block, maybe blocking indefinitely, and the whole shutdown process would stall for some indeterminate period of time (imagine that the whole thing gets blocked up in the middle of an On the second question regarding cleanup, I originally had some code to explicitly close sockets in the I'm not sure how I feel about a special |
I'm just noting this now in relation to discussion on the async-sig. Curio uses async context managers for much more than just closing sockets. For example, they're used with synchronization primitives and I was planning to use them to do some advanced kinds of task scheduling involving those primitives. This won't be possible if Python devs decide that coroutines can't be used in the |
On its own, sync close() 'seems' harmless. I've used it in asyncio without any problems. However, going down to the road of layered generics, combined with other async ops, Given that it's a not very common operation, cutting another async call doesn't save much. |
But, there are cases where you'll want to close without any delay. Personally I don't like the idea of the two different close()'s. |
Given that 'Nice to have' s quickly turning into problematic things What are the 'Really' hard constraints for having async close()? |
I'm keeping |
So obviously, you cannot call async methods from
__del__
methods. And because of this,close
methods generally have to be non-async as well. And in particular, Python enforces that the generator protocol'sclose
method is synchronous and cannotyield
:This doesn't come up much in the context of coroutines, because coroutines are usually iterated to exhaustion. But as soon as you have asynchronous generators then it's a real problem.
For example, imagine that we have an asynchronous WSGI-like API that wants to use an
async for
loop to collect up the body of an HTTP response. And suppose that we have some code that builds that body by reading from a remote socket::And suppose that this iterator is not exhausted -- for example, because the HTTP client went away, so the WSGI-like server code stopped iterating over it. Then eventually either the server code or the Python runtime will call
.close()
on theget_html_body_from_socket
coroutine, this will inject aGeneratorExit
exception, and we are required to handle this exception without yielding. So the above code is... kinda wrong. You'll get away with it in practice because Curio'sSocket.close()
doesn't actually yield, but the easy general rule is that coroutinefinally
blocks should never containawait
, and this violates that. And this constraint means that Curio'sSocket.close()
must never change in the future to yield, so we might as well make it non-async to remind us of this and enforce it.For the same reason, I guess
Socket
should implement the synchronous__enter__
/__exit__
protocol instead of__aenter__
/__aexit__
.Possibly there are other places in curio with the same issue.
The text was updated successfully, but these errors were encountered: