Why aren't changes to @Published variables automatically published on the main thread?

Given that SwiftUI and modern programming idioms promote asynchronous activity, and observing a data model and reacting to changes, I wonder why it's so cumbersome in Swift at this point.

Like many, I have run up against the problem where you perform an asynchronous task (like fetching data from the network) and store the result in a published variable in an observed object. This would appear to be an extremely common scenario at this point, and indeed it's exactly the one posed in question after question you find online about this resulting error:

Publishing changes from background threads is not allowed

Then why is it done? Why aren't the changes simply published on the main thread automatically?

Because it isn't, people suggest a bunch of workarounds, like making the enclosing object a MainActor. This just creates a cascade of errors in my application; but also (and I may not be interpreting the documentation correctly) I don't want the owning object to do everything on the main thread.

So the go-to workaround appears to be wrapping every potentially problematic setting of a variable in a call to DispatchQueue.main. Talk about tedious and error-prone. Not to mention unmaintainable, since I or some future maintainer may be calling a function a level or two or three above where a published variable is actually set. And what if you decide to publish a variable that wasn't before, and now you have to run around checking every potential change to it?

Is this not a mess?

  • As my work continues, I found that the MainActor solution is not a panacea because you can't make a Codable/Decodable class a MainActor. Often you need your data-model classes to conform to those protocols, and those classes are often full of published members to support display in SwiftUI. So it's back to hunting down all possible manipulations of them and enclosing them in a main-thread dispatch.

Add a Comment

Accepted Reply

further research shows that it does not necessarily execute on the main thread.

Right.

It takes a while to grok the Swift concurrency model. My favourite resource for this is Doug Gregor’s ‘sailing on the sea of concurrency’ talk from WWDC 2022. There’s a link to this and other resources in my newly minted Concurrency Resources locked post.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Replies

It’s hard to answer why question — see tip 3 in Quinn’s Top Ten DevForums Tips — but in this case I view it as a matter of timing. SwiftUI predates Swift concurrency and thus it’ll take a few releases for all the gears to align. Indeed you’re already starting to see that with the new Observation framework, which avoids the need for @Published entirely.

As to your actual problem, my general advice is to choose your concurrency strategy and follow where that leads:

  • If you’re using Swift concurrency, annotate your model object with @MainActor, enable strict concurrency checking, and fix all the problems that the compiler highlights. This is the long-term path forward because it leads to a world where most concurrency problems are caught by the compiler.

  • If you’re using Combine, learn to love the receive(on:options:) operator.

  • If you’re not using either, stick with older paradigms and accept that you’ll need to use Dispatch.main extensively.

making the enclosing object a MainActor … just creates a cascade of errors in my application

Right. That’s kinda the point. Assuming your enable strict concurrency checking, which you really should, the compiler will start flagging problems in your concurrency architecture. The majority of those are concurrency issues that you need to think about and resolve [1].

but also … I don't want the owning object to do everything on the main thread.

That very much depends on what those operations are. IMO the key thing here is to isolate model transformations from your model. That has two benefits:

  • It makes your model passive, which simplifies development (using #Preview) and testing of your views.

  • It creates a natural place for you to introduce more concurrency, typically in the form of actors.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] But not all. There are still some holes in the Swift concurrency story. Some of those are situations where you as the app developer are waiting for your dependencies, frameworks and libraries from both Apple and third parties, to ‘catch up’. Others are situations where the Swift concurrency model needs further work. For example, when I switch into ‘full concurrency’ mode I regularly bump into the limitation described in SE-0371, and that’s still being worked on over on Swift Evolution.

  • Thanks a lot for your reply!

Add a Comment

Thanks for that info @eskimo !

I don't consider concurrency to be the big issue in what I'm attempting to do. Yes, concurrent things are happening, but I'm reasonably comfortable with where they are. I just want to make SwiftUI work.

My model is passive, but it is not observed and manipulated by the UI directly. Between the two I have a controller (or "viewmodel" or whatever you want to call it), and the UI observes things in it and then conveys the user's intent through the controller. This is the arrangement I've seen recommended by the vast majority of materials on the subject.

The controller understands the model, kicks off potentially long-running async processes (which is why making it a MainActor seems unwise), and then updates things that the UI is observing (in itself). Otherwise, are we to couple the UI to both the model and to a controller?

Further experiments reveal more shenanigans. If I make the model a MainActor, I can't access any member of it without "await." This means even examining a member variable must be couched in "await." Not exactly tidy.

After more reading, I thought that maybe my concerns about making the controller a MainActor were misguided, because I can mark long-running functions async and await them, so they don't block the main thread even though they're in a MainActor... right? So I took @MainActor off the model and put it on the controller.

Do I need to worry about starting long-running processes in a MainActor if the processes are marked async?

Do I need to worry about starting long-running processes in a MainActor if the processes are marked async?

It depends on the nature of the work:

  • If the work is CPU bound then, yes, you should worry. Your main actor code runs on the main thread and if it ties up that thread doing computation then you’ll experience UI hitches.

  • OTOH, if the work is I/O bound that’s not an issue. For example, if your main actor code runs a request in a URLSession, it’ll await that result. That await is a suspension point and, while the request is running, the main thread is free to do other work.

Now, I’m using I/O bound is a very specific way here, namely, to describe an I/O operation where the async function awaits the result. URLSession is a great example of that because it has good Swift concurrency support. That’s not true for all I/O subsystems. For example, the file system has very limited Swift concurrency support so, for example, if the goal is to copy a large file then it would be bad to do that on the main actor.

Oh, and in this context you can think of IPC operations as I/O operations.

If you’re using an I/O subsystem without good Swift concurrency support, you have two options:

  • Move that work to an actor that’s not the main actor.

  • Use Swift concurrency’s continuation support.

Both let your main actor code await the result, which bring you back to the I/O bound case described above.

However, this is not without its challenges. To start, in both cases you really want to support cancellation. Without that, the structured concurrency paradigm falls apart.

The second challenge is specific to actors. The Swift concurrency runtime has a pool of threads that it uses to run all async code. It’s easy to exhaust that pool by blocking threads using traditional concurrency primitives. For example, let’s say you wanted to copy 10 files. You might think to parallelise that by creating 10 actors and running a file copy in each one. That’ll end badly because each actor ends up blocking in a traditional concurrency primitive (like a BSD read call). The end result is that exhaust your Swift concurrency thread pool, with all the threads stuck inside file copy operations.

Striking the right balance parallelism balance in the face of I/O that’s fundamentally synchronous is one of the ongoing challenges of Swift concurrency. Then again, there wasn’t a great solution for this with Dispatch either )-:

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks again @eskimo . I have revised my code and have it working, with both model and controller as MainActors.

And most of the work I'm doing does indeed boil down to a URLSession, so that's good per your remarks.

Now... when I do want to move work off the main actor (but instigate it from said actor), what is the recommended way to do so? If I call another actor from a main one, does that call not execute on the main thread?

Never mind the last question; further research shows that it does not necessarily execute on the main thread.

further research shows that it does not necessarily execute on the main thread.

Right.

It takes a while to grok the Swift concurrency model. My favourite resource for this is Doug Gregor’s ‘sailing on the sea of concurrency’ talk from WWDC 2022. There’s a link to this and other resources in my newly minted Concurrency Resources locked post.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"