The async/await pattern is a syntactic feature of many programming languages that allows an asynchronous, non-blocking function to be structured similarly to an ordinary synchronous function. It is semantically related to the concept of a coroutine and is often implemented using similar techniques.
Perhaps, the first mainstream language that adopted this feature was C#. During the next decade, the feature spread across many other languages:
- 2012 — C# 5
- 2015 — Python 3.5, TypeScript 1.7
- 2017 — ECMAScript 2017
- 2018 — Kotlin 1.3
- 2019 — Rust
- 2020 — C++ 20
- 2021 — Swift
That being said, in 2022 one can state async/await
, implemented one way or another, is an industry standard. Despite that C# was the first mainstream language that popularised the async/await
keywords, it wasn’t the language that invented the concept. F# added asynchronous workflows with await points in version 2.0 in 2007 (5 years before C#).
It’s interesting that with version 6 release in 2021 (14 years later) the recommended async pattern in F# was changed from async
to task
. At first glance, these two pieces of code look almost identical:
// async
let readFilesTask (path1, path2) =
async {
let! bytes1 = File.ReadAllBytesAsync(path1) |> Async.AwaitTask
let! bytes2 = File.ReadAllBytesAsync(path2) |> Async.AwaitTask
return Array.append bytes1 bytes2
} |> Async.StartAsTask
// task
let readFilesTask (path1, path2) =
task {
let! bytes1 = File.ReadAllBytesAsync(path1)
let! bytes2 = File.ReadAllBytesAsync(path2)
return Array.append bytes1 bytes2
}
Ironically, the new recommended way is less functional (in the sense of functional programming) than the original one. Consider the following code:
let a = async {
printfn "async"
}
let seq = seq {a; a}
printfn "Begin"
seq |> Seq.iter Async.RunSynchronously
printfn "End"
It will print:
Begin
async
async
End
If we removed seq |> Seq.iter Async.RunSynchronously
then the output would be just:
Begin
End
In other words, Async
is lazy by default. It will not start unless the code is awaited. Async
will not cache the result as well. If we changed this code in favour of using tasks:
let t = task {
printfn "task"
}
let seq = seq {t; t}
printfn "Begin"
seq |> Seq.iter (Async.AwaitTask >> Async.RunSynchronously)
printfn "End"
We would see:
task
Begin
End
Not only tasks start as soon as they are created. Tasks' results are also cached. The await
or let!
keywords would check Task.IsCompleted
property. When true
, tasks are executed synchronously because the result is already cached in the Task.Result
property.
Why does it matter? I highly recommend reading Async as surrogate IO by Mark Seemann and its comments. In a nutshell, I believe that async/await got a lot of inspiration from the Haskel IO monad which was released in Haskell 98. I'm not claiming that F# Async
was a direct translation of Haskell's IO, but there are similarities. In this sense, Async
has a much more in common with its monadic predecessor than Task
.
In the end, F#, the language that brought academic concepts to the object-oriented world, adopted the implementation from C#. The task
computation expression works on top of TPL library, so it has better interoperability with Task-based .NET async pattern. Practically speaking, F# fixed discrepancies between the platform and the language syntax. Every other language from the list above also tends to represent the async/await
syntax by futures/promises or similar data structures. Referential transparency is not guaranteed in any of these implementations though.
- P.S. Haskell lead developer Simon Marlow created the async package in 2012, the same year when async/await was introduced in C#.
- P.P.S. It’s possible to abuse async/await syntax to build monad comprehensions in C#.
Top comments (0)