-
Notifications
You must be signed in to change notification settings - Fork 785
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
Async.StartAsTask behavior in cancellation scenarios. #3219
Comments
Would a PR be accepted or at least help with the discussion? |
@matthid Yes, that would be great |
This does the trick: let test() =
let tcs = new TaskCompletionSource<unit>()
let cts = new CancellationTokenSource()
let _ = cts.Token.Register(fun () -> tcs.SetException(Exception "Something bad happened"))
let a =
async {
cts.CancelAfter 500
try do! tcs.Task |> Async.AwaitTask
with :? System.IO.FileNotFoundException -> ()
} |> fun a -> Async.RunSynchronously(a, cancellationToken = cts.Token)
a
|
@eiriktsarpalis That answer makes me a bit sad to be honest. In principle I agree with you: Asyncs should be cancelled immediately. When you get the cancellation-information you should be sure nothing is running anymore. This is not true at the moment as far as I can see when using About your last counter example I'd actually argue the reverse. There is no reason for the cancellation to throw the information about the exception "away". Why should it do that? But I think I get your point: What if we start handling the exception and doing other async work in the "catch"? I think this is quite a special case. My solution would be something like: Wrapping the original exception within a new Also when doing cancellation I personally find the current way very frustrating and limited. There is no way for custom information/logic to flow back to the place which is waiting for the result. To be honest if we even remove the existing way I'm using with exceptions, then I'd probably fall-back to just providing cancellation-tokens as parameters just like in C#. I'd probably never use the async build-in one because it's useless/too limited. |
@eiriktsarpalis Maybe I should ask the other way around: What would you suggest for my scenario? I use asyncs though some level of a call-stack and have cancellation in place. Now I want to mark some "await" places in such a way that I can print where it was waiting at some topmost waiting place. Example: let Inner1Task1() = Task.Delay(500)
let Inner1Task2() = Task.Delay(500)
let Inner2Task1() = Task.Delay(500)
let Inner2Task2() = Task.Delay(500)
let inner1() =
async {
// stuff before
do! Inner1Task1() |> Async.AwaitTask
// stuff
do! Inner1Task2() |> Async.AwaitTask
// stuff after
}
let inner2() =
async {
// stuff before
do! Inner2Task1() |> Async.AwaitTask
// stuff
do! Inner2Task2() |> Async.AwaitTask
// stuff after
}
let outer() =
async {
// stuff before
do! inner1()
// stuff
do! inner2()
// stuff after
}
let blocking() =
let tcs = new TaskCompletionSource<unit>()
let cts = new CancellationTokenSource()
let _ = cts.Token.Register(fun () -> tcs.SetException(Exception "Something bad happened"))
cts.CancelAfter 500
try
// this is expected to finish after 500ms
outer() |> fun a -> Async.RunSynchronously(a, cancellationToken = cts.Token)
with e ->
// information which task was the "faulty" task was should be here
printfn "Error: %O" e Note: The only thing I want is useful/custom information in the exception object. What I did was to add some short-circuit logic: let awaitTaskWithToken (fallBack:unit -> 'T) (item: Task<'T>) : Async<'T> =
async {
let! ct = Async.CancellationToken
return! Async.FromContinuations(fun (success, error, cancel) ->
async {
let l = obj()
let mutable finished = false
let whenFinished f =
let isJustFinished =
if finished then false
else
lock l (fun () ->
if finished then
false
else
finished <- true
true
)
if isJustFinished then
f()
use! reg = Async.OnCancel(fun () ->
whenFinished (fun () ->
try let result = fallBack()
success result
with e -> error e))
item.ContinueWith (fun (t:Task<'T>) ->
whenFinished (fun () ->
if t.IsCanceled then
cancel (OperationCanceledException("The underlying task has been cancelled"))
elif t.IsFaulted then
if t.Exception.InnerExceptions.Count = 1 then
error t.Exception.InnerExceptions.[0]
else
error t.Exception
else success t.Result))
|> ignore
} |> fun a -> Async.Start(a, ct)
)
} With this I can mark those places via: let inner1() =
async {
// stuff before
do! Inner1Task1() |> awaitTaskWithToken (fun () -> failwith "inner1task1")
// stuff
do! Inner1Task2() |> awaitTaskWithToken (fun () -> failwith "inner1task2")
// stuff after
} I know that this basically means the same thing: I accept that those tasks are still running at the background. But the difference is that I explicitly decided to do it this way, and I can decide to apply that only for some part of it. (You can definitely argue about the API - regarding the callback only being useful when throwing an exception - , but it's about the general idea) Maybe I'm doing it all wrong :/ |
This really begs the question: why are you passing a cancellation token to the parent workflow in the first place? |
It's pretty much the point of the cancellation continuation: signalling cooperative cancellation without caring about the result of the computation. It should be used with care. |
@eiriktsarpalis Sorry for being so resistant and thanks for taking the time to answer :)
So this basically means to never use it? Tbh. I already argued above that it's probably better to pass my own tokens. I thought I could use the build-in feature for this and get around passing my own tokens everywhere....
I do not care about the result. I do care about errors in the cancellation process. And I do care about the stuff being actually canceled when the cancellation signal arrives. let outer() =
async {
do! Task.Run (fun () ->
// Some real-life task
for i in 1..10 do
printfn "Task %i" i
Thread.Sleep 500
)
|> Async.AwaitTask
}
let blocking() =
let cts = new CancellationTokenSource()
cts.CancelAfter 500
try
// this is expected to finish after 500ms
outer() |> fun a -> Async.RunSynchronously(a, cancellationToken = cts.Token)
with e ->
// information which task was the "faulty" task was should be here
printfn "%O" e this prints
While let outer() =
async {
do! Task.Run (fun () ->
// Some real-life task
for i in 1..10 do
printfn "Task %i" i
Thread.Sleep 500
)
|> Async.AwaitTask
}
let blocking() =
let cts = new CancellationTokenSource()
cts.CancelAfter 500
try
// this is expected to finish after 500ms
let t = outer() |> fun a -> Async.StartAsTask(a, cancellationToken = cts.Token)
t.Result
with e ->
// information which task was the "faulty" task was should be here
printfn "%O" e prints
Don't you agree that the |
For all intents and purposes I consider exceptions to also be results of a computation. Exceptions can be caught and used for control flow. Cancellation cannot be caught and does not yield a result; it only allows for finally blocks to be executed.
I don't. I content that the behaviour isn't even related to StartAsTask, but it's part of the nature of tasks, that is different to Async in many significant ways. Async inherits its cancellation context from its parent computation, whereas Tasks are standalone futures whose cancellation needs to be configured explicitly. Most C# async APIs address this by exposing an appropriate method, for example this or this. The proper way to consume it in async is as follows: async {
let! ct = Async.CancellationToken
let! response = httpClient.SendAsync(request, ct) |> Async.AwaitTask
...
} Your example does not cancel properly because you just called |
@eiriktsarpalis Yes I agree that tasks work differently and work by passing the token explicitly. It isn't even related to using let outer() =
async {
try
printfn "starting task"
finally
Thread.Sleep 2000
printfn "cleanup"
}
let blocking() =
let cts = new CancellationTokenSource()
cts.CancelAfter 500
try
// this is expected to finish after 500ms
let t = outer() |> fun a -> Async.StartAsTask(a, cancellationToken = cts.Token)
t.Result
with e ->
// information which task was the "faulty" task was should be here
printfn "%O" e it prints
Look how it prints "cleanup" AFTER everything... Sorry I think it is just broken... |
And fundamentally I still think we should forward the exception information as inner exceptions there is just no reason not to do that |
This might even bring our two opinions together because then I'd agree that we can change the |
Cancellation continuations don't pass any information, for good reason imo. I don't see how you could achieve this without rewriting async internals and risk breaking backward compat in the process. The payoff seems extremely tiny to justify this, and goes against -at least what I consider- good practice when using async cancellation. You are right to point out that StartAsTask cancels immediately on token cancellation. This is the offending code. Arguably the promise should have been cancelled by the child's root cancellation continuation, not the the cancellation token itself. I am not particularly bothered by this. In fact, I have argued in this issue in favor of extending this behaviour to Also, I should point out that this issue is completely independent of whether cancellation should surface exceptions. |
@eiriktsarpalis Ok one last try to clarify..
Are those written anywhere to compare with?
IMHO this issue is all about which information the cancellation should give to the waiter. You say it should be none, but than there is absolutely no point in the cancellation-process (1). For me this information should be given:
Here are the reasons why I don't like your point of view:
You are right, it feels like the problems have actually increased within the discussion, because I wasn't aware that the current situation is actually that bad (I only noticed the part with throwing away the exception and the parallelism in (1) To clarify: There is no point, because this behavior is so easy to implement yourself. It's so easy to throw those 3 points I think Cancellation should do away, but quite hard to ensure them. (2) let outer() =
async {
try
try
printfn "starting task"
finally
Thread.Sleep 2000
printfn "cleanup"
// Bug only happending when cancelling
raise <| Exception "Cancellation Bug"
with :? System.IO.FileNotFoundException -> ()
}
let blocking() =
let cts = new CancellationTokenSource()
cts.CancelAfter 500
try
// this is expected to finish after 500ms
outer() |> fun a -> Async.RunSynchronously(a, cancellationToken = cts.Token)
with e ->
// information which task was the "faulty" task was should be here
printfn "%O" e prints
where is my bug? It's just hidden somewhere. I consider this wrong on so many levels I don't even know why It's so hard to convince you. To be completely honest: I think I'll just took at how hopac has done it. This discussion feels pointless, I may have completely wrong expectations about the cancellation process. |
I think we should talk about compatibility later (if we can agree on something), but to be honest I don't see a lot of problems there: I don't think there is any existing code depending on "InnerException" being |
I think I need ask again, why do you want to cancel the parent workflow?
…On Sat, Jun 24, 2017 at 08:37 Matthias Dittrich ***@***.***> wrote:
I think we should task about compatibility later (if we can agree on
something), but to be honest I don't see a lot of problems there: I don't
think there is any existing code depending on "InnerException" being null
on the OperationCancelledException. I feel the change you suggest on
RunSynchronously could be more harmful if people depend on a different
exception at the moment (but I'd accept that as bugfix, if my other points
are honored).
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#3219 (comment)>,
or mute the thread
<https://github.com/notifications/unsubscribe-auth/ACrtszeo18LeFVANw6epqnIiyiEK41N7ks5sHLymgaJpZM4N9gqN>
.
|
@eiriktsarpalis I don't understand. What else should I cancel? Can you give a sample? |
In the app you are writing, what prompts you to wire the parent to an
external cancellation token?
…On Sat, Jun 24, 2017 at 08:51 Matthias Dittrich ***@***.***> wrote:
@eiriktsarpalis <https://github.com/eiriktsarpalis> I don't understand.
What else should I cancel? Can you give a sample?
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#3219 (comment)>,
or mute the thread
<https://github.com/notifications/unsubscribe-auth/ACrts8xo5GRevVadQmAHAMdiXsoLHh04ks5sHMAOgaJpZM4N9gqN>
.
|
@eiriktsarpalis Not sure how that matters here. I'm discussing the problems on a fundamental level. But basically you can see it in the paket pull request I linked in my initial post. What I was trying to do is to start some request logic which is doing requests to servers, some logic and eventually end up with a value. This is started in some custom "pre-loading" scheduler. Now at some point such tasks can be blocking for the paket resolver (if they don't already finish beforehand) in that case I wanted to implement cancellation, but at least print out which request wasn't working. I wanted to use the build-in cancellation mechanism for that (with a custom token), but as you can see I couldn't make it work. Passing the tokens throughout the application might have worked but |
My opinion is that binding on an asynchronous computation that could be
cancelled is bad practice. I typically reserve the functionality to workflows that don't
return anything (such as disposing mailbox processors or scheduling
`Async<unit>` jobs that have proper fault tolerance set up).
Attempting to route the ccont into the econt is futile, just look at how
[`bind` is implemented](
https://github.com/Microsoft/visualfsharp/blob/master/src/fsharp/FSharp.Core/control.fs#L818).
Unless you take care to isolate cancellation properly, your code is only
one Bind (or TryWith as illustrated in my previous example) from stepping
back into ccont. In your particular application I would just wrap the
children in StartAsTask.
…On Sat, Jun 24, 2017 at 09:00 Matthias Dittrich ***@***.***> wrote:
@eiriktsarpalis <https://github.com/eiriktsarpalis> Not sure how that
matters here. I'm discussing the problems on a fundamental level.
But basically you can see it in the paket pull request I linked in my
initial post. What I was trying to do is to start some request logic which
is doing requests to servers, some logic and eventually end up with a
value. This is started in some custom "pre-loading" scheduler. Now at some
point such tasks can be blocking for the paket resolver (if they don't
already finish beforehand) in that case I wanted to implement cancellation,
but at least print out which request wasn't working.
I wanted to use the build-in cancellation mechanism for that (with a
custom token), but as you can see I couldn't make it work. Passing the
tokens throughout the application might have worked but CancellationToken
-> Async feels completely wrong, doesn't it? And I didn't want to do
that, because it would be so invasive throughout the code-base.
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#3219 (comment)>,
or mute the thread
<https://github.com/notifications/unsubscribe-auth/ACrts8KibRbkxF13u9XK6_K0X-tH3M4Vks5sHMIwgaJpZM4N9gqN>
.
|
@eiriktsarpalis Sorry I don't get it. Escaping the token is not my problem. I already scoped it in So maybe it helps to answer my question about which properties you expect from cancellation? Because obviously you don't agree with mine from above... |
So to summarize what (I think) is being discussed here:
I maintain that the two issues are independent. Are you aiming at both here or do you really only care about one of the two? At the risk of repeating myself, here's my response to each of those:
|
Wow sad. So you actually saying cancellation is undefined (there are no guarantees to anything). Which imho means it should just never be used ever. And we leave it like this for the sake of backwards compat... |
Am I being sad for pointing this out? Is it an issue of opinion? I think I've provided more than enough data to back this up. |
Please also explain to me how you expect the following to have reliable cancellation: async { do while true do () } |
No I'm sad :). Sorry it shouldn't be offensive. It's just that I don't agree with your position. I think we should have a set of guarantees in the default implementations and provide primitives to lift some of those guarantees explicitly (if the user don't need them). Which data? I can only see references to the existing implementation. I'm talking about changing those. I also don't agree with your position on #2127 it's basically the same reason others have already pointed out and I have pointed out here several times.
I can't. Why should I? There is just no way to cancel this code. So why make people "belief" there would be a way when using |
That's what I'm talking about the whole time so your example is a good one. We should take a practical approach here. We shouldn't pretend that we can cancel something running in the real world, this only leads to confusion and broken code/subtle bugs. |
This doesn't make any sense to me. You say it's by design because it is implemented this way?
I don't get why we cannot change this, make the thing well defined and keep potentially critical information. I think I will step in and try to provide an implementation (to see what changes are actually required to provide this)... Accepted or not: It might make the discussion easier (because atm I don't think too much has to be changed) |
I will reiterate that this not my opinion, I'm just describing how cancellation works in F# async. I would still like to know whether you're concerned about issue (1) or issue (2) as I described them earlier. I would also like to know whether your proposal of addressing (2) includes passing cancellation through the exception continuation. I think a PR addressing (1) would be easier to accept, pending thorough investigation on how this might break backcompat. Issue (2) sounds like a big rabbithole, you're basically intent on changing the semantics of async. You would need to revise each and every async combinator that handles continuations so that it becomes compatible with your idea of cancellation. This of course won't include third-party async combinators found in the ecosystem, which presumably implement cancellation using the current semantics (MBrace being a good example). The task is daunting, the payoff is small, and the possibility of introducing unintended bugs is significant. People (including me) that have async code running in production wouldn't be too happy about such a change. |
On a final note, consider Contrast this with |
@eiriktsarpalis I'm concerned about both (1) Yes a fix is probably more straightforward to accept. Not sure if application should depend on the current (imho broken) behavior. We could certainly have some new function (maybe (2) I'm still looking at it but it doesn't look as invasive from my point of view. The resulting Exception is exactly the same as today. The only difference is that it would have an non-null About |
Implementation difficulties aside, I'm not sure I like the inner exception idea either. In my view an inner exception indicates a root cause for the parent exception. In this case we have an exception saying "I fired because the workflow canceled, but incidentally here's an unrelated exception (or two) that I happened to come across as I was unwinding the stack". |
Now it really is a philosophical question if the cancellation started and continued because the task we were awaiting has honored the cancellation and thrown an exception or if we cancel because the token was marked as cancelled. Again for pragmatical reasons I'd just use that field :) |
Today I stumbled on some unexpected behavior when cancelling a asynchronous workflow which interacts with Tasks.
When
StartAsTask
is used it is not giving any chance to the underlying workflow to cancel in a regular way and just continues with aOperationCanceledException
, basically throwing away all information from the workflow.DISCLAIMER: Maybe this works as designed, but we should put a warning somewhere in that case :/
Repro steps
OK, to explain this see how different information can flow through the async-workflow:
In this case you get (as imho expected) an
AggregateException
with the"Something bad happened"
message.Now use
StartAsTask
which imho is just another way to start the same workflow:Expected behavior
Accessing
.Result
throwing an AggregateException (possibly wrapping another AggregateException) wrapping the"Something bad happened"
exception.Actual behavior
Known workarounds
Do not use
StartAsTask
. I copied the library implementation and added a timeout parameter (to give the underlying task some time to use the token and finish regulary).Maybe the correct thing to do is to assume the workflow is finishing correctly when the token is forwarded?
Related information
Related discussions:
This one in particular is interesting because I'm suggesting the complete reverse (on a different API).
I'm suggesting we should "ignore" cancellation when interacting with tasks and probably add some other APIs to add timeouts or early cancellation on top. IMHO it's a better default than loosing information and having to write own implementations.
/cc @eiriktsarpalis
Note that you might argue that the "different information flow" only works because it was the last thing the workflow was waiting for, but this works as expected as well:
(IE. the "Something bad happened" is returned)
The text was updated successfully, but these errors were encountered: