Using async/await with existing non-concurrent code

Resuming the discussion from SE-0296: async/await - #185 by Lantua.

I'm asking how to expect to rewrite an extremely common existing pattern: non-concurrent asynchronous code using completion handlers, including (possibly) thread-hopping via DispatchQueue.async on serial queues.

Here's a simple example:

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

The closure body is here executed on the main thread, which makes it possible to reason about mutation of instance variables.

Here's an uglier version of the same pattern:

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
            DispatchQueue.main.async {
                self.isPassSigned = error == nil
                // set some label in the UI, on the main queue
            }
        }
    }
}

Note that making ViewController an actor is not an option here, nor is changing any of the instance functions of ViewController to be async.

We're starting in code that Swift will regard as a synchronous environment, and we're trying to bridge to an asynchronous environment without introducing any concurrency (i.e. no parallel execution on separate threads)

It is claimed that this is what actors are for.

Let's start with: how would we write this with actors? (If the answer is good, then my original concern is moot.)

3 Likes

I don’t think you (should) need actors for this. At least not in your own code. If async/await and the structured concurrency specs are done right then using await on the main queue should be guaranteed to resume on the main queue. That’s because a) await uses the current executor to schedule its continuation, and b) code running on the main queue should be running on a Executor that schedules all continuations using the main queue.

If it is harder than that then I think the proposal should be rejected. It is crucial for async/await to work like this so that it makes asynchronous code easier to reason about instead of harder. This is something I’ve been harping about for a long time on these forums and even back in the old mailing lists. Any async/await model that doesn’t make an await that started on the main queue resume on the main queue automatically would be a disaster.

1 Like

You can only use await in an async function. How would you get into an async function on the main thread? I can't find any proposed way to do so.

It’s coming. I don’t know which spec will cover it, but I guarantee it will be a thing. Maybe @Douglas_Gregor can weigh in.

He already did, and the answer doesn't run any async function on the main thread. It runs something concurrently.

That's why I'm jumping up and down about this. If @asyncHandler isn't non-concurrent, what is?

Your first example (adding a notification handler) likely wouldn’t use async/await on its own because await expects a single completion callback, and the notification is likely to fire multiple times.

But as I understand it, your question is mostly about whether callbacks will still run on the main thread where expected. Is that correct?

Apparently I'm having a bad day for coming up with short examples. :slightly_smiling_face: Yes, it's not a completion handler.

Now that I think about it though, assuming it continued to be just a closure that runs on the main thread one or more times, you still wouldn't be able to get from there (synchronous main thread code in Swift's view) to actual Swift-async code on the main thread. It just pushes the problem down the line, I think.

I suspect that his example using Task.runDetached actually does what you want. Here’s what I think it should do (though I can’t speak to what is actually going to be proposed).

It should call an async closure from the same thread as the calling function. If the async closure is suspended then the continuation should resume on the same Executor, which means if you start on the main queue you should resume on the main queue.

Putting those together, you just have to wrap your entire function in a call to Task.runDetached. If the closure finishes synchronously then your function finishes synchronously. If not then it resumes on the main queue. It should Just Work.

The hypothetical @asyncHandler decorator would just be syntactic sugar for wrapping the whole function, which makes it cleaner.

Let me just further clarify: if Task.runDetached doesn’t behave as I described then there needs to be a function that does. That has to exist, and the asyncHandler syntax should use whatever function does behave like that.

A global actor MainActor will provide the ability to ensure that something executes on the main thread (I think we've also called it UIActor in some places... MainActor is the better name). You can decorate any function with @MainActor to ensure that it will execute on the main thread, so let's add such a method to note when the pass was signed:

extension ViewController {
  @MainActor func passWasSigned(signed: Data, signature: Data) {
    self.isPassSigned = true
    // set some label in the UI, on the main queue
  }
}

Now our state update is guaranteed to happen on the main thread, so let's write viewWillAppear. I noted that the PKPassLibrary API you used follows the Objective-C conventions for completion-handler methods, so it is automatically imported as async based on SE-0297 that is also under review now. So, let's immediately jump into async-land by creating a detached task in viewWillAppear:

extension ViewController {
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    Task.runDetached {    
      do {
        let (signed, signature) = await try library.sign(data, using: pass)
        await passWasSigned(signed: signed, signature: signature) // okay, hops over to the main actor to do its work
      } catch {
        // tell the user something went wrong, I hope
      }
    }
  }
}

Now, @asyncHandler is mean to help with exactly these cases. It essentially runs the body of the function in an implicitly-created detached task on the given actor, so we can eliminate some boilerplate:

extension ViewController {
  @MainActor @asyncHandler
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    do {
      let (signed, signature) = await try library.sign(data, using: pass)
      // hops back to @MainActor now
      isPassSigned = true
      // set some label in the UI, on the main actor
    } catch {
      // tell the user something went wrong, I hope
    }
  }
}

Heh, I appreciate your faith in us, but let's see how the above stacks up ;)

Doug

8 Likes

Wouldn't this run super.viewWillAppear(animated) in the detached task too? Or would Task.runDetached only be inserted for each await?

In this case, yes, it would run in the detached task. You'd have a problem if your superclass viewWillAppear is meant to only be called from the main thread but wasn't annotated @MainActor.

Doug

I don't even know where to begin. In no particular order:

— UIViewController.viewWillAppear is called on the main thread, by UIKit. There's no "if" about it. Not only is it illegal for a subclass to call super.viewWillAppear from a different thread, its's also illegal for a subclass to call it asynchronously (i.e. "later") from the main thread.

— I don't understand why these annotated methods don't just conflict with what they're overriding, which are neither @MainActor nor @asyncHandler. The annotations change the semantics of methods.

— In your first example, sign is not called on the main thread (I assume), but in your second example it is called on the main thread (I assume). Your examples have different semantics, so neither explains the other.

— How would I run something on the main thread (in the first example), or run a concurrent detached task (in the second example)?

— How would I mix synchronous and asynchronous code in the second example?

— Surely there are "really" two different functions (runDetached, and runAttached or something)?

— What does @MainActor actually do, other than modify what thread/queue detached tasks are run on. If that's all it does, in what sense is it an actor? Why isn't it an executor?

— Does @MainActor also somehow constrain what thread a function runs on, apart from detached tasks? (If the question even makes sense.) What happens if a @MainActor function is actually called from the wrong thread?

— @asyncHandler seems to allow its function both to be async and to be called from synchronous code. That seems to invalidate the rule that synchronous code can't call asynchronous functions directly. Why would we have this rule if you could violate it this easily? [@Nevin has made this point previously.]

— It's not clear how this scales for non-main serial queues.

— More things I haven't articulated yet.

Maybe I can summarize my dismay this way:

For the kinds of use-cases I had in mind, I don't think we want synchronous functions to just become asynchronous. I think we need them to stay synchronous, but to allow them to start some asynchronous (but non-concurrent) tasks at will. (We also want to allow them to start some concurrent tasks at will.)

I agree with @adamkemp that "all we need is" two functions, one to run a concurrent task, and one to run an interleaved task. Both kinds of tasks would be independent (not await-able ) and both functions should be callable in synchronous and asynchronous contexts.

2 Likes

The only way to create asynchronous work from synchronous code is to create a new detached task. The alternative is to block a synchronous thread on asynchronous work, which we will not do: it undercuts the performance benefits of asynchronous functions entirely if your threads get blocked.

I’m going to start over with answering your question tomorrow, because my overly-quick response confused things. I think I understand your concerns better now, but it’s going to take time to give an answer that’s precise enough for you.

Doug

4 Likes

If you're saying that a "detached" task could be "concurrent" (conceptually, running in parallel) or "non-concurrent" (conceptually, running interleaved with the triggering task), depending on how it's initiated, then almost all of this discussion goes away.

IOW, if a "detached" task can be made to run on the main thread, that's great.

All that's left is the question of how this is written in source code.

— I really don't want to talk about @asyncHander at all, because it's not fair to expect you to get specific about something that hasn't been designed yet. (Also, based on on what you've said so far, I don't think we either need or want @asyncHandler at all, but that's a discussion for another day.)

— I think we also need a non-@asyncHandler way to get detached tasks running on the main thread.

— I think the way to specify where an explicitly-created detached task runs should be at each call site, not via attributes on declarations. (I can expand on that if necessary.)

— Therefore, there have to be at least two functions (or syntaxes) in place of the simple runDetached we've seen so far.

Yes, that's a convention. It needs to be expressed as @MainActor for that convention to be expressed in the Swift Concurrency model. The Objective-C interoperability proposal makes that possible, and simplifies this somewhat.

Let's assume that UIViewController.viewWillAppear is marked as @MainActor to get that simplification. With that, it means our override is also implicitly marked as @MainActor, so we know that it is executed on the main thread.

Overrides always change the semantics in some way. @MainActor + @asyncHandler means that the method body runs in an asynchronous task. It's acceptable as an override even if we didn't know that UIViewController.viewDidAppear is @MainActor because it's the equivalent of, e.g.,

@MainActor func viewWillAppearAsync(_ animated: Bool) async { }

func viewWillAppear(_ animated: Bool) {
    Task.runDetached { viewWillAppearAsync(animated) }
}

Yes, they do have different semantics. You can factor out the @MainActor part in the first example wherever you like to get the semantics you want. @asyncHandler makes it simpler by making it the whole body.

Put the main-thread code into a @MainActor method and it runs on the main thread. That's exactly what passWasSigned did in the first example. But I guess you're asking for the exact equivalent of the second one.

extension ViewController {
  @MainActor func viewWillAppearAsync(_ animated) async {
      do {
        let (signed, signature) = await try library.sign(data, using: pass)
        // hops back to @MainActor now
        isPassSigned = true
        // set some label in the UI, on the main actor
      } catch {
        // tell the user something went wrong, I hope
      }
  }
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    Task.runDetached {    
      await super.viewWillAppearAsync(animated)
    }
  }
}

Executors are responsible for scheduling work. An actor is a serial executor, ensuring that only an

To call a @MainActor function, you need to either already know you are on the main actor (because you're in a @MainActor function), or you need to do the call asynchronously so it can hop over to the main actor thread. These are the rules spelled out for actor instance methods in the actor proposal. Global actors are actors, so the rules are the same.

Swift code won't let you do that (it's a compile-time error based on the rules of actor isolation), but Objective-C might, e.g., call UIViewController.viewDidAppear from the wrong thread. If it happens, you'll get races. We consider mistakes in Objective-C conventions to be outside of the safety model for Swift (and always have).

@asyncHandler is syntactic sugar for creating a detached task in which the body is executed. We've found it very, very useful for adapting code that receives an event and delivers its effects asynchronously, e.g., via a completion handler. But we don't need @asyncHandler. It's just sugar and can be debated on its own merits once the model is better understood.

That's what the examples have been doing.

The other one we've considered is to allow closures to be annotated with a global actor, which means that they'll hop over to that actor as soon as they run. It's a more exact match for the DispatchQueue.main.async pattern:

Task.runDetached { @MainActor in 
  // main thread code
}

Doug

3 Likes

That's basically the answer I was looking for.

The function annotation syntax:

@MainActor func f() -> Result { … }

(with or without async, and without @asyncHandler) is something that I regard as a great new feature. I know of use cases where I want to have public API that must be called on the main thread, and there's no way of expressing that right now. (I've been using precondition to crash if the API is not called on the main thread.)

I think of it as a new feature because it's not like we don't already have lots of ways to get code executing on the main thread. In fact, execution on the main thread is pretty much the starting point for just about everything.

The code patterns I've been talking about involve getting continuations to run on the main thread — if that word makes sense here. I mean, we've got stuff to do synchronously now, and we've got stuff to do asynchronously later, and getting the later stuff onto the main thread is ugly.

For the stuff to do asynchronously later, I don't think function declaration annotations are a good fit. For example, consider code structured like this:

func g() {
  … some synchronous stuff …
  Task.runDetached {
    let result = await f()
    … do something with result …
  }
  … more synchronous stuff …
}

What I really wanted was for the detached task to run on the main thread. Well, f will run on the main thread, but the rest of it won't, which is a potential cause of bugs.

So, you've offered the olive branch:

func g() {
  … some synchronous stuff …
  Task.runDetached { @MainActor in
    let result = await f()
    … do something with result …
  }
  … more synchronous stuff …
}

That's great! That's exactly what I was looking for. Thanks.

Just … one more thing …

Given that runDetached is going to be used a lot (in either of its forms), and that it seems pretty foundational in transitioning from existing code, and that the foundational syntax around async and await (including async let) avoids mentioning Task because it's something of an implementation detail …

I'd like to suggest that runDetached should actually be provided as syntax, rather than a static method, something like:

func g() {
  … some synchronous stuff …
  detach {
    let result = await f()
    … do something with result …
  }
  … more synchronous stuff …
}

Personally, I'd also like to see a specialized syntax for the main thread, perhaps something like:

func g() {
  … some synchronous stuff …
  attach {
    let result = await f()
    … do something with result …
  }
  … more synchronous stuff …
}

However, I can see that the logic of @MainActor would compel something more like:

func g() {
  … some synchronous stuff …
  detach { @MainActor in
    let result = await f()
    … do something with result …
  }
  … more synchronous stuff …
}

or:

func g() {
  … some synchronous stuff …
  detach(@MainActor) {
    let result = await f()
    … do something with result …
  }
  … more synchronous stuff …
}

Current Swift really punishes developers by leaving no alternative except to wrap a lot of pieces of asynchronous code in DispatchQueue.main.async. To grant us a reprieve from that ugliness, it seems highly desirable to have a really, really frictionless way of saying the same thing in async-land terms. I'm not sure that Task.runDetached { @MainActor in is as frictionless as it could be.

In the above example, in what order would you expect the print statements to be executed (assuming that the await actually does suspend)?

Because of the sequential effect of await, "before await" will always appear before "after await".

Because this is a standard (concurrent) runDetached, it can run in parallel, so its print statements can execute before or after "After detached".

Were you expecting something else?

1 Like

I’d like a definitive answer from Douglas. I’ll give my thoughts, though.

If the detached task doesn’t automatically run on the UI thread as written then the API is deficient. It’s too error prone. That would be a mistake. Async code is confusing enough, but when it comes to code that needs to run on the UI thread we need simple code to Just Work without having to introduce a bunch of new concepts like actors and new keywords and syntax.

Assuming it does run on the UI thread then the "In detached, before await" will either always print first or always print second (there is no race condition if only one thread is involved). The closure is either called from the stack of the outer function or it is called asynchronously.

If it is called asynchronously then that means changing a function like this from non-async to async changes the timing of code even before the first await, which is another foot gun being loaded. That means, as you said earlier, that extracting code into a separate method to allow some of it to be asynchronous changes the timing of code even before the suspension point.

IMO, as I’ve expressed earlier in the thread, there needs to be a function that takes an async closure and runs it in the stack of the calling function until it is suspended. That would mean in the example above that the prints are ordered “before await”, “after detached”, “after await” (unless the await doesn’t actually suspend, in which case they would print in code order).

Doing it that way means extracting code into separate methods doesn’t change them in confusing ways. It also means you aren’t forcing expensive queue hops to happen when they’re not needed.

C# uses this model, and it works really well. Asynchronous code should not be forced to jump queues or yield unnecessarily, and refactoring code should be straightforward without subtle effects on the order of execution.

1 Like