OK, let’s start this over in a new thread. This time, I respectfully request that you respond to what I actually say, not to positions I don’t hold. Also, sorry, this has turned out to be book-length...
We need to start with the concept of a “path of execution”. Consider the following simple example:
X
Y
Z
print(“got here”)
You can think of X, Y and Z as placeholders for ordinary Swift statements, or as function calls if you prefer. X to Y to Z to print
is a single path of execution, which is to say that Y is executed after X, and Z is executed after Y. It’s a clear and extremely simple model to grasp. Beginners can reason about when the print
statement is executed.
Now consider a somewhat more complicated example:
B {
C {
D {
print(“got here”)
}
}
}
Assume that B, C, D are functions with a completion handler represented by a closure. That is, each function finishes whatever it is intended to do, however long that takes, then invokes its completion handler exactly once.
This is a very normal asynchronous pattern in Swift. It’s absolutely how we currently want people to write code, and it’s just fine to initiate this sequence from the main thread. There are other, more complicated asynchronous patterns, but this is the one, I’m claiming, that we should be examining first.
In fact, this is our friend, Ol’ Pyramid of Doom. We don’t like Ol’ Pyramid, but there’s actually nothing very frightening happening here. It’s still a single path of execution, B before C before D before print
. It’s just written with a lot more syntax.
There is, however, a much bigger problem, and this problem is what actually confuses beginners. There are two paths of execution here. To see that, let’s add some surrounding code:
A
B {
C {
D {
print(“got here”)
}
}
}
print(“but what about me??”)
One path is B-C-D-print
as before. The other path is A-print
. Both paths end in a print
statement, and there is no way to predict which print
will execute first. That’s what confuses beginners. They tend to assume that “but what about me??” will print after “got here”, often with fatal consequences.
As far as I’m concerned, this whole async/await discussion is about allowing programmers to write single paths of execution, for real. As far as I’m concerned, we have the “technology” to do that, if only we can get on with implementing it.
So, let’s reduce the last example to what looks like a single path of execution:
A
B
C
D
print(“got here”)
(I omitted the other print
statement temporarily. It'll be back.) In this form, it won’t work, because B, C and D are “asynchronous”, meaning they do their work after returning to the caller, not before. In order to make this an actual path of execution, we need to make them complete their execution sequentially.
That’s where await
comes in. It’s really just a serialization operator:
A
await B
await C
await D
print(“got here”)
The operator applies only to “asynchronous” functions. The result is an actual path of execution. It’s so easy to reason about, we can decide where we really wanted the other print
to happen. For example, here’s maybe what we really wanted:
A
await B
await C
await D
print(“but what about me??”)
print(“got here”)
But, go ahead, put the print
s wherever you'd like. It's easy.
OK, now let’s look at the context in which the above sequence might occur. There are basically two possibilities here. The first is a regular function:
func A1() {
A
await B
await C
await D
print(“but what about me??”)
print(“got here”)
}
The other is an “asynchronous” function:
func A2() async {
A
await B
await C
await D
print(“but what about me??”)
print(“got here”)
}
In both cases, await
is an execution-path sequencing operator.
-
In
A1
, it will actually need to wait for each of B, C and D to complete in order to move on to the next step in the path. -
In
A2
, because ofasync
, it doesn’t actually wait, but chains each step onto the (now implicit) completion handler of the previous asynchronous step.
(Of course, whatever calls A2
will have to await
it, which will either wait or chain in the same way, according to context.)
This “dual” behavior of await
is the heart of the whole proposal. It’s what makes await/async useful, and why await
should not be force-restricted to the interior of async
functions.
Please notice, in everything I’ve said above — except for the deliberately “broken” example where we couldn’t reason about the relative timing of the two print
statements — there is no concurrency. Everything you see in the source code happens sequentially in a single path of execution. That’s the point.
But there’s more. It’s not sufficient that things happen sequentially in a path of execution. We want two additional thread-related constraints:
-
Every step in the path must begin and end execution on the original thread (even if a step has internal implementation details that temporarily switch to other threads).
-
Waiting (as
await
does inA1
) must not block the thread it’s running on. It only blocks its own path of execution.
Constraint #1 means that async/await does not introduce additional thread-unsafety to the thread the execution path started on.
Constraint #2 means async/await is usable in the most likely scenario: the main thread of an app, or any similar single-path thread-like use-case, such as a serial DispatchQueue
.
This is important, because initiating asynchronous behavior from a shared serial thread like the main thread is a valorous design pattern. Starting and finishing asynchronous behavior on the main thread provides the easiest thread safety solution across a wide range of commonly useful scenarios.
AFAIK, the only plausible way of implementing these behaviors is a coroutine, and (no thanks to anything I’ve said or done) that’s what’s actually being proposed, I've been happy to see.
To review:
As far as I’m concerned, the goal here is to add Swift language features to provide a path-of-execution serialization operator, along with a genuine notion of an asynchronous function.
Beyond that:
There is a further discussion which we haven’t even started having yet. There are other asynchronous patterns we might like to provide for. How about, for example, an await
operator that operates on “groups” of asynchronous functions (aka concurrency or dispatch groups)? How about an algebra of futures or promises that can be used within the implementations of asynchronous functions to provide more sophisticated usage patterns?
Those things are important, but they are not nearly as important as the basic serialization behavior. That’s where we need to start.