Why is await unsupported in a non-async function with Void return type?

Screen Shot 2021-06-08 at 5.19.34 PM

I didn't follow why the above example in WWDC21: Explore structured concurrency in swift (at 20:00) is disallowed, and you have to manually wrap the await code in an async { } block.

Since func collectionView(_:willDisplay:forItemAt:) returns no value, couldn't the runtime return control when it hits the await, and then resume again on the main thread since it's marked with @MainActor? What does an explicit async do here that the compiler couldn't already infer?

Apparently … you can’t call “await” outside of a @Sendable closure. (Or outside of another async method).

async {
}

Every sendable closure is actually owned by a Task when it’s running. And every “await” inside that Task creates a child task. This task tree is used to make error handling and “throwing” inside a concurrent code work automatically, with unfinished child tasks automatically canceled on errors.

Also the compiler will enforce that shared reference objects use the Sendable protocol (like actors) so that the compiler can prevent incorrect mutation of shared references.

Hence in swift 5.5 … code “inside” async is being compiled and executed differently than code outside (legacy swift code).

There a lot more going on inside that “async{}” notation that it seems.

We had experimented with a design where void-returning non-async functions could await async calls if they were marked with the @asyncHandler attribute, and they would behave as you described. But in adopter feedback, we found that developers found it hard to reason about exactly what part of their code would run at what point, especially if there was conditional control flow or loops involved. There are many places in AppKit and UIKit where it is very important to know what part of the code runs immediately before returning control back to the framework. So we instead designed the async API as a way of explicitly launching a task that's still related to its enclosing context. This is both clearer, because it makes the part of the code that runs later explicit, and more flexible, because you can use async anywhere in your code, including in sync functions that return an interesting value, not just Void.

8 Likes

Perhaps the example here is too simple to see where the flow would be confusing, since with the await there I would expect it to behave just as if this func were marked async, and am more confused about the need for this wrapper.

1 Like

I feel one of the issues with adopter feedback is that is not from people in the trenches having to write day-to-day make-it-work-with-what-you-have code.

Being able to call async functions from sync code is no brainer for me. I can't count the number of times I needed to do that (use async from sync) without being able to refactor/rewrite the whole thing to use async all the way up the stack.

1 Like

It's not generally safe* to call async code from synchronous paths, as waiting for that work to complete may have unanticipated and unknown side effects.

In the original example at the top of the thread, async work is performed in an implementation of collectionView(_:willDisplay:forItemsAt:). Blocking that call while fetching thumbnails seems like a bad idea, as it can be called many times a second, depending on how quickly the user is scrolling. The most appropriate implementation is similar to what you had to write before Swift had async-await: start the asynchronous work but allow the method to complete, calling back to update the cell when necessary. Or really, when the cell is actually dequeued, pick up the completion of the image and update the internal views appropriately. We can do that now using let task = Task { await fetchThumbnails(for: ids) } and storing the task somewhere for the cell to pick it up later, if necessary.

So for that reason I'm not sure there can be a general solution to this issue. I do wonder if we could do something for synchronous code running within an actor, like the MainActor, since we're guaranteed to have an underlying executor. I'm just not sure any solution can be made generally applicable, and if it isn't, it's probably not a good idea to make it generally available.

* I use safe here, but we should really have a better word for things that aren't memory safety, so as not to conflate this safety with Swift's general goal of memory safety.

1 Like