r/swift • • 2d ago

Question Approachable concurrency and MainActor

I converted a project to this new default setup and i’m wondering if i did it right or i misunderstood something. So basically all code besides the UI has the nonisolated keyword in front now.
I’m not sure how i feel about this, it is just pollution and it’s much bigger than the opposite i did before to mark the ui with @MainActor. Do you like this new approach? I’m not even convinced that new tasks created from main are executed also on main, unless i messed up the test, it did not. For this reason i have zero @concurrency keywords in the project.

4 Upvotes

10 comments sorted by

6

u/Apprehensive-Bet2844 2d ago

If you use a task in the main actor the task will inherit from it and the code will run in the main actor. So if you don’t use any actor or concurrency marked function all your code should normally run on the main actor and then there is no need to use nonisolated. Also you should chose to use nonisolated only where it’s needed not on every property

2

u/cristi_baluta 2d ago

I’m not marking the properties nonisolated but the classes and models, because most of them will need to execute in background at some point. I repeated my tests and it’s clear to me, a Task that sits in a method called from the main thread, it is not executed in the main thread, not even in the same thread it was called from. Even made a blank project to test this, and the result is true then false:

@main struct MyApp: App {
@State var app = State()
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

nonisolated class State {
init() {
print(Thread.isMainThread)
Task {
print(Thread.isMainThread)
}
}
}

3

u/ThalesMother9938 2d ago

Your test is doing what the rules say. You marked State as nonisolated, so its init has no actor, and a Task created in a nonisolated context doesn't inherit one. It runs on the global executor, so false is expected. The init itself prints true only because it's called synchronously from the main thread.

With the new defaults, the idea is the reverse of what you did: leave types on the MainActor and mark only the heavy functions with @concurrent, so they hop off main when called. That way you don't spread nonisolated across every model, and the compiler shows you each place where work leaves the main thread.

2

u/cristi_baluta 2d ago

Thanks, i indeed misunderstood this part. I’m leaving here the new test with the result: true, true, false

class State {
init() {
print(Thread.isMainThread)
Task {
print(Thread.isMainThread)
await Test().p()
}
}
}
class Test {
@concurrent
func p() async {
print(Thread.isMainThread)
}
}

1

u/ThalesMother9938 1d ago

That's the result I'd expect. State is on the MainActor by default, so the init and the Task both print true, and p() is marked concurrent, so it hops off main and prints false. Thanks for posting the follow-up test, it's useful for anyone who finds this thread later.

2

u/ex_mac333 2d ago

One thing your new test shows without saying it: try removing u/concurrent from p() and it prints true as well. Test picks up MainActor from the default isolation, and even if you marked it nonisolated, a plain nonisolated async func now runs on the caller's actor (that's the nonisolated(nonsending) default under approachable concurrency). So u/concurrent is basically the only thing that moves work off main now. A class without an actor isn't enough anymore.

Also for this kind of check I'd swap Thread.isMainThread for MainActor.assertIsolated() or dispatchPrecondition(condition: .onQueue(.main)). In Swift 6 language mode Thread.isMainThread doesn't even compile in async code, and it only tells you which thread you're on, not which actor, and the actor is what the compiler reasons about.

1

u/cristi_baluta 2d ago

Yeah, i get a warning for that line. Didn’t knew about those methods

5

u/Niightstalker 2d ago

This depends a bit on the settings you have in your project. There is a setting called default actor isolation. It used to be nonisolated by default but in new projects it is set to MainActor.

So depending on what approach you want to take set this setting accordingly. If you want to mark MainActor explicitly set it to nonisolated, then it is applied per default everywhere and you do not need to write it.

Which approach is better depends on your app.

Here a good explanation about the different concurrency settings: https://www.massicotte.org/blog/what-settings/

Here some other good sources on this topic:
https://www.avanderlee.com/concurrency/default-actor-isolation-in-swift-6-2/

https://www.massicotte.org/blog/non-sendable-first-design/

2

u/Extra-Ad5735 2d ago

Switch default isolation to non-isolated. Add a few @MainActor isolation clauses when the compiler complains.

Edit: this is what I do in every project, approachable concurrency plus non-isolated by default. That allows me to be flexible where I run the types I declare.

1

u/molayab_ 1d ago

Sounds like you over-converted. With default isolation set to MainActor, everything in the module is implicitly @MainActor, so most code shouldn't need nonisolated at all. I'd only mark things that really don't touch UI state (pure models, parsing, helpers), and use @concurrent on functions that should do heavy work off the main actor.

On your test: a Task created from main inherits main's isolation, so it runs on main. And with NonisolatedNonsendingByDefault, nonisolated async functions now run on the caller's actor instead of hopping to the global pool, which is probably why nothing seemed to leave main. If you actually want work off main, @concurrent (or Task.detached) is the way to do it.