Again, async/await without the guarantee that you end up in the original synchronization context is an anti-feature, i.e. a feature that makes the language worse instead of better. If that gets shipped in Swift then it will be a disaster. It's a foot-gun mislabeled as bubble gum.
This is no minor detail. It's a fundamental part of what makes async/await better. If all it did was make the code look simpler while making most simple-looking code also wrong then it would lead to more bugs, not fewer. The goal should be to make bugs more obvious, not less obvious. And trust me, if await returns to some arbitrary queue after being invoked on the main queue then it absolutely will lead to bugs, many of which will be subtle and unpredictable.
You also can't just change this behavior in the future. Changing this behavior will break code that depends on the original behavior. You would end up needing two different kinds of async/await, and the simple-looking one would be forever stuck with the behavior you almost never want.
One possible way is to declare a value as async result async let/var result, the same as func(...) async -> T; and await async values to concurrently invoke tasks let r=await (r1, r2)
lazy await semantic
async let a = await taskA()
async let b: ...
if condition {
b = await taskB1()
} else {
b = await taskB2()
}
let c = await (a,b) // invoke taskA and taskB1/taskB2 concurrently
This is why I try hard to say "synchronization context" instead of "queue". Synchronization context is a term used in .NET, and there may be a better or more generic one but I don't know what else to call it. The concept is basically "the context that the caller expects to return to", and that is not necessarily tied to a particular thread or a particular queue.
In .NET the synchronization context is a general interface for scheduling work, and the typical contexts are basically "the UI thread" or "the thread pool" (i.e., some arbitrary non-UI thread). You can also make your own custom context and set it as the current context, which you would do if you had a particular way of scheduling work.
I think this concept could be mapped to Swift with similar basic options being "dispatch async to the UI thread" or "dispatch async to some concurrent queue that can be serviced by a thread pool", and there could be some ability to set the current context so you can influence the decision.
The cases that usually matter are the special ones like the UI thread, though, so that's the one that absolutely must work correctly. By default if you start on the main queue you should return to the main queue after an await. You may not be in the same full stack of queues you started on, but you will end up back on the main queue.
// we are on the main thread here
1. let image1 = await getImage1() // returns to main thread
2. let image2 = someLongSyncCall(image1)
3. let image3 = await cleanupImage(image2) // returns to main thread
4. imageView.image = image3
at line 4 you want to be on the main thread, sure thing. though on lines 2 and 3 you probably don't...
when you mistakenly modify UI from a secondary thread in practice you resolve this relatively quickly as the debugger prompts you about this ("don't modify UI from background thread" or smth along those lines). my fear is that performing those steps like 2 unnecessarily on the main thread are much more harder to detect and avoid.
the alternative is something like this with explicit switch:
// we are on the main thread here
1. let image1 = await getImage1()
2. let image2 = someLongSyncCall(image1)
3. let image3 = await cleanupImage(image2)
4. await returnToMainTheadExplicitly() // explicit switch
5. imageView.image = image3
or maybe somehow differentiate your intention via different flavours of await.
// we are on the main thread here
1. let image1 = await getImage1()
2. let image2 = someLongSyncCall(image1)
3. let image3 = awaitOnMainThread cleanupImage(image2) // switch
4. imageView.image = image3
I prefer the Kotlin model, where you can switch context wherever you need.
// we are on the main thread here
imageView.image = withContext(BackgroundThread) {
// we are on a background thread
let image1 = await getImage1()
let image2 = someLongSyncCall(image1)
return await cleanupImage(image2)
}
It makes clear where you are. One rule when designing Android coroutine API, is that an async function must never assume in what context it is when it is executed and always specify it explicitly (using withContext).
This also make a clear distinction between coroutine (which does not imply multithreading) and multithreading. You can perfectly write non-blocking async code where everything is executed in the main context, and you can then add multithreading by switching to background context where needed.
Assuming that async call are executed on an other context is flaw IMHO. getImage1() may perfectly be run on the main thread with non-blocking primitive (callback based IO calls converted into coroutine).
And as adamkemp, I really think that coroutine code must not change context implicitly.
If we’re gonna allow the syntax as you’ve just written, we’d need to tweak the rule surrounding let (or even need @once closure). The closest I can think of with the current rule is something like this
let (a, b) = withNursery {
//Using Builder here
nursery.spawn { taskA() }
nursery.spawn { taskB() }
}
which is not bad, though it might get unwieldy with more items.
If we’re going to use coroutine model, which I believe we are (though @John_McCall did say it could still be premature to discuss that) , we’d at least need to mark a location (inside the callee) where it can yield. Otherwise, it’d just be the same thing as await immediately, differed by the order of execution because there’s still one control point at anytime.
Also, the current proposal said that you need to mark async functions with await, which I do agree with.
Maybe. I’m not asking for a concurrency out-of-the-box, you’d still need to interact w/ dispatch queue, thread, or whathaveyou. I’m asking whether it’s powerful enough to facilitate an easy syntax on the caller site (even if it could cost some headache to the callee).
I just didn’t want us getting side-tracked on specific stack-allocation schemes; coroutine vs. native stack is (like it or not) a key design element and is fine to discuss.
am i understanding this fragment correctly that at point (2) i am still on the main queue, so inside processImage i shall not do something silly like a lengthy math operation synchronously?
Yes. beginAsync, as defined in the original blog post, does not change which thread/queue you're on. It basically just provides a scope that supports await within a scope that doesn't. Note that not every implementation of async/await would require this, and I've been pushing for another direction that would not require this.
Is there a distinction between the withContext idea and a hypothetical awaitable q.async { ... } function? They both seem like they could just be library features built on top of async/await as a convenience. Am I missing something deeper?
tl;dr - search the original proposal for "syncCoroutine"
The proposal talks about having a suspendAsync() function (as a complement to beginAsync) which gives you a callback which you can call to resume the async context at a time and place of your choosing, such as on another thread or queue.
I called this out in a previous comment, and I think it's pretty cool. The proposal then shows an extension method DispatchQueue.syncCoroutine() (and an async variant) which uses suspendAsync() to let you switch dispatch queues in the middle of an async function.
But as I noted before, this gives you two different meanings of "async" which can be confusing, and may account for some of the confusion in these discussions about the async/await concept. There is DispatchQueue.async() which executes the given block on another queue (and often therefore another thread), but the async of "async/await" means that execution of the function can be suspended and resumed in a way that is totally orthogonal to threads and queues.
I'm personally not a fan of trying to use await to switch contexts deliberately. I think it makes it too difficult to keep track of which code runs in which context. Yes, it helps reduce the nested scopes, but in this case those nested scopes carry some important meaning. They show you visually where the boundary is between different contexts.
This is the same reason I think it's so important that await by default should return you to the original context. It's too difficult to reason about asynchronous code if two consecutive lines of code in the same scope may run in different contexts. Trying to be clever about this to reduce the number of curly braces is just going to make your code harder to understand and increase the likelihood of bugs.
If the nested scopes are too visually noisy then I would suggest extracting the nested scope into a function, which is the same kind of thing you would do in any situation where a function is getting too complex.
Note that this doesn't follow the same rules from Chris Lattner's original proposal in that I'm calling an async function and not awaiting it. If that is a requirement it would look more like this:
I still feel like a lot of these cases feel clunky without tying async/await to some future type, which makes it much easier to do things like convert existing callback-based code into awaitable code or to control the behavior. But I don't want to derail the discussion again so I'll just leave it there.
we know that one of the benefits of await/async is to reduce callback hell indentation, which will also not happen if you split callback-hell based code into functions. let me combine your version into a single fragment: