Question about SwiftData and Concurrency interoperability

Hello all,

I’m running into a weird problem. When downloading large amounts of data from an API (~7000 items), and then saving it to SwiftData, I get hangs in Profiler. I’ve already tried all kinds of things in regards to concurrency: right now I have an actor that downloads the data asynchronously, and use a ModelActor to save the data. I have tried using a detached task, setting the priority of the task to background, and in my last attempt I tried using a TaskGroup, all to no avail. From what I understand it should be possible to download the data on a separate thread, and SwiftUI would be able to update a List with an @Query from the main thread. Have I misunderstood something, or is there maybe something I forgot?

I have created a sample project, and would really appreciate if someone could have a look and point me in the right direction.

Thanks in advance

1 Like

Hi @thedan84 , I checked out your app and it turns out that you need to wrap your try await db.saveFeatures(collection.features.map(\.properties)) in FeatureStore.swift inside of a detached task with a priority of .userInitiated. This will help to resolve the hang in your app. This is likely being caused by this saveFeatures method eating up your main thread, as the Time Profiler tool shows this, and one way to fix this is by spinning off a detached task and doing this work on a background thread as detailed in Determining execution frequency.

1 Like

This is likely being caused by this saveFeatures method eating up your main thread, as the Time Profiler tool shows this, and one way to fix this is by spinning off a detached task and doing this work on a background thread as detailed in Determining execution frequency.

Looks like parts of that document are a bit outdated. Specifically:

Alternatively, you could make the countEmoji function asynchronous and nonisolated, which enables Swift concurrency to execute it on the concurrent thread pool.

@MainActor
func updateDisplayedText(_ newText: String) async {
    self.text = newText
    let newEmojiCount = await countEmoji(in: newText)
    updateEmojiCount(newCount: newEmojiCount)
}


@MainActor
private func updateEmojiCount(newCount: Int) { /\* ... \*/ }


nonisolated 
private func countEmoji(in text: String) async -> Int { 
        /\* ... \*/ 
}

private func countEmoji(in text: String) async -> Int should be marked as @concurrent, not nonisolated.

Hi @asaadjaber Thanks for taking a look and providing me with a solution. :slightly_smiling_face:

1 Like
Discussion about SwiftData performance including Self Promotion

For a very different POV on the earthquakes sample project and also a discussion about working around the performance problems with SwiftData you can try our free ImmutableData project which builds a unidirectional data flow abstraction layer on top of SwiftData.[1]

A lot of this philosophy is also shared from the earlier Laws of Core Data essay from @davedelong which promoted an abstraction layer on top of your ORM of choice.

One nice side effect of this approach is that if your view components are decoupled from the actual implementation details of the specific ORM you choose it makes it a lot easier to swap out those ORMs in the future? Want to go back to Core Data? Want to try raw SQL? You already have an abstraction layer there for you. Building components from Query on SwiftData might look like you are moving fast… but if you decide that SwiftData is not the right tool you have a lot more work to do to migrate all those components over to your new database.


  1. ImmutableData-Book/Chapters/Chapter-10.md at main · Swift-ImmutableData/ImmutableData-Book · GitHub ↩︎

Out of curiosity, why was it so? fetchFeatures was called in MainActor:

    func fetchFeatures(modelContainer: ModelContainer) async throws {
        let collection: FeatureCollection = try await loader.load()
        let db = FeatureDB(modelContainer: modelContainer)
        try await db.saveFeatures(collection.features.map(\.properties))
    }

saveFeatures was defined in FeatureDB actor. I don't use SwiftData, but based on these information, I think saveFeatures should run in cooperative thread pool, instead of main thread. If so, how could it keep main thread busy?

1 Like

Also… ModelActor is Just Weird sometimes.

Really what SwiftData seems to be missing here is something like NSBatchInsertRequest. This shipped in 10.15 Catalina… which was almost fifteen years after Core Data launched in 10.4 Tiger. This would be a big performance improvement for this specific problem if it shipped.

A for-in loop that inserts n different models in a SwiftData context using this current API performs orders of magnitude more work than would be needed to add those data models in an immutable collection like a Dictionary.[1] If you needed to copy-on-write your Dictionary for every iteration then maybe then that starts to add up… but at that point the TreeDictionary might be the better choice for the log n copy-on-write operations.


  1. ImmutableData-Book/Chapters/Chapter-19.md at main · Swift-ImmutableData/ImmutableData-Book · GitHub ↩︎

1 Like

I haven’t read the entire proposal yet, but SE-0316 describes the main actor as “a global actor that describes the main thread”. Further, the WWDC video “Protect Mutable State with Swift Actors” states:

…the main actor performs all of its synchronisation through the main dispatch queue. This means that from a runtime perspective the main actor is interchangeable with using DispatchQueue.main.

So my presumption is that because saveFeatures is called in a function which is itself declared inside a class that itself is annotated @MainActor, then saveFeatures is bound to run on the main thread.

Determining execution frequency recommends a strategy that precisely remedies this:

As an alternative to doing less work, you may be able to change your app to execute the same work in a background thread.

So by running saveFeatures within a detached task, that accomplishes precisely this.

Ouch, that is a mean gotcha I would also potentially have run into.
Now that I've read the blog post @vanvoorden helpfully linked I have to begrudgingly admit that yes, indeed something being "an actor" does not say much about where code is executing, but only that it isolates its state... But of course when I declare actor MyActor, I would automatically assume that it does not in fact use the same executor as the @MainActor... were I to desire that behavior, I could as well just use @MainActor final class MyClass

That being said, while a batch insert surely is an important feature, I'd also say that a means to explicitly perform storing operations off the main actor would be nice. I.e. something like in CoreData, where we can specify a context to specifically use a private serial queue.
With SwiftData I don't think that binding this directly top the ModelContext type would be good, but since the ModelContainer already provides a mainContext property (similar to CoreData's NSPersistentContainer's viewContext) it would be nice to have a way to specify context objects that are not "main".

I would have expected the @ModelActor macro to perhaps provide a parameter similar to NSManagedObjectContext.ConcurrencyType from CoreData, at least.
The blog article makes me guess that under the hood the NSManagedObjectContext that's surely created somewhere when using this macro is initialized with the according value of that type (.mainQueue or .privateQueue), depending on whether the initialization happens on the main actor or not. Perhaps this happens in the DefaultSerialModelExecutor, who knows.

I agree with the blog post that this is odd. It mimics the old CoreData distinction, but why does it require on runtime information? It would be useful to specify this during compile time...

Has anyone already made a feature request in that regard? If not, what would be the best approach for this, extending the macro, or perhaps doing something completely different (e.g. extending ModelContainer somehow)?

1 Like

This is true, but it's a property of how MainActor is implemented, not actors in general. MainActor uses a custom unownedExecutor which is a reference to the platform's main executor. So other actors can't execute on the underlying resource. For two actors which don't customize their executor, it is possible for them to be run on the same underlying abstraction, if only by coincidence, depending on the runtime.

Yes, I know, my point was more that usually, using a specific custom executor is a very visible, deliberate choice (i.e. you see this in code), and then using the same executor as the main actor would be even more specific (and could pretty much also be achieved by isolating a class to the main actor).

So while it makes sense that the ModelActor macro somehow messes with this to achieve the underlying CoreData mechanic of serializing background write operations (it's pretty clear that the private queue NSManagedObjectContexts can use is a serial one, in effect that makes a kind of FIFO for operations), I think that

  1. It should do that more explicitly with a parameter or something
  2. It should do that during compile time and not, as the blog post illustrated, somehow during runtime.

I don't get it. If the purpose is to implement a FIFO queue for database operations, why not implement the custom executor by using a serial DispatchQueue? From what I read this is the typical way to do it.

@asaadjaber The behavior you described applies to nonisolated functions. In OP's code, however, the method is defined in a user defined actor, so it's usually supposed to run in cooperative thread pool. As explained in the blog post linked by Rick, the behavior is because the actor uses a custom executor.

Yes, that's exactly what I mean. I don't get why they didn't implement it like that either. Even less considering SwiftData presumably builds on top of CoreData, where a similar behavior is already present (though on NSManagedObjectContext and not on a higher level like here).
The new ModelActor protocol relies on a helper type called DefaultSerialModelExecutor and from the blog post that seems to use the main queue when created on the main actor and something else.

My hunch is that what actually matters is in fact the ModelContext object that is passed to it, because that is basically the equivalent to NSManagedObjectContext (and I assume it uses it under the hood): NSManagedObjectContext has, in its constructor, the option to either use the main queue or a private queue. It could be that when a ModelContext is created, it automatically choses either the former or the latter to create its underlying NSManagedObjectContext instance, depending on whether it runs on the main actor or not. ModelContext itself does not expose this option, but it would explain the observations made in the blog post.

Ultimately that results in the ModelActor kind of "hiding" the custom executor logic of this. I mean, when you expand the macro you can see it uses one, but it's not clear (and also not documented at all) that it's easily possible to, in effect, tie your annotated actor back to the main actor (well, to the same executor the main actor uses). That's, imo, questionable design for said macro and lead OP to run into these problems.

1 Like

I think the problem here is that the expansion of ModelActor creates its ModelContext "eagerly" on construction of the ModelActor. If you construct ModelActor on main… the ModelActor will construct its ModelContext on main. Which gets us back to the original problem.

One workaround I see out there is for product engineers to explicitly construct their ModelActor on the background. This could mean constructing the ModelActor in a detached Task on app launch.

Another Workaround Including Self-Promotion

The workaround from ImmutableData constructs the ModelActor implicitly in the background with a "box". The basic idea is for your ModelActor to be a lazy property of another actor:

@ModelActor final actor LazyActor {
  
}

final actor ModelActor {
  lazy private var lazyActor = LazyActor(modelContainer: self.modelContainer)
  
  private let modelContainer: ModelContainer
  
  init(modelContainer: ModelContainer) {
    self.modelContainer = modelContainer
  }
}

Here we create ModelActor on main… but that's ok because when we actually need the ModelContext its created on-demand an off main.

1 Like

I think that this comes down to which actor will the function run on, the main actor which is the context in which the function was originally called, or the custom actor that the user defined where the function is declared? I don’t have the answer to this, but I’d have assumed that it was the former.