Using async/await with existing non-concurrent code

It seems to me there are two possible use-cases:

  1. This is all meant to run on the main thread, which is the scenario you want to see, IIUC.

  2. The detached task is really wanted to run concurrently (including in parallel on another thread).

In the second case, there is no convenient reliance on main thread interleaving to give (e.g. atomicity), but it's the only way to get an independent (non-child) task spawned from synchronous code, isn't it?

That's why I think we need 2 run… APIs, one for each use-case.

You keep arguing for having two run... APIs (your names are runDetached and runAttached, I believe), but it seems like this argument is always made in the context of the main thread. Are there cases where you want to run a function asynchronously on the same thread that enqueued it, where that thread is not the main thread? We don't even do that with libdispatch right now—you can enqueue work onto the same serial queue, but that's not guaranteed to always run on the same system thread.

It doesn't seem obvious to me that we need a runAttached; it seems like you're what you're arguing for is more like Task.runOnMainActor {}, or more likely MainActor.run {}.

Are there cases where you'd be writing new code with the new concurrency system that you'd need background-thread serialization where you wouldn't want to create an actor?


As far as UI updates are concerned and making sure that they happen on the main thread, I'm frankly expecting that we'll see a future version of UIKit where all the UI classes are marked with @MainActor. This would automatically enforce that you could only call those functions on the main thread, so it doesn't really matter what thread Task.runDetached runs on. (I have no idea how such a version of UIKit would maintain API and source compatibility, but it seems obvious to me that one of the main benefits of a @MainActor is to enable exactly such a transition. This is all just speculation on my part, but I'll be very surprised if Apple doesn't take advantage of actors to make it easier to do the right thing with UIKit calls.

2 Likes

It's an interesting question. There are cases, but I'm not sure if "async Swift" needs to support them.

The question shouldn't really be asked in terms of threads, since that's an implementation detail that might vary from platform to platform, but it can be asked in terms of executors, I think.

If you could runAttached or whatever from a task using a non-main executor, then I guess it would be asking for the detached task to be run interleaved (but not concurrent) with the originating task. This pattern would be similar to using a non-main (aka "background") serial thread in GCD, for which there are use cases.

However, in that case, I think there's a fairly strong argument to use an actor instead, because it's already a fairly obscure usage, and an actor might actually simplify either the design or the implementation. So maybe we don't really need a runAttached for non-main executors. I don't know.

I think the reason it would be good to have runAttached for the main thread/queue code pattern is that it would provide a good transition from existing code using completion-handler-style asynchonicity to code using async-style asynchronicity.

Why? Because rewriting code to use actors may turn out to be a significant design change (and there's going to be a learning curve for actors that developers might want to take slowly or defer to new projects), but reworking just the syntax (more or less) within a current design might be a more practical choice.

1 Like

It looks like the relationship is inverted to me. You'd want the code in the runDetached to be on UIActor because the code therein requires it, not because the caller is running on UIActor. If you can inherit the actor, you already don't care about the actor you're running on and are more focused on the serialization, which already isn't viable across the runDetached boundary.

I'm not sure I understand this. Normal refactoring, like separating code into a function, already maintains the execution order, even on async ones.

The order of execution breaks is when you move code across runDetach. I don't see why it's more problematic than crossing DispatchQueue.async, which changes the order at about the same level of unpredictability.

That is a good question. In .Net there is a general concept of a "synchronization context" which is analogous to what the Structured Concurrency proposal calls an Executor. The synchronization context determines how work is scheduled, and it is used to schedule continuations. When executing code there is always a current synchronization context that is (by default) used to schedule the continuation. Tasks can be configured to instead use a different synchronization context for their continuation or they can be configured to stick with the context that the work itself was performed on.

To more directly answer your question, I think predominantly there are only two synchronization contexts commonly used in .Net: one for stuff that has to execute on the main thread and one that executes in a generic thread pool. There are situations in Windows where objects are meant to be used by a single thread that may or may not be the main thread (objects that use the "single threaded apartment" model), and there are probably synchronization contexts built around those. I haven't actually looked. For most people you only have to worry about the two common modes.

I do try to avoid using the word "thread" much, especially when not talking about the main thread. I'm not sure how consistent I have managed to be. The model I have in my head is like the .Net model, which is in terms of contexts, not threads. A context may use a particular thread or a particular queue. Actors seem like synchronization contexts tied to a particular serial queue but not a particular OS thread. The main actor is tied both to a particular queue and a particular thread (because the queue itself is tied to that thread).

If I had my way we would be able to just write this:

override func viewWillAppear(_ animated: Bool) async {
    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
    }
}

In C# that's equivalent to an async void method. Async void methods can be called by code that is unaware the function is asynchronous at all. The function starts on the same call stack as the caller and remains on that call stack until it is suspended (if it's never suspended then it just finishes entirely synchronously). If it is suspended then its continuation is scheduled using the current synchronization context, whatever that may be. At that point the continuation is entirely detached. Nothing is waiting for it.

I think the designers of this Swift feature don't like that last bit, which is understandable. It's a bit of a sharp edge in C#. I can understand the desire to force a bit more syntax that maybe makes it clearer what the bounds of that task are.

But in doing so we shouldn't be forcing developers who want to write some asynchronous code in a UI event handler (like a button click) to implicitly dispatch_async their code unconditionally. And we should as much as possible make code that interacts with the UI thread remain on the UI thread by default without forcing the developer to remember to add extra keywords or decorations. We don't want async code to be too hard to use. It should make our lives better, not introduce more things we can very easily screw up.

3 Likes

I wasn't very clear about what I meant, largely because my last response was written on a phone where I couldn't easily type code examples.

The kind of case I have in mind is when you start with code like this:

func doStuff() {
    doThisFirst()
    doThisLast()
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    doStuff()
    doOtherStuff()
}

Now say you need to add an extra async step in the middle of doStuff:

func doStuff() async {
    doThisFirst()
    await doThisSecond()
    doThisLast()
}

Oops, now that's async so you have to await it in the viewWillAppear method, but you can't directly do that as proposed. So that method has to change somehow. The current proposal is like this:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    Task.runDetached {
        await doStuff()
    }
    doOtherStuff()
}

Oops, now doStuff is no longer called on the UI thread. Maybe it should be this?

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    Task.runAttached { // or runInterleaved? Or what?
        await doStuff()
    }
    doOtherStuff()
}

But if Task.runAttached wraps the whole closure in a dispatch_async then now the call to doThisFirst happens later than it used to, which would be after doOtherStuff. If you actually care about doThisFirst happening before doOtherStuff then you would end up having to extract that part from doStuff entirely.

Imagine instead that it worked like I described in my last comment. You would update viewWillAppear like this:

override func viewWillAppear(_ animated: Bool) async {
    super.viewWillAppear(animated)
    await doStuff()
    doOtherStuff()
}

Now there are only two potential changes in timing: doThisLast and doOtherStuff might (or might not) happen later than before, which in both cases is made clear already by the usage of await. There are no other changes to the relative ordering of any of the calls.

There are multiple possible ways to design the syntax and APIs for async code and how they behave, and as you can see some of them can be harder to use than others. Some have more sharp edges than others. I would like to have fewer sharp edges.

1 Like

You cannot just "add" an extra async step, because the caller has called this method synchronously.

This isn't just about when the pieces (aka partial tasks) of the method body are executed or in what order, but also about when the synchronous return to the caller occurs.

If you mark viewWillAppear as async then the timing of the synchronous return becomes inscrutable. It'd be at the first actual suspension point, but there's no feasible way to reason about this in the source code.

If you leave viewWillAppear as synchronous, then the synchronous return is easily determined, but you cannot do asynchronous things before returning.

Those are the only 2 options we're being offered for Swift.

It's been a few years since I did anything with async/await in C#, but my recollection is that this wasn't actually a problem for the UI thread because the caller (of, say, an on_button_clicked handler) let you provide either a synchronous or asynchronous function, and called it appropriately.

That doesn't work here, because the caller is something inside UIKit, which doesn't know about async functions.

Sure you can. I just did. This is the kind of stuff people are going to end up wanting to do when async/await is available. Does it have an effect? Of course it does. Even in the example I showed of how I wanted it to work there's still an effect on timing. Inherently that's what await does.

I disagree, and I am speaking with the experience of having worked with actual code bases that use async/await in C# using UIKit (via Xamarin.iOS). In Xamarin.iOS code you can literally write this:

public override async void ViewWillAppear(bool animated) {
    base.ViewWillAppear(animated);
    await DoSomethingAsync();
}

Don't take my word for it. Here are the search results on GitHub.

More commonly, though, it's something like this:

button.TouchUpInside += async (s, e) {
    button.Enabled = false;
    spinner.IsAnimating = true;
    await DoSomethingAsync();
    button.Enabled = true;
    spinner.IsAnimating = false;
};

Note that TouchUpInside is an event with a return type is void, and it is called by code that doesn't have any idea that it is implemented using async/await. The caller may look like this:

private void HandleButtonClick() {
    if (TouchUpInside != null) {
        TouchUpInside(this, new EventArgs());
    }

I don't know why you would say this. It's actually pretty straightforward to reason about. In the example above with the button click handler it works like this:

  1. The button is disabled and the spinner enabled first, in the original caller's call stack.
  2. We call DoSomethingAsync. That method can either return immediately (on the same call stack) or suspend and return later.
    3.a. If DoSomethingAsync returns immediately then after the await we are still in the same call stack we started in. The button is re-enabled and spinner disabled, and the user is none the wiser (they never see anything change on screen).
    3.b. If DoSomethingAsync suspends then the continuation happens later after it resumes and returns. In between that time the UI thread will continue handling events, which will allow the button to update its appearance and the spinner animation to get started. Then when the continuation is called the button is re-enabled and the spinner is stopped. At this point you're on a different call stack, but still on the UI thread.

All of that is just baked into how async/await works in C#. It's actually not hard to understand if you get the general concept of await, and conveniently the "ending up back on the right thread" part is baked in so you don't even have to think about it most of the time.

Try writing something that elegant and simple using the model where you must write Task.runDetached or some variant. That's when it starts getting confusing because you're reintroducing the nested closures that async/await is intended to help you remove.

Again, I've worked on a decent amount of code written using this technique in C# using UIKit so I'm speaking with actual experience here. This model works well, and it has worked in practice for years at this point. There's strong precedent for it. I think ignoring that precedent or brushing it aside is a mistake.

2 Likes

I think maybe you ran right past my point.

  1. For this:

    override func viewWillAppear(_ animated: Bool) { }
    

    Swift won't let you use await inside the function.

  2. For this:

    override func viewWillAppear(_ animated: Bool) async { }
    

    Swift doesn't know how to call an async function from synchronous code.
    Swift won't know how to let synchronous code call an async function directly.

What's your 3rd alternative?

It should, though! It is entirely possible to implement that behavior. The only thing that can't be done is to have an async non-void method get called synchronously. Async void is just "call and forget". It can be done. It has been done in other languages.

Since we're talking about features that have not actually been approved, and in some cases have not even been proposed we are free to talk about alternative rules. That's what this discussion is about.

At the very least, what I would want is a function similar to Task.runDetached with this behavior:

  1. The argument is an async function.
  2. The given function is called starting initially on the same call stack as its caller (not via a dispatch_async or equivalent).
  3. If the given function completes without suspending then the call is in effect synchronous (control is returned to the caller, and nothing gets run later).
  4. The function automatically inherits the Executor of the function that calls it. That means if it's called on the main queue then any continuation resumes on the main queue. No extra decorations should be necessary for that to work.

To improve on that further, the hypothetical @asyncHandler decorator could wrap the entire contents of the decorated function in a call to the function with the behavior described above.

Alternatively, as an ideal (IMO) solution, just make async void methods work like that to begin with instead of having to jump through hoops. That is exactly what C# does. It works great. You don't need new functions to learn to use or new syntax to learn to apply. You just make the function async and use await directly. Why not (other than "that's not what was proposed so far")?

2 Likes

Just wanted to jump-in to point out a couple of things that might help clear things up a little bit:

First, please note that UI code in Cocoa/UIKit is already running in a runloop and we already know not to block it to keep the runloop and UI thread responsive. Although all these methods are synchronous right now, they are nothing like calling a long running synchronous function directly in main.swift. The code that is running in that context (such as viewWillAppear) can become async in principle. Whether Apple is going to support this or not, I don't know.

Second, the implementation of async/await in C# has some very important differences with Swift and you should be careful when comparing the two and we should note the differences.

Sure, I agree it could be done and, assuming it was done, that the sequential behavior of the newly-async viewWillAppear will be just fine.

The issue that I think you're not addressing is that viewWillAppear is stateful. It is a view controller state that exists between the call and the return. In order to associate the behavior with the state, you need to know when the return happens — not when the body of the function finishes executing.

For example, here is something that is meaningful today:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    if someCondition {
        doSynchronousStuff()
        self.needsCleanup = true
    }
}
override func viewDidAppear(_ animated: Bool) async {
    super.viewDidAppear(animated)
    if self.needsCleanup { 
        doCleanupStuff()
        self.needsCleanup = false
    }
}

It makes sense to begin some setup in viewWillAppear and finish it in viewDidAppear. Compare with an async variant:

override func viewWillAppear(_ animated: Bool) async {
    super.viewWillAppear(animated)
    if someCondition {
        await doAsynchronousStuff()
        self.needsCleanup = true
    }
}
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    if self.needsCleanup { 
        doCleanupStuff()
        self.needsCleanup = false
    }
}

Note that we didn't change the sequencing of what viewWillAppear does. It still happens in the right order. However, self.needsCleanup = true is now effectively detached (well, may be detached, depending on whether suspension actually occurs), and that makes it incorrect to test it in viewDidAppear.

The point I was trying to make in my earlier post was that, when we know from the source code where viewWillAppear will actually return to its caller, we can reason about viewDidAppear and other things that happen "next". We lose that ability if viewWillAppear is suddenly async.

That example may be too simple to be convincing, but it shows the general principle. We might, for example, have:

override func viewWillAppear(_ animated: Bool) {
    if someCondition {
        doSynchronousStuff()
        self.needsCleanup = true
        super.viewWillAppear(animated)
    }
    else {
        super.viewWillAppear(animated)
        doAlternateSynchronousStuff()
    }

(That is, we may want to call super at the end of the function sometimes.) You can't safely make this function async just by changing the synchronous stuff into awaited asynchronous stuff.

Again, this is not about sequencing the body of the function. It's about the relationship of the body of the function to the caller.

2 Likes

async/await in Swift doesn't exist today. Part of it has been proposed. Many parts related to it have not yet been even propose. So we're talking about what it should do, not what it does do (which is nothing yet).

I realize that things have been proposed that are different from what C# does. I'm asking why should that be? If what I'm describing above cannot be done in Swift then why not? Let's get specific. Then let's talk about what can we do to make it easier to use because what was described above by others as the way it will work is not good enough, IMO. It is too difficult to use and too error prone. We can and we should do better than that.

It is already well beyond mere proposal. I think some key parts are already decided and mostly implemented. A lot of things you are asking are higher level services that can be (will be) built on top of the core coroutine-based facility.

It feels like you're arguing that "it's possible to write bugs with async versions of these methods", which...yeah. Of course. It's also possible to write bugs with the non-async versions. If you're using fields as state to communicate between two method calls in UI code then that's brittle code, and there are many things you can do to mess that up, even without introducing async/await.

The fact remains that there are years of precedent of people writing code using a feature just like what I described, and overall it makes code simpler, not harder. The reality is most implementations aren't going to have that state in between will appear and did appear. In fact, most of the times you need this kind of feature isn't for a function like viewWillAppear, it's for something more like the button click handler. That's the real use case.

1 Like

If that is the case then why have an evolution process with proposals and discussions at all?

2 Likes

Unlike some Apple-specific surprises (SwiftUI), concurrency have been an active area of open discussions for several years and using coroutine-based async/await is mostly settled a long time ago well before this proposal.

I understand it is a surprise to newer members of the community. We need someone to gather and post these historic discussions somewhere.

2 Likes

I have been involved in the online discussions about this feature since it came up on the evolution mailing list years ago. I have been bringing up these issues every time it has come up for years.

It's not a surprise that it's being developed. It is a surprise if some people went and made a bunch of decisions outside of the community, implemented those decisions, and are now hoping for a rubber stamp for what they decided.

I have been watching the evolution forums for any async/await related discussion the whole time, since these forums have existed. At no point was there serious discussion on these forums or in the mailing list explaining why we can't have async void or why we can't have the kind of behavior I described above. If that was decided then it is something that some people decided on their own without asking the community what they thought about it. As I've said, there are people like me who have years of experience using async/await features with UIKit, and you would think those designing the feature would want to listen to those people when they say "what you have proposed has problems in practice".

On the whole I don't have a problem with going off and making a bunch of decisions as a way of getting to a concrete proposal to bring to the community and discuss. I do have a problem with the idea that "oh we've been working on this for a while and it's already implemented so the community will just have to live with what we're proposing". That's not how this is supposed to work. If what is proposed has problems then the community can and should reject it.

2 Likes

SE-0296 is talking about the basic low level facility to support non-blocking (async) code. It is also only discussing the language-level programming model, not the implementation details. When we talk about C# async/await, we think of the whole high-level facility with supporting types, library and runtime. They are not directly comparable.

The issues you bring up are relevant to the higher level facilities that are going to be built on top of this foundational proposal. The specific parts you are most concerned about regarding UIKit programming are completely beyond anything we will be doing in Swift evolution and it is Apple's decision how to integrate and use these facilities in their frameworks. As I mentioned before, they can make it work very much like Xamarin if they choose to.

  1. When it comes to "should async void be allowed, and if so then what does that mean" then it does relate to some extent to what has been specifically proposed. I have let that slide so far because other proposals are coming, and I want to see how those relate to my concerns. I did, however, bring up my concerns about evaluating SE-0296 independently of these other proposals because much of the important issues I'm bringing up relate to how async/await interacts with other features. As I expressed in the review, if async/await doesn't have certain behaviors covered by other specs then async/await itself should be rejected. If, for example, async/await doesn't behave well with UI code then async/await is a dangerous feature to include in the language. It has to meet that bar.
  2. In this particular discussion we're talking about things that presumably will come in later proposals. Now is the perfect time to discuss those things because presumably there is still time to fix things if they're wrong. If what is coming down the pipe is not going to meet the needs to UIKit users then it needs to be fixed. I don't want to wait for the proposals, bring up these issues, and then be told "it's too late to make those changes".

Nothing I'm describing belongs in UIKit. This is very much a question of "how should the language and the standard library behave". I absolutely don't want any of this behavior to only work for UIKit. After all, what I described for what C# does isn't an implementation detail from Xamarin. It's how the language and the framework works. The behavior async void and Task and synchronization contexts and all of that is a fundamental part of how async/await works in C#. Don't push the important bits up so high that the actual behavior becomes framework-dependent and unpredictable.

1 Like