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)
-
\$\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\$Fyodor Soikin– Fyodor Soikin2018年07月23日 13:40:51 +00:00Commented Jul 23, 2018 at 13:40
1 Answer 1
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).
Explore related questions
See similar questions with these tags.