4
\$\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)
\$\endgroup\$
  • \$\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 Jul 23 '18 at 13:40
2
\$\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).

\$\endgroup\$

Your Answer

By clicking "Post Your Answer", you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.