SE-0296: async/await

Sorry, I chose an unsuitable example. I was looking for one where the completion handler is guaranteed to run on the main thread, but that wasn't one. Here's one:

    override func viewDidLoad() {
        super.viewDidLoad()
        observer = NotificationCenter.default.addObserver(
            forName: UIApplication.didBecomeActiveNotification,
            object: nil,
            queue: .main) { _ in
            // do something on main thread
        }
    }

The important point is that closure is intended to execute interleaved with the original code and other code on the main thread. To get that effect with the PassKit example, it would need to look even worse, like this:

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        library.sign(data, using: pass) { signed, signature, error in
            DispatchQueue.main.async {
                self.isPassSigned = error == nil
                // enable some button or label in the UI
            }
        }
    }

It's the same interleaved pattern I've been trying to describe, but it's horrible to write.

Given the almost universal prevalence of this pattern currently, why isn't there an improved syntax that (a) uses sign in its new async syntax (without a completion handler) and (b) eliminates boilerplate but gets the asynchronous code back onto the main thread?

Or, I suppose, how does this look when written in terms of actors, if that is meant to achieve the same effect?

1 Like

I think we're getting fairly into Actor Isolation part esp. the Global Actor part, it @QuinceyMorris agrees, maybe the mods can move us there.

In so far, you shouldn't trust that the code is executed on any particular actor (MainActor in this case) unless the entire block is. So most likely it'll look something like:

override func viewWillAppear(_ animated: Bool) async {
  super.viewWillAppear(animated)
  let (signed, signature) = await try library.sign(data, using: pass)

  await withActor(MainActor.self) {
    self.isPassSigned = ...
  }
}

or

@MainActor
func updateUI(...) {
  self.isPassSigned = ...
}
override func viewWillAppear(_ animated: Bool) {
  super.viewWillAppear(animated)
  let (signed, signature) = await try library.sign(data, using: pass)

  await updateUI(...)
}

I don't think design in this area would affect much (if at all) of this proposal, which pertains only async/await keywords and type relationships. So maybe we can move on to other threads.

I jumped to a new topic.

I'm not sure if it helps, but I see "async/await vs actors" to be analogous to "closures/functions and struct methods".

Functions/closures in Swift can capture arbitrary state, are great at expressing a single action performed against a bag of state, and you can technically define families of them to do things if you try hard enough. However, this quickly becomes a pain when the state you manage is the primary thing, and the actions implemented against that state grow over time.

To solve this, Swift allows you to define a struct, which is a state-primary way of expressing your design, and methods on that struct which can read and/or mutate that state. This is convenient, and extensions allow you to add new behaviors to a bag of state in a retroactive way.

I see async/await in the same way. Simple async functions are great at handling single actions that apply to a specific bag of state (which includes the closed-over values as well as the parameters to the function). Many problems map cleanly onto this.

However, there are other problems where you want to be state-primary: I have a bag of data that I want to reason about, and define actions against it. Actors protect that state from concurrent access and provide a syntactically convenient way to declare the state, declare actions against it, and allow retroactive extensions of those actions.

This duality is a very natural and convenient way to handle things. While we could say (like Java etc) that "every function must be a method on a type", such overhead isn't great. Instead we admit top-level code and global functions to allow direct expression without this overhead. In the world of concurrency, we actually have four things: 1) normal functions that implicitly act against the current actor (which might be a global actor like main), 2) async functions which can be suspended but are otherwise on the current function. 3) async functions that are on an anonymous actor (a member of a "nursery / async let" in the most recent structured concurrency proposal, still TBD), and 4) async methods on actors.

The difference is a spectrum between syncronicity vs asyncronicity on the one hand, and state primary vs action primary on the other hand.

The major payoff of actors (in my opinion) is to way for programmers to think about concurrent programs with a state primary design pattern, just like we have for structs for single threaded designs.

-Chris

15 Likes

Indeed - looking at examples of async code in other languages, they primarily store state in function locals. It’s a slightly different model of programming than we may be used to, but it makes sense when you think of an async function as an “operation”.

Most async functions that I can find really do look quite similar to this example from the proposal:

func processImageData() async throws -> Image {
  let dataResource  = await try loadWebResource("dataprofile.txt")
  let imageResource = await try loadWebResource("imagedata.dat")
  let imageTmp      = await try decodeImage(dataResource, imageResource)
  let imageResult   = await try dewarpAndCleanupImage(imageTmp)
  return imageResult
}

If you need to lift that state out of function scope and share it across multiple async functions, that’s where actors come in.

2 Likes

In my personal opinion:

What is your evaluation of the proposal?

+1, with some bike-shedding on the terminology (see below).

Is the problem being addressed significant enough to warrant a change to Swift?
Does this proposal fit well with the feel and direction of Swift?

Yes to both. Absolutely.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

In-depth study of the proposal.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

Prior to reading the proposal, my intuition of some function that runs "async" was that, when the function is called, it immediately returns a future / promise to the caller, who can then demand the returned value (with await) at a later point. Then, that promise may execute concurrently, depending on resource availability, etc. My intuition for "async" is influenced by code I've written that aligns with this sort of interpretation, e.g., C++'s std::async.

After learning more about the proposal, I discovered that the proposed async marker for function types is actually an indicator of the potential to capture a continuation. The name "async" for this meaning has not yet sat right with me. I agree with @michelf in that a term like "suspends" might make more sense, because it becomes easier (for me) to remember the reasoning behind the rules for async functions. For example, here's an explanation that I can imagine someone would need to write about async functions somewhere in the Swift book:

[...] A suspending function is one that is allowed to pause itself to capture the continuation of the function as a value, called a suspension, that can be resumed at a later time. The code that can access a suspension value is allowed execute on the same thread that captured the suspension. That is, a suspending function can give up its thread for other code to use. Because the dynamic caller of a suspending function is captured as a part of its suspension, all callers of suspending functions must themselves be marked async too.

This type of minimal implementation description helps me keep in mind that async functions are just building blocks for concurrency and parallelism, etc. So, I would be in favor of one of the following terminology changes for async/await with respect to async let from the Structured Concurrency pitch:

  1. Use suspends (or something other than async) in the function type, and keep async let to mean spawning a new task and binding an opaque promise / future, etc. This reserves the term async for features that introduce concurrency.

  2. Keep async for the function type and avoid using async for something like async let (perhaps something like detach let or spawn let instead?). This reserves the term async for a feature that can be used to build concurrency, but detach or spawn actually introduces concurrency.

I prefer (1) but I don't mind (2), either. Especially since it seems that there's already precedent for (2) in existing Swift code, where "async" in a function name means "call me with a completion handler," aka a continuation.

From what I can tell, option (1) creates a cleaner correspondence between Swift's async let / await and async/ await as seen in languages like Javascript and Kotlin. While I've not used it before, it seems that for Javascript, the placement of await makes a difference in whether a call to an async function blocks until the promise is resolved or not. To me, this aligns with Swift's proposed async let, where the point at which one awaits the bound value matters. I'm not sure which languages align with (2), but I'm sure there are some.

tl;dr: My main feedback is that I think it would be confusing to have async used in both the function type to indicate the possibility for suspensions, and in a feature that spawns new concurrent tasks like async let.

1 Like

I have two concerns about this proposal that (IMO) should prevent it from being accepted as-is:

:one: With a Swift Evolution proposal, the burden of proof lies on the authors to convince everyone that their proposal is the best of the available alternatives. While I agree that the current "callback hell" situation is not ideal, the proposal is missing a survey of alternatives. The proposal begins with the assumption that async/await is the "right" way to solve this problem, but it completely misses discussing any other alternatives. Thus, the burden of proof has not been met, because no proof has been offered.

We are being shown a rosy-colored future, but we are only being shown one future. A proper proposal needs to include "what other ways could we solve this problem", and this proposal does not include those. On this point alone, I believe the proposal should be rejected; it is not a finished proposal.

:two: I am dismayed that we are venturing further down the road of exclusive and non-composable syntax. Early on in Swift's development, language features were built as special transformations of library-provided types. We see sugar syntax around literals (sugar syntax for an initializer), type definitions (sugar syntax around a full generic type written out: Dictionary<Key, Value>) and optionals (sugar syntax for writing an Optional<T>). This implementation (IMO) is vastly superior, because it allows authors to choose the syntax that works best for them in a particular context. Where [Foo] is unclear, the author can choose Array<Foo> or Array(Foo). Where optionality might cause too many parentheses, the author can choose (Void -> ())? or Optional<Void -> ()>. The freedom to choose what is contextually most expressive is (in my mind) one of Swift's super powers. Recent language additions like string interpolation and result builders recognize the power of being syntactic sugar.

However, we also have had departures from this mentality. We have the split world of throws vs Result, and it is not easy to compose the two. We've had to resort to manual error handling or manual Result-wrapping in order to go between the two.

This proposal seems to be repeating the mistake of throws-vs-Result. I believe a better future is one where async/await is sugar syntax for an underlying type, and not as another dialectic digression. Using await on a method call should be synonymous and interchangeable with using a standard-library-provided Task<T> value. Marking a function as async should be sugar syntax for returning a Task<T> value.

This would carry on Swift's heritage of allowing developers to choose the syntax (and level of expressivity) that works best for their contextual situation. It would also allow for all of the nice-looking and rosy-colored code the proposal suggests. But we would be avoiding the future in which we end up with a bifurcated API, like we are stuck with in regards to error handling.


Edited to add a different summary of :two: would be: the proposal does not convince me that this should be something wholly brand new, rather than sugar syntax on a new type. This is especially true because the entire premise is how the syntax of our existing situation is bad. So... why isn't the proper solution to fix the syntax?

14 Likes

If this would be true, there wouldn't have been any accepted proposals at all...
However, I agree with the gist that things are hurried too much (that did not start with the threading-story; maybe it started even before SwiftUI); the time of the "small steps" seems to be over.
That isn't bad per se, and I often wished for a "bigger picture" — but it is impossible to avoid errors when deciding complex questions, and I have the constant fear that the compatibility concerns will prohibit to correct them :-(
So although (afaik) it breaks with the official process, I think it is a good idea to release "experimental" features; there should be months or even years of evaluation before all those proposals become fully accepted.

6 Likes

Several years ago I came across this fantastic two-part blog post, which might be of interest here: All about Concurrency in Swift - Part 1: The Present explores Swift's current (as of ~2017) async functionality, such as NSLock and GCD, while Part 2 compares various models like coroutines, actors, futures/promises, etc, with code examples from other languages such as Go.

I haven't reread the entire posts recently, but I remember them to be a great introduction to concurrency in general, as well as a good rundown as to how other languages solve these problems.

2 Likes

+1

You're asking for an unbounded amount of unspecified work. That is not and has never been the burden of proof for a proposer, because it is unattainable.

This implementation scheme works when there is only surface-level integration between the syntactic sugar and the underlying feature. Arrays and Dictionaries only intersect with the compiler in fairly limited ways (type and expression sugar; one extra set of implicit conversions) that don't pervade the implementation or type system.

Optional types being syntactic sugar for Optional<Wrapper> is basically a lie: they are special-cased at every level of the compiler and runtime to the point where we would have been better off making them true structural types rather than pretending they are nominal.

Syntactic sugar is nice. It's easy to reason about, easy to show how it improves things, but it doesn't move the model forward to allow you to do something truly different.

This approach might make sense if we'd been shipping a Task or Future API in the standard library for a long time and a huge ecosystem of APIs that worked with that approach. Neither of those holds, and trying to cope with all of the various kinds of future types folks work with would likely make a mess.

That approach also has a large opportunity cost because you don't get to build an efficient implementation of asynchronous functions. In a sense, the lightweight nature of the async/await syntax does too good a job of hiding what's going on under the hood. If it were just closures and captures and state machines, which is the implementation model one does for syntactic sugar on top of Task-returning synchronous functions, we'd have missed out on the ability to purpose-build async functions for efficient suspension.

You aren't happy about throws-vs-Result, but it also proves the point above: if it were layered on top of Result, code size would increase and performance would suffer due to the generic manipulation code around Result and the need to emit "thunks" from non-throwing to throwing functions. Instead, Swift's calling convention for throwing functions is purpose-built to minimize code size and allow conversions from non-throwing to throwing functions to be simple bitwise operations (no thunks), making it efficient enough to use everywhere that needs error handling. The effect with async will be more pronounced because the difference between the "sugar" implementation and a purpose-built implementation is greater.

Some---perhaps many---features make sense as syntactic sugar over something else that you can already express. But it's important to distinguish cases where syntactic sugar suffices from cases where making a feature truly succeed requires deep integration. Concurrency requires deep integration.

Doug

27 Likes

This is a great point; it makes lots of sense and is explained very clearly. Can it be recorded for posterity in the proposal text?

9 Likes

+1

It could be in the Considered Alternative section. A fair number of people also asked for a sugar-based solution

2 Likes

This is a great way of wording something I’ve been trying to get after for a long time:

Many statements by the proposal authors imply that they, the authors, have put a lot of thought into considering the design of this proposal. The authors will, for example, immediately dismiss out-of-hand a possible alternative that somebody raises, for reasons that the author finds trivial or obvious.

But in fact those reasons are not at all obvious to everyone else (as evidenced by the number of such posts), and they only seem trivial to the authors because those authors have been focused so deeply on the async domain for so long that reasoning about it has become second-nature to them.

The authors are (hopefully) experts in the area.

The proposal should be the method by which the authors communicate their expertise to the rest of the community.

Quite the opposite.

Proposals have an “alternatives considered” section, where the authors are to explain what other avenues they explored, and why they ultimately settled on the one being proposed.

You were able to quickly summarize why you see async / await as a better fit for Swift than basing everything on futures. That shows you have already thought about the matter.

In other words, you have already considered a futures-based concurrency system, and you have determined that async / await is preferable. That is the type of information which should be documented in the “alternatives considered” section.

You know why (in your view) async makes a better foundation than futures. But the rest of us, those who have not spent months or years developing a concurrency system alongside you, do not.

This same principle holds for every design decision in the proposal. The authors know why those choices were made. They have already thought about it, reasoned through it, and come to a conclusion. But the rest of us don’t and haven’t.

The authors’ reasons should be recorded for posterity in the proposal text.

Conversely, if the authors of a proposal (any proposal) had not considered a reasonable spread of alternative approaches, then it seems obvious to me that such a proposal ought to be returned until the authors had done their due diligence and explained in the text what other designs they considered, and why they believe the one being proposed is best for Swift.

10 Likes

Thanks for taking the time to respond.

No, that is not what I'm asking for. I'm asking for the "Alternatives Considered" section of the proposal to show actual alternatives, not minor variations on what was proposed. @Nevin describes this very well.

Edited to add: A different way of describing what's missing here is that in order to judge this proposal, I need something to compare it against. The proposal offers nothing in the way of comparison. It starts off with the carte blanche assumption that async/await is the right solution, but does not explain why. If we accept it as-is, then we will forever be left wondering "but what if another thing...".

I'm not asking for treatises on other concurrency systems. I'm asking for the "Alternatives Considered" section to say "we considered futures; here's where they're great; here's where they fall short. because of x, y, and z, we don't think it's a viable future for everything we want to do" and "we considered syntactic manipulation; here's where it's great; here's where it falls short. because of x, y, and z, we don't think it's a viable future for everything we want to do" and so on.


I make no claims about being a compiler engineer. I have no idea how hard or easy any of this is to implement; that sort of thing is well outside my wheelhouse.

What I do know is that the throws-vs-result world has been frustrating for me to use as a developer. I'm getting the same sense from this proposal about async/await and its co-existence with existing concurrency code. That, to me, indicates that this proposal will end up causing me even more frustration as a developer, because I'll be using a language that forces me down a particular syntactic path in certain contexts whether I want that style or not. That's frustrating and inelegant and counter to my productivity.

I want Swift to be the best language to use, not one where new features make it ever-more frustrating.

So you say. But without seeing any alternatives discussed, what basis do I have for accepting this claim?


The point of these review threads is for community members to express their thoughts on a proposal. These are my thoughts:

As the proposal currently stands, it does not sufficiently convince me this is the right path forward, and therefore, I do not believe it should be accepted.

3 Likes

My mental model of a proposal is that they just need to highlight net new changes to swift. If more context is needed then this should be provided as a reference to prior art.

I do agree with you that there may be some concepts that are so different from previous art that we would need all the context to be included in the proposal but I don't believe this specific proposal falls in that category because swift is just adopting an already popular idea from other languages. In the case of async/await the authors point out a previous document that has a section named learning-from-other-concurrency-designs

If swift was the first language to propose async/await then I would expect exactly what you are proposing.

3 Likes

No.

There is currently a proposal to adopt (and adapt) an idea from other languages into Swift.

That proposal does not, in its present form, explain why we should adopt this one particular idea, rather than any of the other existing ideas in the same space, some of which are also already popular in other languages.

Moreover, this is not the garden variety “Let’s add a few things to the standard library” type of proposal.

This is a low-level proposal that would make a fundamental design choice for Swift. In order to evaluate that, it is necessary to consider, in detail, what other designs could be chosen, what the benefits and tradeoffs of different choices are, and how that will affect the language and libraries going forward.

“Alternatives considered” is not simply nice-to-have here. It is absolutely imperative to consider alternatives. And not just in a cursory, “Oh, here are some other ideas, but we like this one better,” sort of manner.

The alternatives should be steel-manned, not straw-manned.

We’re not asking the authors to prove “beyond a reasonable doubt” that no better alternative exists, but the proposal should present clear and convincing evidence that the authors made a good-faith effort to identify the best design they could find.

6 Likes

@Chris_Lattner3's Swift Concurrency Manifesto definitely goes over many alternative designs/approaches, and there are a couple specific blurbs in there about futures too.

Perhaps not all of these alternatives were mentioned in the proposal, but clearly they've been considered, and reasons for Swift to go with async/await (instead of known alternatives) were given in that doc

5 Likes

Yes, but I think what many have been expressing in trend in hard to track decision making documentation (documentation in general) regarding the Swift project.

This has come up in a few proposals that referenced material and important context are missing from proposals that exist, because it's not mentioned or linked to in a centralized official location that is easy to reference: The Swift Evolution Proposal itself.

If these things are considered "background context" to the proposal's frame of reference, it needs to be explicitly mentioned and linked to in order for readers to be in the same evaluation bounds that the author's are in.

EDIT: The mentioned "Concurrency Manifesto" by Chris is linked in the proposal, but the statement stands for any other possible discussions that were written that are considered prior research for the proposal.


In general I agree with this proposal, but I'm also 100% in agreement with @davedelong and others who have expressed concerns over the proposal's effective use of our ability to decide if it's the best choice without at least a basic pros/cons list of other approaches to concurrency that were considered.


@davedelong as a side-note, it was explicitly mentioned through the Result proposal review that the intention is that the type might end up deprecated and was a stop-gap for when we had a first-class concurrency model so that you just annotate async throws on a method and never deal with Result again.

... but I wrote that last statement before finding and re-reading the link, and the quote I remembered and the quote written are different and makes me wonder if Result might have some role to play in the Structured Concurrency conversation (or even concurrency model)

2 Likes

I certainly agree that this is basically a documentation issue, excellent documentation has been demonstrated to be difficult time and time again (and I'd personally despise for anything at all to be held up by a lack of documentation).

Mostly I was just helping connect the dots, because docs are hard :)

1 Like