5
\$\begingroup\$

When writing asynchronous code in F#, one often needs to call methods in the .NET BCL that return Task or Task<T> rather than the F# native Async<'a> type. While the Async.AwaitTask function can convert a Task<T> into an Async<'a>, there is some cognitive overhead in having to keep track of which functions return Task<T> and which functions are Async. The TaskBuilder.fs library can help with this when using only Task<T> returning functions, but it has the same limitation in requring any Async<'a> values to be converted to Tasks with a function like Async.StartAsTask.

To simplify this interoperability, I created a new await computation expression that supports using both Async<'a> and Task or Task<T> inside it. This works by overloading the Bind, ReturnFrom, Combine, and Using methods on the AwaitableBuilder Computation Builder to handle Async<'a>, Task, and Task<T>. I haven't seen many computation builders that overload the methods to support different types, so if this is a bad idea, let me know. Also let me know if there's anything I missed in terms of supporting the interop for Task and Async.

open System
open System.Threading
open System.Threading.Tasks
/// A standard representation of an awaitable action, such as an F# Async Workflow or a .NET Task
[<Struct>]
type Awaitable<'a> =
| AsyncWorkflow of async: Async<'a>
| DotNetTask of task: Task<'a>
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module Awaitable =
 let private await f g = function
 | AsyncWorkflow a -> f a
 | DotNetTask t -> g t
 /// Convert an F# Async Workflow to an Awaitable
 let ofAsync = AsyncWorkflow
 /// Convert a .NET Event into an Awaitable
 let ofEvent event = Async.AwaitEvent event |> AsyncWorkflow
 /// Convert a .NET Task<T> into an Awaitable
 let ofTask = DotNetTask
 /// Convert a .NET Task into an Awaitable
 let ofUnitTask (t: Task) =
 t.ContinueWith(fun (task: Task) -> 
 let tcs = TaskCompletionSource<unit>()
 if task.Status = TaskStatus.Canceled
 then tcs.SetCanceled()
 elif task.Status = TaskStatus.Faulted
 then tcs.SetException(task.Exception)
 else tcs.SetResult()
 tcs.Task).Unwrap() |> DotNetTask
 /// Start an Awaitable, if it is not already running
 let start = await Async.Start <| fun t -> if t.Status = TaskStatus.Created then t.Start()
 /// Create an Awaitable that will sleep for the specified amount of time
 let sleep = Async.Sleep >> AsyncWorkflow
 /// Convert an Awaitable into an F# Async Workflow
 let toAsync<'a> : Awaitable<'a> -> Async<'a> = await id Async.AwaitTask
 /// Convert an Awaitable into a .NET Task<T>
 let toTask<'a> : Awaitable<'a> -> Task<'a> = await Async.StartAsTask id
 /// Construct an Awaitable from an existing value
 let value<'a> : 'a -> Awaitable<'a> = async.Return >> AsyncWorkflow
 /// Synchronously wait for the Awaitable to complete and return the result
 let wait<'a> : Awaitable<'a> -> 'a = await Async.RunSynchronously <| fun t -> t.RunSynchronously(); t.Result
 /// Run a set of Awaitables in parallel and create a single Awaitable that returns all of the resutls in an array
 let Parallel<'a> : Awaitable<'a> seq -> Awaitable<'a []> = 
 Seq.map toAsync >> Async.Parallel >> AsyncWorkflow
 /// Monadic bind, extract the value from inside an Awaitable and pass it to the given function
 let bind f = function
 | AsyncWorkflow a -> 
 async.Bind(a, f >> toAsync) |> AsyncWorkflow 
 | DotNetTask t -> t.ContinueWith(fun (c: Task<_>) -> 
 (c.Result |> f |> toTask)).Unwrap() |> DotNetTask
 /// Delay the evaluation of the given function, wrapping it in an Awaitable
 let delay f = bind f (value ())
 /// Combine an Awaitable<unit> with an Awaitable<'a>, 
 /// running them sequentially and returning an Awaitable<'a>
 let combine a b = bind (fun () -> b) a
 /// Evaluate an Awaitable<'a> until the guard condition returns false
 let rec doWhile guard a = 
 if guard ()
 then bind (fun () -> doWhile guard a) a
 else Task.FromResult() |> DotNetTask
 /// Try to evaluate the given Awaitable function, then unconditionally run the `finally`
 let tryFinally fin (f: unit -> Awaitable<_>) =
 async.TryFinally(f() |> toAsync, fin) |> AsyncWorkflow
 /// Try to evaluate the given Awaitable function, running the `catch` if an exception is thrown
 let tryCatch catch (f: unit -> Awaitable<_>) =
 async.TryWith(f() |> toAsync, catch >> toAsync) |> AsyncWorkflow
 /// Scope the given IDisposable resource to the Awaitable function,
 /// disposing the resource when the Awaitable has completed
 let using (a: 'a :> IDisposable) (f: 'a -> Awaitable<_>) =
 let dispose =
 let mutable flag = 0
 fun () ->
 if Interlocked.CompareExchange(&flag, 1, 0) = 0 && a |> box |> isNull |> not
 then (a :> IDisposable).Dispose()
 tryFinally dispose (fun () -> bind f (value a))
 /// Evaluate the given Awaitable function for each element in the sequence
 let forEach (items: _ seq) f = 
 using (items.GetEnumerator()) (fun e -> doWhile (fun () -> e.MoveNext()) (delay <| fun () -> f e.Current))
 /// Ignore the result of an Awaitable<'a> and return an Awaitable<unit>
 let ignore<'a> : Awaitable<'a> -> Awaitable<unit> = bind (ignore >> value)
type AwaitableBuilder () =
 member inline __.Bind (x, f) = Awaitable.bind f x
 member inline __.Bind (a, f) = a |> AsyncWorkflow |> Awaitable.bind f
 member inline __.Bind (t, f) = t |> DotNetTask |> Awaitable.bind f 
 member inline __.Delay f = Awaitable.delay f
 member inline __.Return x = Awaitable.value x
 member inline __.ReturnFrom (x: Awaitable<_>) = x
 member inline __.ReturnFrom a = a |> AsyncWorkflow
 member inline __.ReturnFrom t = t |> DotNetTask
 member inline __.Zero () = async.Return() |> AsyncWorkflow
 member inline __.Combine (a, b) = Awaitable.combine a b
 member inline __.Combine (a, b) = Awaitable.combine (AsyncWorkflow a) b
 member inline __.Combine (a, b) = Awaitable.combine (DotNetTask a) b
 member inline __.While (g, a) = Awaitable.doWhile g a
 member inline __.For (s, f) = Awaitable.forEach s f
 member inline __.TryWith (f, c) = Awaitable.tryCatch c f
 member inline __.TryFinally (f, fin) = Awaitable.tryFinally fin f
 member inline __.Using (d, f) = Awaitable.using d f
 member inline __.Using (d, f) = Awaitable.using d (f >> AsyncWorkflow)
 member inline __.Using (d, f) = Awaitable.using d (f >> DotNetTask)
[<AutoOpen>]
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module AwaitableBuilder =
 let await = AwaitableBuilder()

Here's an example of using the new computation expression to bind both Task andAsync values.

// Example Usage:
let awaitable =
 await {
 let! asyncValue = async.Return(3)
 let! taskValue = Task.Run(fun () -> 5)
 return! async { return asyncValue * taskValue }
 }
printfn "Awaitable Result: %d" (awaitable |> Awaitable.wait)
asked Jul 23, 2018 at 13:16
\$\endgroup\$
1
  • \$\begingroup\$ It may seem that you've removed the cognitive overhead, but that's an illusion. You will find, over time, that you still need to keep track of which is which. \$\endgroup\$ Commented Jul 23, 2018 at 13:40

1 Answer 1

3
\$\begingroup\$

This is a nice idea, and I'm only going to comment on some of the generic bits of the F#:

if task.Status = TaskStatus.Canceled
then tcs.SetCanceled()
elif task.Status = TaskStatus.Faulted
then tcs.SetException(task.Exception)
else tcs.SetResult()

I recommend using a match on task.Status here. It reads cleaner, is more maintainable, and fits more in with the "functional" patterns of F#:

match task.Status with
| TaskStatus.Canceled -> tcs.SetCanceled()
| TaskStatus.Faulted -> tcs.SetException(task.Exception)
| _ -> tcs.SetResult()

let start = await Async.Start <| fun t -> if t.Status = TaskStatus.Created then t.Start()

Oof...clever, but ugly. I never liked using the <| to avoid parenthesis in lambda's—I've always preferred using the (fun ...) approach, as it reads more clearly and lets you view the scope more properly. This is mostly a "personal" belief.


let bind f = function
| AsyncWorkflow a -> 
 async.Bind(a, f >> toAsync) |> AsyncWorkflow 
| DotNetTask t -> t.ContinueWith(fun (c: Task<_>) -> 
 (c.Result |> f |> toTask)).Unwrap() |> DotNetTask

I hate the formatting here, and always have. If you have to put any lines of a function on the next line, then do that with all of them. Same with the case of DotNetTask t, if you are putting any of a match case on a new line, do that with the whole thing. Additionally, I like to treat parenthesis (()) as I would braces ({}), and close them on a new line if I'm multi-lining the body:

let bind f =
 function
 | AsyncWorkflow a -> 
 async.Bind(a, f >> toAsync) |> AsyncWorkflow 
 | DotNetTask t ->
 t.ContinueWith (fun (c: Task<_>) -> 
 (c.Result |> f |> toTask)
 ).Unwrap()
 |> DotNetTask

Sure, it's a few more lines, but it tends to follow more clearly. Additionally, don't discriminate between name( and name ( for .NET / BCL methods vs. F# functions, I always put the arguments (even if tupled) with a space between the name and opening parenthesis.


Another critique on your if's:

let rec doWhile guard a = 
 if guard ()
 then bind (fun () -> doWhile guard a) a
 else Task.FromResult() |> DotNetTask

I never liked putting the then on a newline, I always do if ... then\r\n\t, so:

let rec doWhile guard a = 
 if guard () then
 bind (fun () -> doWhile guard a) a
 else
 Task.FromResult() |> DotNetTask

This helps it read better, and follow more clearly. Likewise, if I do it with one if ... then case, I do it with them all.


You compose here:

let sleep = Async.Sleep >> AsyncWorkflow

But not here:

let ofEvent event = Async.AwaitEvent event |> AsyncWorkflow

Why not? Was there an issue? Why not write it as:

let ofEvent = Async.AwaitEvent >> AsyncWorkflow

Otherwise, everything looks pretty good. A couple minor whitespace tweaks I'd make, but I won't comment on them (as it's highly personal preference, and can be quite nit-picky).

answered Aug 14, 2018 at 18:39
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.