-
Notifications
You must be signed in to change notification settings - Fork 21
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
Native support for task { ... } #581
Comments
Overall, I think it is a good idea for pragmatic purposes, but to be fair, there are some cons:
Nonetheless, I do agree that it would make it more approachable to work with the majority of .NET libraries and it certainly is an issue that gets raised every so often with regards to good F# support in this or that library/framework. In particular, I remember that there was an Orleans issue about this very thing and while that particular issue never got resolved it was addressed in Orleankka by implementing a custom task ce. So I would say that if authors of both ASP.NET and Orleans based F# libraries have found a need for a task ce, it might make sense to make it official. |
One of the most important differences between Async and Task, and this would need to be well documented in the docs for the |
@rmunn I don't want to confuse anyone here. But that is just how the C# compiler is doing it. You actually can have a "not-started" task object (and start it with the "Start" method). I have actually used that in the past. But I agree that the general philosophy is that Starting |
An alternative to this suggestion might be to just ease the interop between async and task. FusionTasks is an example of an approach to this, and I'd love to see something similar baked into the async builders and support in F#. |
@ReedCopsey One motivation of this proposal is to cut down overhead as well where it is not required. FusionTaks is nice, but it still has to convert between the two models where there are many cases where an application could live entirely without a single conversion if there would be native support for tasks. |
A true implementation to handle Task and ValueTask probably should, ideally, use the functionality from C#'s task like types. However, I expect that'd change the scope of work from S-M to more L+... |
As my experiments are linked here, a small clarification:
Not my rewrite but the Task CE from FSharpX is a lot faster than crossing async <-> TPL boundary. The rewrite just eliminates one allocation, its perf difference is not clear. Using a single |
One thing I would like to call out is to NOT repeat the mistake of C# with regard to context capture. To ease working with Tasks in a GUI context, they enabled capture by default, which means that in a non-gui context you need to add I've been using TaskBuilder.fs (https://github.com/rspeele/TaskBuilder.fs) in a few projects, and it works quite well. And the best feature is that it acually has two taskbuilders, So I am all for adding a native optimized task CE to F#, but please take care of the context handling. |
I think having an inbuilt task CE is essential for easy .Net Library Interop without a performance hit on conversions back and forth to async, but I think the async and task would perform two different functions.
The starting and running of async work pipelines with Async is safer and easier so I think Async still serves a purpose although if the two could someday converge it would simplify things ... have a f# Task module that safely started/managed/parallelised task workloads the way Async does today.
If it is clear that task CE is a helper CE for .net application/librarys that predominantly manage the starting of their own tasks, task CE would just be an async function pipeline, while Async in its own right can manage the whole pipeline of starting, parrallel, async etc. The task CE would not have any parallelism built in (for/while in Async CE are not parallel). This narrows the use case down to working with .net libraries where no Async is needed as library manages task lifecycle. I agree that task CE is not a substitute for extensive functionality Async, would have a specific use case of .net task library interop without the expensive conversions back and forth to Async that are unneeded.
Would that be needed for just a task CE or to impliment a Task Module with comparable fucntionality to Async? If the later, I do agree it would be a big job. If the former, just the task CE, you will know better then me but CE overloads & type inference is a bit limiting now, when trying to overload task CE with plain @0x53A
This is very helpful for team building it to know ahead of time, if for and while loop can perhaps use lightweight recursive task unwrapping or somehow reuse task completion source, would help reduce overhead you identified. |
Ideally, if we could bind based on the member
// General Task type case that can accept Task<'T>, ValueTask<'T> and any other custom awaiter type
member this.Bind<'T,'S,'U when 'T : (member GetAwaiter : unit -> TaskAwaiter<'S>)>(m:'T, f:'S->Task<'U>) : Task<'U> =
let a = m.GetAwaiter()
if a.IsCompleted then a.GetResult() |> f
else
let tcs = TaskCompletionSource<Task<'U>>()
let t = tcs.Task
a.Completed(fun () -> tcs.SetResult( a.GetResult() |> f ))
t.Unwrap() |
@matthid but aren't the majority of Task-returning methods in the BCL "hot" tasks? In which case it would be pretty difficult to reason about what "kind" of a task you already have. In this respect Async is very consistent. |
This is true, what many already pointed out, Task cannot replace Async in F#, because having the control of when an async computation gets started makes more sense in a functional language. The Task support would have to be an addition to better interop with other .NET code. |
Not only has the Async not started yet, but you may never start it, or start it multiple times. Conceptually |
I am marking this as approved. I would love to see an RFC and proposed implementation for this, with the people in the community who need it taking a strong lead on it? |
I think one of the best implementations to date, that we adopted for Giraffe, is rspeele/TaskBuilder.fs as it can accept Task-like objects like Task<'T>, Task & ValueTask<'T> without complier having inference issues thanks to extension methods approach. Robert has implemented using AsyncTaskMethodBuilder similar to how c# does async/await so better then chaining ContinueWith, but with little added overhead of allocations on DU etc. There may be some room to improve slightly using structs and using IAsyncStateMachine SetStateMachine for pauses but the current number/size of refs being copied etc would make it slower so this would need further testing & investigation in line with how C# async/await is able to overcome this. |
I've had a few conversations about it with different people spanning half a year or so @cartermp @gerardtoconnor @ReedCopsey On the performance end we've used some ugly hacks in parts of our codebase to try get closer to C# speed where needed but it's difficult to get on par with exec time and allocs. We got close enough for our liking but the allocs are still substantially higher than C#'s We probably never quite will because there's just a lot more closure capturing etc going on vs having the compiler rewrite the method body to goto's for each step and it also generating correctly shaped types where all locals can reside in unboxed form while on the heap. Nevertheless I'm very excited to see a green light for it and as we (my company) feel the pain I'm eager to help! |
I took a look and it is great. Very impressed. Would love to see this as the basis for a PR I also wonder about a |
The In We do need native F# Maybe someone could show me, please, where to start/play on this? I'm trying to grok Rust's async story that is evolving fast and, frankly, didn't look into low level async state machines before Rust. But want do learn how that works across languages and platforms. |
@buybackoff It's useful feedback: basically you're saying that if we add native Task support for F# then it should be done in the same way as C# to get full performance parity. Rather than just adding the TaskBuilder After also looking at async debugging I can see the wisdom in that approach. That's a big job, but perhaps it's feasible since it would just be emitting code as C# does, wee would be following an established spec. |
Yes, I see no reason why to differ from TPL. There is already Just adding |
Yes, I think we're all agreed on this approach now. FWIW I find But I'm also happy to "approve-in-principle" inbuilt support for |
The key point not to miss is
As @davidfowl kindly explained recently, async endless loop just use the same chunky state machine (aspnet/KestrelHttpServer#2620 (comment)):
If the bridge cannot be seamless and a long async pipeline with F# and C# parts cannot be parts of the same state machine and every iteration will allocate something or be slow - then the effort of native But even as of now, another pattern is quite possible from C#:
I see little difference in usability. E.g. F# could be used for synchronous processing of messages and keep some complex (logical) state machine for message processing/decision making, and C#'s part could consult F# code but do the efficient work of message delivery.
Just realized I am mostly doing "kind of" system programming lately to deliver data for analysis as fast as possible, unfortunately without much complex algos where F# could shine. My use case is the pattern I described above. And it's just fine not trying to use F# everywhere just for the sake of it, but it could be plugged into any processing pipeline easily now. Maybe this native I now think |
I'm not sure I understand the overall comment (and I'd like to). For the kind of very-allocation-sensitive code you are writing then your F# code would just use |
I guess he means the C# async implementation with AsyncTaskMethodBuilder. The builder will create a state machine - preferrably with small mutable state data - heap allocated: "When IO is actually async (because read data is not yet available or the response buffers are full), seeing async allocations is completely expected. Since we don't want to block a thread, we have to move the continuation data off the stack and onto the heap." However unless the F# somehow manages to merge to the same "generated" async state machine, and the end result is a big long running state machine, the second state machine - have to created (and allocated on the heap to continuation data) on each message (which is useless for performance due the allocations): " I would focus on the per message allocations. Usually, if you can make the async state machine chunky (large long running async loops) then it's fine. The thing that ends up killing perf usually are short lived async state machines (the overhead of the async machinery might be too high for a short lived async method). This usually means receive loops are fine, but writing isn't." Single state machine pseudocode with one long running allocation for the states:
Multi state machine pseudocode with one long running allocation for the main state machine and per message state machine allocation for each received message:
And using sync methods in fully async context is the worst thing that you can do: " To expand on @davidfowl's point, while theoretically synchronous I/O could reduce async machinery related allocations at the cost of blocking threads (which use a lot of memory in their own right mostly for the stacks), this is not the case for ASP.NET since it written from the ground up using async/await meaning the async-related allocations will happen regardless. When you use the synchronous Stream APIs with ASP.NET, you're basically adding hidden Task.Wait()s everywhere. We call this sync-over-async, and it gives you the worst of both worlds. In 2.0, we actually wanted to completely remove support for the synchronous Stream APIs in ASP.NET, but this broke too many things. |
Unless IO stack is written in F# completely, there is always a point to cross between F# and TPL. This is not so much a language issue per se, but the fact that the current IO stack is using TPL with it's abstractions and concrete implementations, they managed to make it async and allocations-free on hot paths, yet F# cannot generate code that is an integral part of those async state machines implementation. I see the issue is not "be like C#", but natively support .NET async infrastucture.
Exactly that was my point, async pipline should be as long as possible. If C#'s async method has 10 steps that are merged into a single state machine, and then F# awaits ( If that goal is technically or economically (time/human resources) not feasible then TaskBuilder.fs would be just fine to make it "just work fast enough" when needed. Even loops in Kestrel allocate now, but objects are small and GC0-collectable.
very is not possible in .NET in principle for a general-purpose code. I think .NET currently is perfectly pragmatic for most workloads. Trying to make it absolute performance winner will make it C/Rust, trying to make it pure FP will make it JavaScript :) |
In relation to the implementation for this, I would prefer if we could fix properly and make Task a first class citizen in the Fsharp compliler with a specialised Task CE like async & query. To do this we should/could adopt the async/await model for nested task CEs, This uses a pre-allocated struct statemachine that is boxed back into the AMB at the end of a run on waits but needs to have fields available for all the captured values though out the entire pipeline of tasks. How this differs from how the current TaskBuilder bindings work is that all continuations will be Just want this to be a jump off point so please feel free to suggest better alternatives but I think it is best to keep in line with the C# async/await pattern to ensure we benefit from updates in future module Task
// initial example of a complex (pointless) task CE that needs to be wrapped up in a state machine
let get url1 url2 = task {
let! res = HttpClient.GetAsync(url)
if res.StatusCode = 200 then
return res
else
return! HttpClient.GetAsync(url)
}
let main () = task {
let captureVariable1 = "captured value one"
let! bind1 = task {
use conn = SqlConn("connectionstring")
let! DBData = conn.Query<MyType>("SELECT …")
let captureVariable2 = "captured value two"
<expr uses captureVariable2>
let! bind2 = task {
let captureVariable3 = "captured value three"
let result = 0
for data in DBData do
Result <- result + data.count
let! res = Get data.url1 data.url2
Do! HttpClient.Post(capturedVariable3,res)
return result
}
if bind2 > 0 then
return true
else
return false
}
if bind1 then
return! HttpClient.Post(capturedVariable1,"success")
else
return! HttpClient.Post(capturedVariable1,"failure")
return ()
}
////// Inlined and flattening the nested task CE's would look something like below, allowing removal of awaits on child computations
let main () = task {
let captureVariable1 = "captured value one"
use conn = SqlConn("connectionstring")
let! DBData = conn.Query<MyType>("SELECT …")
let captureVariable2 = "captured value two"
<expr uses captureVariable2>
let captureVariable3 = "captured value three"
let result = 0
for data in DBData do
Result <- result + data.count
let! res = HttpClient.GetAsync(url)
if res.StatusCode = 200 then
Do! HttpClient.Post(capturedVariable3,res)
else
let! res' = HttpClient.GetAsync(url)
Do! HttpClient.Post(capturedVariable3,res')
if result > 0 then
do! HttpClient.Post(capturedVariable1,"success")
else
do! HttpClient.Post(capturedVariable1,"failure")
return ()
}
// a specialised StateMachine type created for the pipeline by compiler
type StateMachine'filename@# =
struct
val AMB : AsyncMethodBuilder<unit> // AMB return type for SM is fixed as initial Task type we need to return task for
val mutable state : int
// all captured variables in pipeline pre-allocated (potential for slot re-use where new variable needed after one finished with)
[<DefaultValue>] val mutable capturedVariable1 : string
// captureVariable2 is not used after a bind so not captured at any point
[<DefaultValue>] val mutable capturedVariable3 : string
[<DefaultValue>] val mutable result : int
[<DefaultValue>] val mutable url : string
[<DefaultValue>] val mutable res : HttpResponse
// all enumerators preallocated
[<DefaultValue>] val mutable DBData : Enumerator<MyType>
// all unique awaiters in the pipeline pre-allocated (only one awaiter can be awaited at once so slot re-use fine)
[<DefaultValue>] val mutable awaiter1 : TaskAwaiter<MyType seq>
[<DefaultValue>] val mutable awaiter2 : TaskAwaiter<HttpResponse>
[<DefaultValue>] val mutable awaiter3 : TaskAwaiter
// all disposables pre-allocated
[<DefaultValue>] val mutable sqlConn : IDisposable
member inline x.Invoke0() =
x.captureVariable1 <- "captured value one"
x.sqlConn <- SqlConn("connectionstring")
let work = conn.Query<MyType>("SELECT …")
x.awaiter1 <- work.GetAwaiter()
state <- 1
x.AMB.AwaitUnsafeOnCompleted(&x.awaiter1,&x)
member inline x.Invoke1() =
x.DBData <- x.awaiter1.GetResult().GetEnumerator() // get result integrated into the method
// not is similar to how bind works already with captures
let captureVariable2 = "captured value two"
<expr uses captureVariable2>
// binding of TaskCE (bind2) is inlined/flattened
x.captureVariable3 <- "captured value three"
x.result <- 0
state <- 2 // for safety set state to two althrough already calling it
x.Invoke2() // start loop
member inline x.Invoke2() = // start looping block
if x.DBData.MoveNext() then
x.result <- x.result + x.DBData.Current.count
// binding of TaskCE (res) is inlined/flattened
let work = HttpClient.GetAsync(x.DBData.Current.Url1)
x.awaiter2 <- work.GetAwaiter()
state <- 3
x.AMB.AwaitUnsafeOnCompleted(&x.awaiter2,&x)
else
// loop finished so execute following block
if x.result > 0 then
let work = HttpClient.Post(x.capturedVariable1,"success")
awaiter3 <- work.GetAwaiter()
state <- 5
x.AMB.AwaitUnsafeOnCompleted(&x.awaiter3,&x) //re-using awaiter3 as same plain awaiter type
else
let work = HttpClient.Post(x.capturedVariable1,"failure")
awaiter3 <- work.GetAwaiter()
state <- 5
x.AMB.AwaitUnsafeOnCompleted(&x.awaiter3,&x) //re-using awaiter3 as same plain awaiter type
member inline x.Invoke3() =
let res = x.awaiter2.GetResult()
if res.StatusCode = 200 then
let work = HttpClient.Post(x.capturedVariable3,res)
x.awaiter3 <- work.GetAwaiter() //re-using awaiter3 as same plain awaiter type
state <- 2 // finished loop block so go back to start of loop block enumeration
x.AMB.AwaitUnsafeOnCompleted(&x.awaiter3,&x)
else
let work = HttpClient.GetAsync(x.DBData.Current.Url2)
x.awaiter2 <- work.GetAwaiter() //re-using awaiter2 as same HttpResponse awaiter type
state <- 4
x.AMB.AwaitUnsafeOnCompleted(&x.awaiter3,&x)
member inline x.Invoke4() =
let res' = x.awaiter2.GetResult()
let work = HttpClient.Post(x.capturedVariable3,res')
x.awaiter3 <- work.GetAwaiter() //re-using awaiter3 as same plain awaiter type
state <- 2 // finished loop block so go back to start of loop block enumeration
x.AMB.AwaitUnsafeOnCompleted(&x.awaiter3,&x)
member inline x.Invoke5() =
x.sqlConn.Dispose()
x.AMB.SetResult( () )
// the state machine interface is
interface IAsyncStateMachine with
x.MoveNext() =
match x.state with
| 0 -> x.Invoke0()
| 1 -> x.Invoke1()
| 2 -> x.Invoke2()
| 3 -> x.Invoke3()
| 4 -> x.Invoke4()
| 5 -> x.Invoke5()
| _ -> ()
x.SetStateMachine sm = x.AMB.SetStateMachine sm
new StateMachine'filename@#(amb) = { AMB = amb , state = 0 }
end
/// then the replaced complied code for the CE looks like
let main () =
let amb = AsyncMethodBuilder()
let mutable sm = StateMachine'filename@#(amb)
amb.Start(&sm)
amb.Task |
I wrote http://github.com/crowded/ply a while back, it's not on Nuget yet but it would serve as the best starting point for this endevour. It is super close to async await in terms of cpu time and I have a general outline below of what I think is needed to get Ply and CEs in general super close to C# async await in terms of allocations. These are the sources of allocations in Ply:
I'm presenting language suggestions below which are all thought of in the spirit of maximum generality, possible of improving any CE. If we see these suggestions are a no-go we basically have to do a compiler level CE rewriting pass for As far as I know not even To remove the existential allocation I have a prototype here: https://github.com/crowded/ply/tree/feature/experimental-state-passing it allocates a new computation builder instance per CE invocation to have it carry around the running AsyncTaskMethodBuilder as instance state. It directly enqueues awaiters in Beside it being dirty it makes expressing CE actions like On top of that to remove the other allocations I'd love to see two features to deal with most of that:
let inline (k: () -> Ply<'K, 'T>) =
// valueK is of type IFunction but won't locally lose it's true type so we can use the struct type for generic type constructors or constrained calls.
// Invoke actually calls the IL method here, not the allocated k instance Invoke method.
let valueK = { struct IFunction with member __.Invoke() = k() }
// 'K where 'K :> IFunction , _ is inferred to be the actual struct type.
// This true type may or may not be surfaced to the user but that's something to discuss.
Ply<_,'T>(valueK)
Sidebar: Instead of these two features a different rewrite could be added Those two additions to F# would, should, get us within one alloc per entire CE block of async await, leaving only the single variable space allocation. The additions are general in design and applicable to other CEs. Besides CEs I'd say any function value optimizations are extremely useful for any future perf work you might want to do in F#. Related discussion is struct/value lambdas in C#, specifically the last comment by Bart de Smet where he outlines a similar scheme. Their compiler does a rewriting for Which brings me to that last allocation.
Whether either of these get added isn't really important for me personally. To conclude, I'm not sure if I would advocate for special one-off compiler level CE rewriting anymore, even if that means we keep that single closure value allocation... Thoughts? P.S. I feel this is the right path forward but I may be failing to see some crucial thing which is a complete blocker for this approach. It's complex to reason through so please be a critical reader :) |
@NinoFloris It's a fascinating write up, I hadn't seen
Quick response: In my heart I feel we need a one-off compiler rewrite just for ensuring good debugging alone. And it seems simpler to rely on than Optimizer.fs, given how fragile such thing can be. But I may change my mind if/when I actually try to implement it! |
@dsyme Stack traces are actually very pleasant in ply. I made sure of that, alloc optimizations needed quite some inline already and that happened to match up with how I'd like to see the traces too. It's only showing the MoveNext method of the statemachine most of the times. Just like C# would. Who knows indeed. I know I would generally like some extra power (without going the macro route) in F# CEs to have them be used more without restraint. |
@NinoFloris I wonder if some ast input/output generation could achieve something interesting here? |
Definitely 🙃 but that would fall under 'macros/code gen' and no end to end .net tool chain exists to do it well, yet. For now it doesn't seem an option worth recommending for general purpose production development. |
@NinoFloris I might be doing something for the Applied F# Challenge so it was just something I was pondering... |
Hey @NinoFloris , awesome work on Ply. I am amazed that you can get performance that close to C# -- I didn't think it could be done without compiler support. By the way, feel free to copy the tests from TaskBuilder.fs if you want'em as a starting point, they aren't much but they did help me out with catching correctness problems a time or two. I am curious about something you said in a much older comment I didn't see till now:
While TaskBuilder.fs definitely doesn't match the performance of C# and clutters stack traces, I do believe it should behave correctly. The case of infinite while loops is something I remember looking at specifically as that was a problem with the older builders based on For example this code runs into at least the 100s of millions of iterations with minor fluctuations but no persistent increase in memory usage that I can see: let one() =
task {
do! Task.Yield()
return 1
}
[<EntryPoint>]
let main argv =
(task {
let mutable counter = 0
while true do
let! x = one()
if x > 1 then failwith "Mistake"
counter <- counter + 1
if counter % 1_000_000 = 0 then
printfn "%d" counter
}).Wait()
0 // return an integer exit code |
@rspeele Thanks, that means a lot coming from you! Yeah the benchmarks helped catch mostly type errors too.
I think you can chalk my old comment up as some reasoning error. |
If what @matthid is saying is true, that the compiler itself can choose whether to return a started task or not. Wouldn't it make sense to completly change the underlying way Fsharp handels the async ce to also use the TPL infrastructure to reap maximum performance benefits? Having the async ce be basically a cold task without context and a task ce a hot task, like csharp, without context and ontop a sync ce that also returns a hot task but with context? Could we then also get a syncAsync ce with cold task and context just to handle all cases? I would prefers this to be done by the compiler itself as to not introduce breaking changes in the existing async ce uses today. Is this basically the plan how to solve this or am I missing something? |
@dsyme and I chatted about this topic - giving the compiler knowledge of both async and task ces - and we felt it was certainly worthwhile. I think it would be strange only to do that for one of them. Of course the biggest issue is testing and ironing out a bug tail. |
Maybe this compounds the issue or adds another pro to adopting native C# 8.0 adds If native support for |
In C#, when we do
However, when we do this: try
{
await task;
}
catch (Exception ex)
{
}
On the one hand this is convenient because majority of tasks describe just a single asynchronous flow, but in case of try
{
var task = Task.WhenAll(task1, task2, ..., taskN);
await task;
}
catch (Exception ex)
{
var exns = task.Exception.InnerExceptions;
} There is more to this behaviour: async Task Main()
{
Do().Wait();
}
async Task Do()
{
//throw new ArgumentException();
await Task.Delay(0);
throw new ArgumentNullException();
} play with the code changing from The point is that asynchronous method can throw in its synchronous part before task is even returned. In other words either
|
All of this flows automatically from using GetResult(), everything around exceptions is controlled by the core library. Taskbuilder/Ply/ and #581 all function identically to C# async/await wrt to AggregateException. |
@NinoFloris yes, my bad. TaskBuilder does this even better: open System.Threading.Tasks
let task = task {
//raise (ArgumentException())
do! Task.Delay(0);
raise (ArgumentNullException())
}
task.Wait() In both cases it throws |
Just an idea, I really like how this library enables interop between Task and Async |
@uxsoft this is really cool, I would like to see something native like this |
This is now in main, in /langversion:preview |
Re the extra performance of ply vs TaskBuilder in earlier comments and ply's repo - just to clarify, is any of that included in dotnet/fsharp#6811 ? |
Much of the (smallish) perf difference in Ply was due to a bit more inlining, removing unneeded fsharprefs, custom fsharpfuncs, restructuring expressions to get nicer IL etc. Almost all of that work is unnecessary with the compilation to a single state machine method! |
I propose we implement a
task {}
workflow, similar toasync {}
which can execute and unwrap aTask
,Task<'T>
andValueTask<'T>
without having to convert the task into an async workflow first.Given that most .NET libraries (almost everything if not everything) implements asynchronous methods which work with
Task
andTask<'T>
it doesn't make sense to constantly have to convert back and forth between async workflows and tasks with the staticAsync.{...}
methods if F# could just natively work with tasks instead.My proposal is to add a
task {}
workflow which can exist side by side to the already existingasync {}
workflow. It would be a non-breaking addition to the F# languageand probably (over time) supersede.async {}
altogether (I think async would only be used for backwards compatibility at some point)Pros and Cons
The advantages of making this adjustment to F# would be better performance (as I understand that Tasks are slightly faster than Async and it will cut down the additional overhead cost of converting from Task to Async and from Async back to Task in every application. For example this is typical overhead in a standard .NET web application written in F#).
There are no disadvantages, because Async will still exist.
Extra information
FSharpX has a task computation expression and someone else has further improved it in this GitHub repository, which claims to be a lot faster.
Estimated cost (XS, S, M, L, XL, XXL):
S-M
Related suggestions:
structural awaiters
The difference between #559 and this suggestion is that I don't propose to add more conversions between Task and Async, but to add native support for Tasks instead.
Affidavit (must be submitted)
Please tick this by placing a cross in the box:
Please tick all that apply:
The text was updated successfully, but these errors were encountered: