SE-0296: async/await

Yeah, fully reentrant actors are definitely hard to reason about (personal opinion™).
Thus, reentrancy discussion and proposals here: [Actors] Reentrancy discussion and proposal by ktoso · Pull Request #38 · DougGregor/swift-evolution · GitHub

It's not really an async/await question, and deserves it's own thread -- I think during actors proposal 2.0 would be the best time to tackle this. As by then we have just enough runtime to play with stuff and alternative solutions as well.

I guess what I mean to say here is: you’re definitely being heard that this is tricky, we are thinking about solutions. They are not really about async/await itself. The re-entrancy things would not change async/await really, but how an actor/executor deals with scheduling. I.e. async/await in a class would always be reentrant anyway (so, actors are much better for your sanity than classes with async thrown at them :slightly_smiling_face: )

3 Likes

I agree with @nuclearace, If promise makes it to the standard library, it won't be directly through async/await.

async let is a building block of Structured Concurrency. It starts a child task, that can't outlive its parent.
In that configuration cancellation is mostly handled by Swift.

There is one remaining "corner" of the non-concurrent async/await universe that I'm unclear about.

Currently, we can get the effect of "interleaving" "partial tasks" (using those terms in an analogous way, not literally as defined in this proposal) on a single thread, using DispatchQueue.async with a serial dispatch queue.

This pattern is useful for simple cases where variable mutation must be atomic and we want synchronous regions of code, but larger data races are not an issue. In practical terms, we often write code for the main thread/queue in order to avoid having to write explicit synchronization or data protection infrastructure. (Less often we do it for a "background" thread/serial queue, but the reasoning is the same.)

AFAICT, this possibility does not exist within the async/await portion of this proposal. In other words, given two Tasks, there is going to be a way to run them concurrently (possibly in parallel on multiple threads) — runDetached — but there is no way to run them non-concurrently (possibly interleaved) on the same thread (and not in parallel on multiple threads). Maybe I mean "on the same executor" here, I'm not sure.

Again AFAICT, the possibility is not proposed here because the pattern is intended to be subsumed into the global actor pattern. That's good, because the global actor pattern would make such things actually safe, rather than hopefully safe (i.e. safe if we don't make silly mistakes).

It's also somewhat bad, though, because:

  1. It requires coming to grips with actors before allowing the use of async/await to improve some of the completion-handler-based patterns we use today. That's going to be a bit off-putting to developers new to Swift or asynchronicity, I think.

  2. It requires all methods being used in the pattern to be explicitly marked as UIActor or some such global name. That seems like a lot of boilerplate to get many methods to participate in a simple pattern.

  3. It requires all methods marked with a global actor to be restricted to that actor, and prevents them being used by "the same actor as code in another Task, whatever it might be".

  4. Protecting ordinary state directly with a non-global actor requires even more hands-on knowledge of actors than #1, since (IIUC) it would involve moving the state into that non-global actor class.

Is it possible that we could have (say) runInterleaved that would run the specified task interleaved with the current task, but non-concurrently? Or is there something so egregiously wrong with our current serial queue pattern that we must disable it going forward?

I had hoped that the forthcoming @asyncHandler proposal would include this functionality, but comments upthread seem to say that the underlying API for @asyncHandler would be runDetached (concurrent) rather than a hypothetical runInterleaved (non-concurrent).

3 Likes

The presence of await does not mean that the call is synchronous. I think the confusion here, which is a common misunderstanding, is that the "wait" does not block the original thread

So, calling "await" on the main thread in an iOS app (let's say on a button being tap) doesn't lock the main thread ?
And do we have the guarantee that the function code will be resumed on the main thread as well ?

Correct. await doesn't block the calling thread. That is in fact the entire point of async/await.

That's not part of this spec, but I think it is part of the Structured Concurrency spec.

1 Like

Right. Actors are the mechanism for consistently serializing access to state. Global actors so that for global/static variables. Actor classes do that for their instance variables.

I don’t quite agree with this assessment. You can use async/await in the same places you use completion handlers today, and it improves that code. But you still have the same data races that you have today in concurrent code. You need actors to start making things safe from data races.

This is why global actor annotations can be placed on types and extensions and such, to reduce the annotation burden. But yes, this is an issue.

Most code is expected to be actor-independent, in the sense that it doesn’t care what actor it is running on, because it won’t refer to global state anyway. There’s no reason these need annotation.

Yes. To be clear, actors themselves are not that complicated in the user model. They’re like a class that’s more picky about self.

This isn’t that different from calling an async function with no actor annotation, is it? You can yield if you want to let other code run on your current actor.

Doug

That's not actually the current situation. We can use completion handlers in synchronous code today. Of course, it makes the code "asynchronous" in some informal way, meaning that the synchronous path of execution returns (or continues) before the completion handler executes.

There is a vast amount of existing code that does this, where the synchronous path is on the main thread, and the completion handler happens later (on or off the main thread, depending on which API it is).

OTOH, async/await just can't be used in synchronous code at all. That doesn't sound like "the same places" to me.

For most of the cases I'm talking about, there is no global or static data involved, the only commonality being the main thread. For example, we use the main thread currently as a principal and easy way to protect instance data of a view controller, relying on the fact almost all methods of a view controller (and of the things it is a delegate for) start running on the main thread.

If view controllers were going to be converted into actors by this proposal, then that would be the answer to my question (at least for view controller state). But that's not going to happen, is it? A view controller actor would need async methods, and that doesn't seem a feasible change to the existing classes.

I may be misunderstanding you, but it seems like you're saying that view controllers (for example) are going to be forced to be somehow wrapped in actors in order to use await, or else we will need to continue using explicit completion handlers. If that's what you mean, either way it seems like a pretty big deal.

1 Like

We'd use a global MainActor in that case.

Do you have an example on that? AFAICT, most of the existing usage of the callback are used to call code in a straight line, which would be easy to convert to async code.

I don't know what you mean by "straight line". If you mean that the caller has a continuation after the completion handler executes, that doesn't apply to synchronous code, because waiting for it would block the thread. (And not waiting for it would execute the continuation out of order.)

I'll use the API from SE-0297:

class ViewController: UIViewController {
    var library: PKPassLibrary! // assume a proper value
    var pass: PKSecureElementPass! // assume a proper value
    var data: Data! // assume a proper value
    var isPassSigned = false
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        library.sign(data, using: pass) { signed, signature, error in
            self.isPassSigned = error == nil
            // maybe enable some button or label in the UI
        }
    }
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        isPassSigned = false
    }
}

How are you proposing this should be written?

Couldn't we do:

override func viewWillAppear(_ animated: Bool) {
  super.viewWillAppear(animated)
  Task.runDetached {
    let (signed, signature, error) = await library.sign(data, using: pass)
    isPassSighed = error == nil
    // Update some UI
  }
}

No, that's my point. Using runDetached runs that closure concurrently — on another thread. The instance data (isPassSigned) is no longer protected.

The function library.sign immediately returns, and call completion (the handler) later. It doesn't wait for the signing to succeed (or fail), and we don't know which thread the closure runs on. isPassSigned is unprotected in both scenarios.

1 Like

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