I have been reading the various threads on Async/Await, and I am coming around to the idea that we really need to rethink the problem entirely.
@Nevin pointed out that await
on the call site isn't really helpful to the caller because having each line wait for the previous to complete is the standard way in which things work. We should only really be marking the case where we spin off a separate execution path... because that is the point at which we need to use different reasoning.
What if, instead of marking functions as async
, we allow Types to be marked async
:
let x: async Int
This looks a lot like a future, but has several important sugary nuances that allow it to fit much more naturally with the existing Swift control flow (avoiding having to chain things like .then{}).
The big change to the compiler would be augmenting return
to be able to be used from a nested execution context, but in those cases, it would return an async
variable (aka future) instead of the naked type:
func myFuncReturningAsync() -> async Int {
myQueue.async{
///Do Calculation here in background
return myInt ///This returns as an async Int
}
}
There should be automatic promotion of variables to async
versions when necessary (similar to optionals). For example, if there are multiple return points where some are Int
and others are async Int
, the non-async points are automatically promoted to async
.
This means that an Int
could be passed to an async Int
parameter, but the opposite is not true. You would be forced to recover an actual Int
(see below) to pass the value to an Int parameter.
To get back to the naked type, we would use await
:
let x: async Int = myFuncReturningAsync()
let nakedX: Int = await x
Note: await
would work similarly to try
in that a single declaration would also await nested scopes, which allows for waiting on multiple variables to resolve.
We can also build functions into CGD (etc...) that take an async variable and call a closure on a queue when it has resolved:
myQueue.await(x) { nakedX in
}
One last bit of sugar for real world use. We should be able to await an array of async variables and get back an array of the naked types:
let myArray:[async Int] = ...
let nakedArray: [Int] = await myArray
So far it just looks like we have essentially sugared futures to avoid the usual boilerplate which makes them hard to read, and that is one possible implementation, but I want to point out that the compiler actually has a lot more freedom here to optimize things and implement in various ways. Await could still use coroutines, for example.
Let's use what we have so far to take things a step further and return to the idea of marking the function itself as async
:
func myAsyncFunc() async -> Int {
myQueue.async{
///Do Calculation here in background
return myInt ///This still technically returns as an async Int
}
}
Calling back to @Nevin's point, this should essentially work like any other function (avoiding the need to deal with it's asynchronous internals at all):
let nakedX = myAsyncFunc()
which is essentially sugar for calling await
on a version which returns async Int
. That may seem strange/useless, but there is one last step. We can tell an async function to actually return that async
version (aka don't await) by prefacing it with async
:
let x = async myAsyncFunc()
let nakedX: Int = await x
So what does all this get us?
It gives us functions which:
- Act like normal functions when called normally
- Allow you to just return from a nested execution context when you have the resulting value and have it "just work"™
- Allow you to easily treat them asynchronously when desired
- Requires an annotation at the call site in the asynchronous case (which helps the caller to think through the implications... they are also forced by the compiler to
await
the value at some point in order to use it) - Allows easier reasoning about the execution context flow, since
await
always resolves in the current execution context - Have a natural syntax for passing/returning async variables as first class parameters
Let me give a concrete example of how this makes real world code simpler and easier to reason about. Grabbing an example from another thread, we would currently write:
func getImages(urlList: [URL], callback:(UIImage)->()) {
myQueue.async {
for url in urlList {
fetchContents(of: url) {
for imageURL in extractImageURLsFromHTML($0) {
downloadImage(imageURL) {
processImage($0) {
callback($0)
}
}
}
}
}
}
}
let allImages:[UIImage] = //Getting this array left as an exercise for the reader
This could be rewritten as:
func getImages(urlList: [URL]) async -> [UIImage] {
var images:[async UIImage] = []
myQueue.async {
for url in urlList {
let contents = fetchContents(of: url)
for imageURL in extractImageURLsFromHTML(contents) {
let image = downloadImage(url)
let processed = processImage(image)
images.append(processed)
}
}
return await images
}
}
let allImages = getImages(urlList: urls)
Notice that we still need to use GCD to switch queues. async
doesn't provide concurrency by itself... that is done by the internals of the async function. However, assuming all of these calls are async
themselves (and can take async parameters), we can use their internal concurrency to make our function concurrent:
func imageURLs(in urlList: [URL]) async -> [URL] {
return urlList.flatMap { url in
let contents = async fetchContents(of: url)
return async extractImageURLsFromHTML(contents)
}
}
func getImage(url: URL) async -> UIImage {
let image = async downloadImage(url)
return async processImage(image)
}
func getImages(urlList: [URL]) async -> [UIImage] {
return async imageURLs(in: urlList).map { async getImage($0) }
}
let allImages = getImages(urlList: urls)
Notice here how we are able to easily break up the function here into much simpler pieces using standard Swift mechanisms.
In conclusion, we get the benefits of futures, but we also get progressive disclosure that futures can't provide. These async functions behave like any other synchronous function to the caller, unless we specifically ask them to be asynchronous. When we get an asynchronous value, we can pass it around like a future, but we also get automatic promotion so we can pass in normal values just as easily.
I feel like this is much easier to reason about overall... especially for callers of the functions.