I love this proposal so much. Much of it is exactly how I’ve thought Swift’s concurrency model should look over the last year.
Making async a return attribute just like throws
seems like the right solution to me. Building on top of callbacks (rather than introducing futures/promises) is also the right approach for swift. I think this proposal nails the problem right on the head: callbacks don't work well with the rest of Swift's error handling, is awkward, error prone, and yes, looks ugly.
One point that I've gone back and forth on is how strictly to enforce excecution order. For instance, in this example. it would make sense to allow the first 2 lines to excecute in parallel and excecute the third line once they both complete:
let a = await foo()
let b = await bar()
return [a, b]
But not in this case:
await client.connect()
let rooms = await client.getRooms()
In the first case the compiler could automatically optimize to run in parallel, the second, it could not. I like the idea of wrapping parallel code in a future, making the operation explicit and clear.
I’m not familiar with C# or other implementations of async/await, only with Javascript’s (very new) implementation. I’m curious how C# handles execution contexts (threads, queue etc) since JS doesn’t have to deal with that.
The syncCoroutine
and asyncCoroutine
example seems weird to me. It's also unclear in that example what the context would be after a call to async. Would excecution return to the queue, or be on whatever queue the async function called back on? It makes a lot more sense to me to represent this with code blocks, something like:
doSomeStuff()
await startAsync(mainQueue) {
doSomeStuffOnMainThread()
}
await startAsync(backgroundQueue) {
doSomeStuffInBackground()
}
Where every line in the code block is run on the context. This doesn't handle synchronous excecution though. For instance, if we wanted to block a queue until the entire async function had returned. An alternative might be to have queues and other contexts define their own method that took async functions:
doSomeStuff()
mainQueue.sync {
await loadSomethingStartingOnMain()
doSomeStuffOnMainThread()
// don't let anything else exceute on the main queue until this line
}
await mainQueue.async {
doSomeStuffInBackground()
}
Using queue.async
taking an async function might be a good alternative to a language "startAsync". Having the excecution context undefined based on whatever queue the underlying code calls back on seems dangerous to me. Forcing the user to define the context would fix that, but at the cost of introducing extra dispatch calls where they may not be needed. A general purpose context, that simply continued excecution in place would fix that, and be more explicit when it was needed.
One issue I see with the importer is that the conventions for callbacks aren’t as strict as NSError ** methods were. For instance, URLSession completion blocks include response, data and error, all of which are optionals. The response is almost always present, even if there was an error. But there is no way to know that from the ObjC type system, and no way to represent a throwing function that also returns metadata on error in Swift.
There are also cases where you wouldn’t want ObjC callbacks to be imported as async functions. For instance, it wouldn’t make sense for NotificationCenter callbacks to be awaited. In general, any callback that can be called more than once is dangerous to use as an async function.
Personally, I would be in favor of taking a reserved but default on approach to importing ObjC functions as async, and adding annotations to ObjC to control their Swift importing. For instance, by default callbacks with either 0 or 1 arguments would be imported as async non-throwing and callbacks with 0 or 1 arguments plus an error would be imported as throwing async. Callbacks with more than 1 argument would need to be manually annotated. Methods that should not be async (like NotificationCenter) can be annotated to not be imported as async.
Another issue we’ll need to contend with is intermediate tasks. Both URLSession and the Photos framework come to mind. In the existing model, they return something that allows you to cancel the request while it is in progress. Consider the following example:
class ImageView {
private var currentTask: URLSessionTask?
var source: URL? {
didSet {
currentTask?.cancel()
image = nil
guard let source = self.source else { return }
load(source: source) { image, error in
guard self.source == source else { return }
if let image = image {
self.image = image
} else {
self.image = errorImage
}
}
}
}
var image: Image?
func load(source: URL, completion: @escaping (Image?, Error?) -> Void) {
let task = URLSession.shared.dataTask(with: source) { (data, response, error) in
guard let data = data else {
completion(nil, error)
return
}
let image = UIImage(data: data)
completion(image, nil)
}
self.currentTask = task
task.resume()
}
}
How should we represent dataTask(with:completion:)? If I were writing this API from scratch, task.resume() would be the async function, but that may not be feasible for the importer.
If I could rewrite that example in a magical future version of Swift and Foundation, it would look something like this:
class ImageView {
private var currentTask: URLSessionTask?
var source: URL? {
didSet {
currentTask?.cancel()
image = nil
guard let source = self.source else { return }
startAsync {
do {
let image = await try load(source: source)
guard self.source == source else { return }
self.image = image
} catch {
guard self.source == source else { return } // kind of awkward to have to write this twice
self.image = errorImage
}
}
}
}
var image: Image?
func load(source: URL) async throws -> Image {
let task = URLSession.shared.dataTask(with: source)
self.currentTask = task
let data = await try task.resume()
return await UIImage(data: data)
}
}
I fail to see the benefit of adding an entirely new construct for the actor model. It seems like access control, value semantics, and dispatch queues largely fill this need already, and the implimentation of an actor wouldn't be so complicated currently that it needs a compiler feature to ensure a correct implimentation.
Futher, it seems like giving up strict actor models would remove some of the benefits other languages have with their actor models. It has been quit a while since I worked with Erlang, but from what I remember, the magic of it's recovery model comes from it's recursive, functional model. It can recover from otherwise fatal errors because it can just reset it's state to the state before the last message was received. For reasons outlined already, Swift can't enforce that strictly, so no matter what we are going to have to rely on implimentations to be implimented correctly.
Perhaps I'm missing something though.