-
-
Notifications
You must be signed in to change notification settings - Fork 379
Description
It's tempting to think that you can fake a "check once, but don't block" semantics by setting up a 0-timeout cancel scope:
async def fake_accept_nowait(self):
with fail_after(0):
await self.accept()This is not how Trio's cancellation semantics are supposed to work – there's no guarantee that accept won't raise Cancelled before it even attempts the operation.
@sorcio points out in chat that this actually works surprisingly well though. In fact, it probably succeeds deterministically right now (!), because fail_after(0) schedules its cancel scope to be cancelled "immediately", but since it uses a timeout to do this it doesn't actually happen until Trio processes timeouts, which it doesn't do until after this task yields. And if you look at trio._socket._SocketType.accept... it doesn't yield until after trying a non-blocking accept. So right now things work by accident.
This is dangerous, because this is a tempting thing to try, and the sort of thing where people might end up depending on it because it seems to work. I've been tempted and stopped b/c I thought it through and realized it wouldn't work – but we can't expect everyone to have my level of familiarity with the subtleties of Trio's semantics :-) – and @sorcio's tried it and found it worked...
So, if we want to keep the current semantics, maybe we should special-case move_on_after(0) and fail_after(0) to immediately cancel the scope they create, instead of going through the normal timeout machinery. Of course it would still be possible to get a similar effect with move_on_after(1e-12), but then at least it's obvious that you're doing something potentially race-y.
Alternatively, could we change our semantics to make this work? The obvious problem would be IOCP on Windows. If you use IOCP to implement accept, then the underlying operation is actually "hey Windows, please start an accept running in the background", and then blocking and going through the event loop to check if it's done. In practice, if an accept is ready immediately, then it'll probably complete either synchronously (IOCP operations are allowed to do that) or at least before we notice the timeout and call CancelIoEx. But it makes me nervous? I dunno, maybe this would actually be fine.
The bigger problem is that the semantics of "don't deliver cancellations until a task actually blocks rather than just checkpoints" are fundamentally problematic: if you have some heavy computational work to do in the background, then the standard way is to go ahead and do it and just make sure to checkpoint frequently so other tasks can run. If we start delaying cancellations in general, then it becomes impossible to cancel a task like this.
So if we were going to do this, it would have to a specific thing the user had to request, I think – and then it would be the user's job to make sure that you only use it for operations that either succeed or block quickly, like accept. with trio.move_on_when_blocks(): .... That's... not totally implausible. Certainly it'd be possible to implement, so long as we wrote down a clear definition of the difference between a checkpoint and blocking – which I'm a bit reluctant to do, because adding more distinctions adds complexity. Though we've already opened that door somewhat with trio.testing.wait_all_tasks_blocked… but that's in trio.testing plus it lets you specify a slop factor exactly to handle things like IOCP accept needing a few microseconds to be processed.
Probably the immediate thing to do is to add the special-case to make zero timeouts actually instantaneous, to keep our options open, and then continue to think about this.
Cross-ref: #242; in some sense this and that are alternatives, since they both potentially provide more general ways to handle XX_nowait.