Architecting apps for robust async/await

Hi!

I have a little experience with different concurency approaches in languages, and mostly used Dispatch on Apples’ platforms. With new features in Swift I am now wondering how to design apps keeping in mind new concurrency concepts.

Since primarily I am writing in Apples’ ecosystem, the current approach I have adopted is to wrap everything in tasks at the topmost level to have a better control and understanding where I am going to call everything.

It seems to be working OK, but I am concerned that at lower levels I have classes with sets of methods marked as async, which potentially could be called from any thread, and these classes are not thread-safe, and they should not be such generally. Back then when I have everything working via dispatch queues, everything were not thread-safe as well, but it was much harder to eventually run something on a different threads, which were addressing most of the concerns.

I was looking into an approach where concurrency are defined only at the top level completely, meaning I do not mark anything at the so called “business logic” layers as async, working mostly with synchronous code. But I am wondering if it worth to try that direction and how then address work with shared mutable state, which inevitably pop up somewhere. Should I worry at all that this classes are not thread-safe?

I would really appreciate any advices on that matter, or resources (publications, books, repos, etc.) addressing this topic, since it is a new world for me and I am trying to define how to work with that better.

Thanks!

2 Likes

My own experience is that it’s much easier to predict where an async call will continue execution with Swift Concurrency rather than with closure based callbacks. This can be achieved using actors or global actor annotations.

And if you have classes that are not thread safe, perhaps they can be made that by annotating them with @MainActor or by converting the classes to actors instead.

1 Like

One of the most common things I’ve noticed people struggling with in swift concurrency is that it flips which of caller and callee decide where things run relative to libdispatch.

Rather than Tasks calling synchronous methods (i.e. making Swift Concurrency pretend to be DispatchQueue.async), you most likely want async methods that declare where they run (either by being on an actor or via a global actor annotation).

If most stuff is async already, then you don’t need to use Tasks to hop into an async context, so there’s less boilerplate as well.

2 Likes

Thank you for responses! Maybe I didn't make myself clear, since I am not trying to turn synchronous code into asynchronous, so I will give a small example to better illustrate my question.

Let's say there is a class to do manipulations with the data, e.g. some list of strings. And it, of course, provides methods to do so. These methods are already asynchronous, since they working with other elements like networking or file system (which are more likely to be represented by actors):

class Content {
    private(set) var items: [String] = []

    func load() async { 
        items = await fileSystem.read()
    }

    func update(at index: Int, newValue: String) async throws {
        try await fileSystem.write(newValue, at: index)
        items[index] = newValue
    }
}

The class itself is not thread-safe, I can easily call load and update from different threads and have crash due to modification of items from different threads. And it do not needed be thread-safe by design, it is perfectly OK that there would be a crash in such scenario. My intention here is to ensure by design that it would be hard to eventually call them from different threads, but not restrict by making an actor.

Or, maybe my intention is just wrong in async/await and I need to look another direction, but do not quite understand which one, yet.

When it comes to apps, the fundamental thing that you want to keep in mind is that any data which the UI needs must live on the main thread (i.e. be @MainActor-isolated). The UI should never have to wait for data it needs it render.

Unfortunately, right now none of Apple's UI frameworks have comprehensively adopted @MainActor annotations. Perhaps contrary to expectations, SwiftUI is the framework that is most lacking in this regard -- simple closures such as Button's action callback or gesture callbacks are not annotated as @MainActor (even though they absolutely will always and only be called on the main actor), meaning that they cannot safely and synchronously manipulate data required by the UI. Even something as simple as a button which increments a counter cannot be safely expressed in SwiftUI as things stand.

Currently, basically the entire Swift ecosystem on Apple platforms is relying on a compiler bug, which causes the compiler to forget to check @MainActor annotations when expressed using closure syntax. It has been like this for several years now. My biggest hope for the upcoming SDK releases is that Apple will comprehensively audit and annotate closures which are known to be invoked on the main actor, and that it will allow the compiler bug to be fixed so we can actually have consistent static enforcement of global-actor-isolated data.

Until that happens, I'm sorry to inform you that Swift concurrency is not really usable as intended for UI applications on Apple systems. You will see inconsistencies, and frameworks will appear to be basically unusable by the rules that you know, and you (or your team members) will naturally doubt their understanding as a result of that. Let us hope that the situation improves.

3 Likes

I have noticed that SwiftUI much less enforces @MainActor marks compared to UIKit, which in general is an upsetting point, but I'm hoping that they cared of that internally at least (while static enforcement would be definitely better, I suppose internal implementation of SwiftUI introduces complications to do that).

However, despite that insight on the @MainActor in Apples' SDK, when it comes to UI I haven't faced issues with main thread execution. By extracting every action into a protocol, marked as @MainActor, and ensuring state of the view being updated from the main thread by dispatching it at the single point, everything works fine.

As for the topic of general async/await usage, in the way I have implemented it for now I am not facing issues or accidental crashes (despite the ones not from concurrent access :slight_smile:), it just feels not quite right or missing some part, and easy to break, especially for less experienced developers.

Static enforcement is (broadly) the main advantage of language-integrated concurrency. Of course, if you manually check that everything which needs it does run on the main actor, you won't encounter data races - but in that case Swift concurrency is doing nothing for you; you could do that in Objective-C.

async/await allows you to more easily work with data across various concurrency/isolation domains - some of it may be @MainActor, while other parts may be owned by a background thread or a custom actor instance - but that doesn't mean you should always split everything up in to the smallest possible units of concurrency and throw await and Task { ... } around too liberally. Again - things which the UI displays should live on the main actor, and the majority updates should happen synchronously.

The main issue that I have found with regards to architecture is that, when the system behaves inconsistently, it becomes impossible to talk about architecture because less-experienced developers don't feel they understand what is going on. You want to start by introducing principles such as "any data which the UI needs must live on the main thread", but they can trivially defeat that, and it requires a certain amount of experience and confidence in your understanding before you are even willing to entertain the idea that perhaps the compiler and/or frameworks are the ones at fault.

For example:

struct MyView: View {

  @MainActor
  class ViewModel: ObservableObject {
    var value: Int
    init() { self.value = 0 }
    func increment() { self.value += 1 }
  }

  @StateObject var viewModel = ViewModel()

  var body: some View {
    VStack {
      Text(String(viewModel.value))
      Button(
        action: { viewModel.increment() },  // <---
        label: { Text("Increment") }
      )
    }
  }
}

The button's action closure is not annotated @MainActor, and yet we're able to access main-actor-isolated data and functions without so much as a warning. This applies to essentially every closure in SwiftUI.

To illustrate that something is going wrong here, and that truly our understanding is correct, let's replace the action closure with an unapplied method reference. This should be exactly equivalent to the above, except that now we do get a warning:

  var body: some View {
    VStack {
      Text(String(viewModel.value))
      Button(
        action: viewModel.increment,  // Warning: Converting function value of type '@MainActor () -> ()' to '() -> Void' loses global actor 'MainActor'
        label: { Text("Increment") }
      )
    }
  }

It is difficult to express how much of a roadblock this is for many developers - it seems like the first version "works", but really the compiler isn't checking things properly, and when you try to show them that, they tend to get very confused (because it's a bug; of course it doesn't make sense). If you finally get them to accept that, the only conclusion is that SwiftUI is basically unusable with Swift concurrency, which is also difficult for many to accept. Surely that can't be the case -- they must just not understand it, right?

They were told back at WWDC 2021 that their ObservableObjects should be annotated with @MainActor, and yet if you do that, basically SwiftUI doesn't work (unless you rely on this compiler bug, in which case anything that works is a coincidence). In the years since, it has not been fixed. Fingers crossed that 2023 will be the year...:crossed_fingers:

image

That's why I say it's "unusable". IMO, architecture patterns are basically moot until the system achieves a baseline level of consistency which it has not yet achieved.

I don't mean to vent here (I hope it doesn't come across like that). Swift concurrency is very nice and exciting, and I have hope that it will one day achieve its aims -- but we're not there yet, and so I think it is not really possible to experiment with interesting architectures that leverage it.

3 Likes

Yes, there are many things to improve, the concurrency checks are not quite deep; have a look:

func foo() {
    struct DispatchQueue {
        static let main = DispatchQueue()
        func `async`(execute: @escaping () -> Void) {
            Dispatch.DispatchQueue.global().async(execute: execute)
        }
    }
    DispatchQueue.main.async { // not `Dispatch.DispatchQueue.main.async` but something else
        ViewModel.shared.increment() // no warning
    }
}

Here I am changing DispatchQueue.main.async to something else to bypass the check, and it seems compiler just makes a literal string comparison with DispatchQueue.main.async when it decides whether to show the warning or not.

Having said that, while the absence of 100% proper static concurrency checks is an inconvenience, it is not a show stopper. You can compensate by having manual runtime checks (dispatchPrecondition) – not so good but better than nothing. As a rule I put dispatchPrecondition with either .onQueue(.main) or .notOnQueue(.main) or, if the code in question needs to run on a given queue – .onQueue(queue) at the very first line of almost every function. Once we have better static concurrency checks in the language I can remove those dynamic preconditions.

I cannot agree to that. Main thread checks is only a part of the whole mechanism. The are still better readability, actors isolation apart the main thread, Sendable checks, etc, so I’d say there are a lot of benefits anyway.

The only thing right now you have to ensure is updating view state from the main thread, and even get static checks 80% of the time and 99% sure that code would be called from the main thread anyway. For the rest there is a main thread checker.

As it has been said, I do not see main thread checks here to be significant issue in Swift concurrency adoption.

I appreciate overall discussion, but this is what I am mostly curious about. Right now I have a lot of classes that just declare async methods here and there, as in example in my previous post. This classes do not meant to be actors, I feel this in an overkill to make every such class to be an actor, as much as marking all of them to be a part of some global actor (especially main-one). However, you are third in this thread suggesting to do that, and I do not understand what is the approach here.

Take Apple’s WWDC video you’ve shared. They’ve made some object to work with photos, where 3-4 methods are declared and all they are async. Finally, they are marking it as @MainActor, since this object would be directly used by SwiftUI. Except the last part, this is how the most classes are look like in my code right now. When it comes to UI presentation models (plain structs of data) being generated and updated at the main thread using generic wrapper.

If we extrapolate WWDC’s approach, then I could have tens of classes in the app following that pattern and explicitly require main thread everywhere. But why I want that at all? I do not want all my classes to be on the main actor, I do not want to care for most of them where they would be called at all, and to delay that decision as much as possible. It would be much nicer, as far as I understand from design perspectives, to be able to control that at some point, but not getting too specific with each class.

I really appreciate notes on UI issues on platforms and will take a look at this once again, for now I’d better abstract from the current issues that exist, and focus more on how it is supposed to be in async/await world.

But why not, really? :thinking: just make load and update functions return items.

Ah, a bit hard to understand problem without reading a code.
In my experience this issue is usually solved by splitting state and state changes of those classes.

UPD.
For me personally TCA is doing great for an app architecture with modern concurrency.

What for? The class is not meant to be thread-safe, I do not make some parallel computing here, so this is just a small piece of logic. And to that small piece I would bring:

  1. Additional complexity of managing the rest of the code which interacts with this actor.
  2. Runtime overhead for actor isolation.

Taking the previous example class Content, imagine I'll eventually run its methods concurrently:

try await withThrowingTaskGroup { group in
    let content = Content()
    group.addTask { await content.load() }
    group.addTask { await content.update(...) }
    try await group.waitForAll()
}

The example is made-up, you clearly won't write on purpose that in real code, but since every async method should be called inside some Task, you can eventually do that in much less obvious way, resulting your not thread-safe class mutated from different threads. And this is the problem I see when there are just a lot of methods on classes (imagine there are 20-30 classes like that) marked as async. That is why I also does not feel right to make all these 20-30 classes to be actors.

Could you please explain it in more details?

Yes, with their recent discussion over testing async code I am now taking a look at what they choose as way to deal with the concurrency.

If you want to ensure that a class cannot be used from multiple threads, I believe it is sufficient to not mark it as Sendable. When full strict concurrency checking is enabled (either in Swift 6 or by turning on the option), I believe this should ensure that your class cannot escape its current concurrency domain.

Would that achieve what you’re looking for?

2 Likes

Feel like I need to re-read Sendable proposal once more... Thank you, that is definitely have a point for my concerns on concurrency right now.

But functions are anyway marked as async.
Agree, that making everything actor like overkill. Just imagining and speculating a bit. :upside_down_face:

I've got the problem overall, but still gets a bit hard to understand example. Like:

Why? I guess not to fire from main thread? But then you anyway want thread safety from my point of view. Was it some queue.sync before?
And even without marking async content.load and content.update could be fired from those 20-30 classes, potentially. So the problem was always there? :thinking:

Ah, just speculating. In example above Content shouldn't be aware of fileSystem, so somehow will start splitting it. Probably? :slightly_smiling_face: Again, example is too generous.

Yeah, it's good, just be aware it's more of functional approach. You can also check Elm architecture from which all of those are inspired.

1 Like

A bit off topic, but Evan's thesis on Concurrent FRP for Functional GUIs is also a good read. :slightly_smiling_face:

1 Like

Yes, the “problem” was always there, you are right, it only become more obvious and (as at least I seems to me) harder to avoid. However, with note on Sendable above I got thinking that I am overthinking this :slightly_smiling_face: Swift Concurrency is meant to be safer approach than what we have had before, and bring more compile-time checks, so the code should be safer after all.

Definitely it is abstracted in real-world cases, this is just abstract example to make it clearer that it have some asynchronous access in the implementation.

Thanks!

Just a little update: suppose I've figured out my concerns taking another look at Sendable details and getting warnings for Swift 6 checks correctly enabled across the whole project. And all the thread-safety issues that were concerning gone away when I've got concurrency warnings at these places as it supposed to be. Thanks!

1 Like

In my experience the Async/await system takes a lot of thinking since it LOOKS so much like other concurrency primitives, but behaves very differently. The session last year on islands as actors and ships as tasks helps. Currently actors protect only their directly owned data, meaning no reference types, and appear to not handle all cases of async functions declared on them, in particular no guarantee an Async function completes before another starts. So, recommendation: only use value types in actors, and only synchronous functions. Do not approach an actor as an “object” with local business logic. Program as if in a functional language and put all logic in tasks or functions only called from tasks.

1 Like