SE-0296: async/await

Regarding suspension points, in this example I have one suspension point, after count += 1:

class Ping {
  var count = 0

  func run() async {
    count += 1
    await networkCall("/ping")
  }
}

If I now extract some code into a new function:

class Ping {
  var count = 0

  func run() async {
    await ping()
  }

  func ping() async {
    count += 1
    await networkCall("/ping")
  }
}

Do I have two suspension points now? One before count += 1 (in the run function) and one after?

Or the more general question: Does the act of extracting pieces of code into functions create new suspension points?

I agree there is a confusion with DispatchQueue.async. In fact, if it used the async/await vocabulary it would be something like DispatchQueue.detached.

The opposition between sync / async is kind of nice. With suspends, what would it be ? sync / suspending, sync / suspendable or non-suspending / suspending ? I'm not opposed to it.

You can think of await as giving the okay for suspension to occur somewhere within the expression.

In your example, I expect suspension to occur only inside of networkCall after sending the request to the network. You still have to treat it as a suspension point in all the callers because they all get suspended together when networkCall suspends.

I would expect that too, but I'm wondering if that is guaranteed or if an executor is free to insert interleaved tasks at every await, e.g. at await ping(), before calling ping().

Or can interleaved tasks only be inserted if the execution context changes?

In general, it's best to not try and think too hard about when suspension happens. Doing so is going to lead to weird concurrency bugs when code is rearranged or if the underlying executor changes.

The general mental model to have is when you see an await expression, you should assume the current execution context might suspend, and the executor free to schedule whatever on the thread being yielded (in async/await, it's also generally best to avoid thinking about how the threading works as well, as that's an implementation detail of the executor)

1 Like

So then the question is: how we can work with async variables?

How do I pass such a value to a function?

Something like foo(bar: async Int) async?

Can I declare an instance property as async let? This could be useful when starting to load something in the constructor of a class and then only await it later once it is needed.

TBH I would prefer to do this in the type system with an equivalent to Promise/Future. This would also allow an async task to be treated as an object, allowing stuff like:

let x: Promise<Int, Never> = asyncMethod()
// cancellation (if supported)
x.cancel()
// getting the result
x.resultIfAvailable

Also, it would keep the complexity of the language down. Instead of introducing the concept of async let, a new type would be added - the language would only be syntactic sugar around it.

I believe this type of async/await is off the table for Swift. Swift is striving for a solution a little more intertwined with the compiler (thus allowing more optimizations) rather than a transpile to a Promise model.

To prevent and to analyse bugs I think it is crucial to understand when other tasks can be interleaved.

If this is true it implies that I would not be able to restructure async functions within a reentrant actor without the possibility of introducing bugs. I would need to assume that simply extracting a piece of code into a new async function has the potential to break existing invariants.

1 Like

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