Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Which Async implementation is best practice

Tags:

f#

I just can't seem to find a straight enough answer to this. I can't understand why someone would choose "Get" over "Get2".

  • In "Get", async will bleed all over my code like C#. In C#, this is for good reasons. Is this true for f# as well? async doesn't seem compose as well, functionally. Just like C#.
  • In "Get2", I've returned a value without a wrapper. The caller does not have to "await" anything. In C#, this is impossible without calling "GetAwaiter().GetResult()".

Unless there are technical reasons not to, I would always choose "Get2". That way my code can be async at the io edges of the app rather than everywhere. Can someone please help me understand why you would choose one over the other. Or the circumstances for both? I understand the threading and execution model of async/await in C#, but not how to ensure I'm doing the same thing in f#.

member this.Get (url : string) : Async<'a> = async {
    use client = new HttpClient()
    let! response = client.GetAsync(url) |> Async.AwaitTask 

    response.EnsureSuccessStatusCode() |> ignore

    let! content = response.Content.ReadAsStringAsync() |> Async.AwaitTask

    return this.serializer.FromJson<'a>(content)
    }

member this.Get2 (url : string) : 'a = 
    use client = new HttpClient()
    let response = client.GetAsync(url) |> Async.AwaitTask |> Async.RunSynchronously

    response.EnsureSuccessStatusCode() |> ignore

    let content = response.Content.ReadAsStringAsync() |> Async.AwaitTask |> Async.RunSynchronously

    this.serializer.FromJson<'a>(content);
like image 365
TBone Avatar asked Nov 29 '22 22:11

TBone


1 Answers

If you're writing a program that can afford to block, such as a console application, then by all means, block. Get2 is fine if you can afford to block.

But the truth is, most modern programs cannot afford to block. For example, in a rich GUI app, you can't afford to hold up the UI thread while you're doing your long-running thing: the UI will freeze and the user will be displeased. At the same time, you also cannot afford to just do everything in background, because eventually you need to push the results back into the UI, and that can only be done on the UI thread. So your only choice is to slice your logic into a chain of background calls with callbacks that ends back on the UI thread. Async computations (as well as C# async/await) do that for you transparently.

For another example, a web server cannot really afford to keep a live thread for every active request: that would severely limit the server's throughput. Instead, the server needs to issue long-running requests to the operating system with on-completion callbacks, which would form a chain ultimately ending with a response to the client.

This last point, perhaps, needs special clarification, because, as I have discovered, the difference between keeping an active background thread and issuing OS requests is not immediately clear to everybody. The way most OS calls are built (deep at the lowest level) is along this pattern: "Dear Operating System, please go ask the network card to receive some packets on this socket, and wake me up when that's done" (I'm somewhat simplifying here). This operation is represented in .NET API as HttpClient.GetAsync. The important point about this operation is that, while it is in progress, there is no thread that is waiting for its completion. Your application has kicked the can over to the OS side, and can happily go spend its precious threads on something different - like accepting more connections from clients or whatever.

Now, if you explicitly block on this operation with Async.RunSynchronously, that means you've just condemned your current thread to sit there and wait for the operation to complete. This thread cannot be used for anything else now until the operation is done. If you do this in every single instance, your server is now spending a thread per active connection. This is a big no-no. Threads are not cheap. And there is a limit on them. You've basically built yourself a toy server.

The bottom line is, C# async/await and F# async weren't invented just to satisfy the language designers' itch: they were sorely needed. If you're building serious software, at a certain point you are bound to discover that you can't just block everywhere, and your code turns into a mess of callbacks. And that's exactly the state most code was in just a few years ago.


I have not answered the claim that "async doesn't seem to compose well", because that claim is unsubstantiated. If you were to clarify which specific difficulties you see with composing async computations, I would be happy to amend my answer.


Edit: in response to comments

F# async and C# Task are not exactly analogous. There is a subtle difference: F# async is what we call "cold", while C# Task is what we call "hot" (see e.g. cold vs. hot observables). In plain terms, the difference is that a Task is "already running", while an async is only getting ready to run.

If you have a Task object in your hand, it means that whatever computation that object represents is already "in flight", already started, already running, and there is nothing you can do about that. You can wait for its completion, and you can obtain its result, but you can't prevent it from running, or restart it, or anything else like that.

F# async computation, on the other hand, is a computation that is "ready to go", but is not going yet. You can start it with Async.Start or Async.RunSynchronously, or whatever, but it's not doing anything until you do. One important corollary of this is that you can start it multiple times, and those would be separate, different, completely independent executions.

For example, consider this F# code:

let delay = async { 
    do! Task.Delay(500) |> Async.AwaitTask 
    return () 
}

let f = async {
    do! delay
    do! delay
    do! delay
}

and this (not quite) equivalent C# code:

var delay = Task.Delay(500);

var f = new Func<Task>( async () => {
    await delay;
    await delay;
    await delay;
});

f().Wait();

The F# version would take exactly 1500ms, while the C# version - exactly 500ms. This is because in C# delay is a single computation that runs for 500ms and stops, and that's it; but in F# delay is not running until it's used in a do!, and even then, each do! starts a new instance of delay.

One way to distill all of the above would be this: F# async is equivalent not to C# Task, but rather to Func<Task> - that is, it's not a computation itself, but a way to start a new computation.

Now, having that background, let's look at your individual small questions:

So, for the record, the async {} expression is specifically the functional equivalent to async/await in C# correct?

Kind of, but not exactly. See the explanation above.

And coupling that with "AwaitTask" calls (as above) is.... normal?

Kind of, but not exactly. As a way to consume C#-centric .NET APIs - yes, it's normal to use AwaitTask. But if you're writing your program entirely in F# and using only F# libraries, then tasks shouldn't really enter the picture at all.

it's async & let! that are the direct equivalents to async/await. Am i right?

Again, kind of, but not exactly. let! and do! are indeed direct equivalents of await, but F# async is not exactly the same as C# async - see explanation above. There is also return!, which does not have a direct equivalent in C# syntax.

And RunSync... is for some specific context where blocking is acceptable.

Yes. Usually, RunSynchronously is used at a boundary. It's used to "convert" an asynchronous computation into a synchronous one. It is equivalent to Task.GetAwaiter().GetResult().

If I want to expose that function to C# it would have to be returned back into a Task. Is "StartAsTask" the way to do that?

Again: kind of, but not exactly. Keep in mind that a Task is an "already running" computation, so you have to be careful about how exactly you turn your async into a Task. Consider this example:

let f = async { ... whatever ... } |> Async.StartAsTask

let g () = async { ... whatever ... } |> Async.StartAsTask

Here, the body of f will be executed at initialization time, and the task will start running immediately when your program starts running, and every time somebody takes the value of f, it will be always the same task. Probably not what you intuitively expect.

On the other hand, g will create a new task every time it is called. Why? Because it has an unit argument (), and so its body doesn't execute until somebody calls it with that argument. And every time it will be a new body execution, a new call to Async.StartAsTask, and therefore, a new Task. If you're still confused about what the empty parentheses () are doing exactly, take a look at this question.

does [StartAsTask] explicitly start a new thread immediately?

Yes, it does. But guess what? That's actually exactly what C# does anyway! If you have a method public async void f() { ... }, every time you call it like f(), that call does, in fact, start a new thread immediately. Well, to be more precise, it starts a new computtion immediately - that may not always result in a new thread. Async.StartAsTask does exactly the same thing.

is that a compromise that must be made for that kind of interop?

Yes, this is an approach that must be taken for that kind of interop, but I don't see why it's a "compromise".

like image 126
Fyodor Soikin Avatar answered Dec 04 '22 13:12

Fyodor Soikin