-
-
Notifications
You must be signed in to change notification settings - Fork 343
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
Expand cancellation usability from native trio threads #2392
Expand cancellation usability from native trio threads #2392
Conversation
Codecov Report
Additional details and impacted files@@ Coverage Diff @@
## master #2392 +/- ##
========================================
Coverage 99.13% 99.14%
========================================
Files 115 115
Lines 17242 17432 +190
Branches 3085 3107 +22
========================================
+ Hits 17093 17283 +190
Misses 104 104
Partials 45 45
|
I'd like to see threads be able to run code directly in the nursery that started it, this should fix this issue more robustly. I'll edit this comment to link to what I mean shortly. Remind me if I don't |
Is this similar to what you are suggesting: #606 (comment) |
…tly poll for cancellation
avoids creating a Cancelled manually and a double-reschedule race
53f48a2
to
f6b27b2
Compare
@graingert I have implemented your request (finally!). Would you have a look at it? Also, @agronholm this might have implications for AnyIO so it would be better to have your input sooner than later! |
from_thread.run_sync now only raises in the cancellable=True case
This looks very interesting, and I can use this. I'm looking forward to adding this to AnyIO once it's released for Trio. |
it should be type-safe now, but i'm a typing newbie so maybe I could get a review on that aspect as well? |
7ec12a5
to
7a2456e
Compare
# Conflicts: # trio/_tests/test_threads.py # trio/_threads.py # trio/from_thread.py
7a2456e
to
a09aa84
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does this do if the thread call gets "uncancelled"? Ie this sequence:
- Some outer enclosing scope enters the cancelled state, so
run_sync
becomes cancelled - A more-inner enclosing scope gets mutated as
cscope.shield = True
, sorun_sync
is no longer cancelled - Then the user calls
check_cancelled
The straightforward implementation was to make this a level change in cancellation for the thread, so I think this is also the "best" behavior because it is an approximation of what would happen if the thread were blocked in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you, this generally looks excellent!
trio/_tests/test_threads.py
Outdated
await to_thread_run_sync(sync_check, cancellable=True) | ||
|
||
assert cancel_scope.cancelled_caught | ||
assert await to_thread_run_sync(partial(queue.get, timeout=1)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, this suggests that from_thread.run_sync
does raise Cancelled if the thread was abandoned. Where is that coming from? It can't be from the cancel scope, because the cancel scope doesn't exist anymore (in the general case). If it's a general "this thread was abandoned" marker, then that's mildly backcompat-breaking and I think I disprefer it regardless - "sync functions have no checkpoints and therefore can't be the cause of a Cancelled" is a nice and easy rule to remember and works better if we don't have corner-case exceptions to it.
I'm not sure how async from_thread.run
from an abandoned thread should work. Options I see are:
- run it in a system task in a cancelled cancel scope (and raise Cancelled in the thread if the cancel scope catches a Cancelled)
- run it in a system task that isn't cancelled, like we did before this PR
- raise Cancelled immediately
I would prefer one of the first two, since the third is unlike anything done elsewhere in Trio (Cancelled is raised at checkpoints, entry to an async function is not a checkpoint) and makes it hard to hand off resources safely (if you 'own' a resource and you pass it to a task that begins [async] with resource:
, then that's normally safe but wouldn't be with the new semantics because Cancelled could be raised before the recipient enters the context manager).
Regardless of what we choose, we need to document it carefully, because it's a subtle consideration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I could on board for "run in cancelled system task" but documenting the nuances of "if you abandon your thread, your async functions are going to run in a weird different task" seems really hard.
What do you think of re-using RunFinishedError
or making a TaskFinishedError
to raise immediately? This would at least not trample on the semantics of Cancelled
, but it wouldn't help with the resource handoff issue (on the other hand, users must already handle RunFinishedError
in the present case).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I implemented the semantics I think you expected, and there were enough degrees of freedom in the test suite that it didn't need much adjustment. That makes me think that we should think of a way to (a) document the surprising abandon-to-system-task semantics and (b) anchor them in the test suite. I can't yet think of which behaviors really should be represented that way though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, on further consideration I agree my previous proposal is fairly wonky and we can probably do better. I think my preference would actually be to have cancellable=True
threads use the old semantics (whether they've actually been cancelled yet or not): don't pass cancellation, don't run from_thread.run
reentrant calls in the to_thread.run_sync
task, just use a system task (that is not cancelled) like we did before this PR. Rationale:
- Making stuff fail that worked before isn't great for backcompat. If someone is using the explicit "abandon thread when I'm cancelled" flag, then they might specifically want the abandoned thread to continue operating; our attempt to interrupt it as soon as we regain control might be counterproductive.
- Since the
cancellable=True
thread explicitly might outlive the original task, any attempt it makes to call back into Trio is probably for something unrelated to the original task's context, so it's strange for that attempt to reflect the cancellation status of the original task. - I imagine a conceptual distinction between
cancellable=False
threads being an "extension of the current task", vscancellable=True
being "fire-and-forget + retrieve the result if you're still around when it becomes available". It just feels wrong to propagate cancellation in the latter case.
This discussion is underscoring for me that cancellable=True
is a bad name; cancellable=False
threads are arguably more cancellable! My first thought for a replacement is detachable
but maybe you have a better idea. Doesn't need to go in this PR but maybe should arrive in the same release to reduce confusion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was going to suggest a new, overriding kwarg abandon_on_cancel
. That tells you what happens and when, and my multiprocessing library could use kill_on_cancel
instead. cancellable
would then hang around indefinitely, deprecated but with unchanged semantics.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm equally happy with abandon_on_cancel
as a name.
…_cancelled # Conflicts: # trio/_tests/verify_types_darwin.json # trio/_tests/verify_types_linux.json # trio/_tests/verify_types_windows.json
67de17b
to
0941d05
Compare
0941d05
to
eab30c4
Compare
That's the only available way to change a task's context without creating a new task, and it's probably lower-overhead than the new task approach (but I didn't measure). The new task approach would be to push the reentrant call into a system task, which can have whatever context you want, and wrap it in a cancel scope that gets cancelled when the
There was a comment upthread that said inspecting CancelStatus wouldn't be thread-safe, but I don't think that's actually a problem: if the check is racing with the uncancellation then AFAICT it doesn't really matter whether the check sees the uncancellation or not. However my earlier snipept (which would |
I think there may be another nondeterministic edge case: if the cancellation arrives while the task is busy in As long as we need a nursery to get those semantics right, we might as well also consider an alternative implementation where the each |
I think it depends what you mean by "exactly right". :-) The analogous situation without the thread would not raise a cancellation in this case, so I don't think the thread needs to either. In any event I don't think it would be worth the trouble and overhead of a whole separate task to wait for cancellation. In general the way Trio behaves is that you need to execute a checkpoint while you are effectively cancelled (have a cancelled cancel scope closer to you than the closest shielded cancel scope) in order to raise Cancelled. If you perform a cancellation and then a shielding in the way that causes the effectively-cancelled status to toggle back and forth, but no checkpoints observe the "cancelled" state, then for your purposes it didn't happen. I think this analogizes nicely to threads if we take the "checkpoints" to be calls to |
ca9b856
to
5d93ed9
Compare
in short, cancellable threads always use system tasks. normal threads use the host task, unless passed a token
5d93ed9
to
2f79f15
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good, thank you for seeing this through! I think the main remaining thing is documentation wording, for which I've left some suggestions. It looks like there are also a few new lines that aren't covered by the tests. You're welcome to merge once you've resolved the remaining comments.
Documentation clarifications Fix function name typo leading to missed coverage Co-authored-by: Joshua Oreman <[email protected]>
Fixes #606. I used types as suggested there to distinguish the different messages from the worker thread, but it also IMHO nicely refactored the logic of the
from_thread
functions; they are now thin wrappers with docstrings. The host task containing a call toto_thread.run_sync
is reused to handle calls tofrom_thread.run*
iftrio_token is None
. The context of the thread is copied and used back in the main thread to propagate any contextvar changes that may have happened in the thread. I'd be happy to hear suggestions for applying the context infrom_thread.run
that could avoid two extra checkpoints!This PR reuses the thread local object in
_threads.py
to stash theraise_cancel
provided to abort, which basically implements a suggestion by @njsmith in #961 (comment):This is used in the existing
from_thread
functions to avoid race conditions, but I would like to use this to allow a sqlite query to poll for cancellation. Currently, I would have to dig into the internals and use this recipe from @oremanj:Furthermore, that method is not thread safe if shielding is being toggled:
trio/trio/_core/_run.py
Lines 223 to 231 in 2d62ff0
So I added a new function
from_thread.check_cancelled
which is a very simple wrapper to grab the appropriateraise_cancel
function if it is there and call it. A question though: Would it be better to call it or just return it?might be more along the lines of what people are expecting compared to
But I feel like people should be nudged to actually raise
Cancelled
if they are in fact responding to cancellation!