Reputation: 30318
I have an ObservableObject
class and a SwiftUI view. When a button is tapped, I create a Task
and call populate
(an async function) from within it. I thought this would execute populate
on a background thread but instead the entire UI freezes. Here's my code:
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async {
var items = [String]()
for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds
items.append("\(i)")
}
self.items = items
}
}
struct ContentView: View {
@StateObject var model = ViewModel()
@State var rotation = CGFloat(0)
var body: some View {
Button {
Task {
await model.populate()
}
} label: {
Color.blue
.frame(width: 300, height: 80)
.overlay(
Text("\(model.items.count)")
.foregroundColor(.white)
)
.rotationEffect(.degrees(rotation))
}
.onAppear { /// should be a continuous rotation effect
withAnimation(.easeInOut(duration: 2).repeatForever()) {
rotation = 90
}
}
}
}
Result:
The button stops moving, then suddenly snaps back when populate
finishes.
Weirdly, if I move the Task
into populate
itself and get rid of the async
, the rotation animation doesn't stutter so I think the loop actually got executed in the background. However I now get a Publishing changes from background threads is not allowed
warning.
func populate() {
Task {
var items = [String]()
for i in 0 ..< 4_000_000 {
items.append("\(i)")
}
self.items = items /// Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
}
}
/// ...
Button {
model.populate()
}
Result:
How can I ensure my code gets executed on a background thread? I think this might have something to do with MainActor
but I'm not sure.
Upvotes: 45
Views: 34219
Reputation: 437552
First, as a general observation, in WWDC 2021’s Discover concurrency in SwiftUI, they recommend that you isolate the ObservableObject
object to the main actor.
But the hitch in the UI is caused by the main actor being blocked by this slow process. So we must get this task off the main actor. There are a few possible approaches:
You can move the slow synchronous process to a “detached” task. While Task {…}
starts a new top-level task “on behalf of the current actor”, a detached task is an “unstructured task that’s not part of the current actor”. So, detached
task will avoid blocking the current actor:
@MainActor
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async {
let task = Task.detached { // this introduces unstructured concurrency!!!
var items: [String] = []
for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change
items.append("\(i)")
}
return items
}
items = await task.value
}
}
Note, while this solves the blocking problem, unfortunately, Task.detached {…}
(like Task {…}
) is unstructured concurrency. You really should wrap it in an withTaskCancellationHandler
. And while here (pursuant to my observations in point 4, below), we should also:
yield
to the Swift concurrency system; andSo, like so:
@MainActor
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async throws {
let task = Task.detached { // this introduces unstructured concurrency
var items: [String] = []
for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change
if i.isMultiple(of: 1000) {
await Task.yield()
try Task.checkCancellation()
}
items.append("\(i)")
}
return items
}
try await withTaskCancellationHandler { // with unstructured concurrency we have to handle cancelation manually
items = try await task.value
} onCancel: {
task.cancel()
}
}
}
As of Swift 5.7, one can achieve the same behavior with an async
function that is nonisolated
(see SE-0338). And this keeps us within the realm of structured concurrency, but still gets the work off the current actor:
@MainActor
class ViewModel: ObservableObject {
@Published var items = [String]()
private nonisolated func generate() async throws -> [String] {
var items: [String] = []
for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change
if i.isMultiple(of: 1000) {
await Task.yield()
try Task.checkCancellation()
}
items.append("\(i)")
}
return items
}
func populate() async throws {
items = try await generate()
}
}
Or we can do this with a separate actor
for the time-consuming process, which again gets the task off the view model’s actor:
@MainActor
class ViewModel: ObservableObject {
@Published var items = [String]()
private let generator = Generator()
private actor Generator {
func generate() async throws -> [String] {
var items: [String] = []
for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change
if i.isMultiple(of: 1000) {
await Task.yield()
try Task.checkCancellation()
}
items.append("\(i)")
}
return items
}
}
func populate() async {
items = try await generator.generate()
}
}
As shown in my examples above, I would advise adding cancelation logic (in case the user wants to interrupt the calculation and start another) with try Task.checkCancellation()
.
Also, in Swift concurrency, we should never violate the contract to “ensure forward progress”, or, if you must, periodically Task.yield
to ensure proper function of this concurrency system. As SE-0296 says:
Because potential suspension points can only appear at points explicitly marked within an asynchronous function, long computations can still block threads. This might happen when calling a synchronous function that just does a lot of work, or when encountering a particularly intense computational loop written directly in an asynchronous function. In either case, the thread cannot interleave code while these computations are running, which is usually the right choice for correctness, but can also become a scalability problem. Asynchronous programs that need to do intense computation should generally run it in a separate context. When that’s not feasible, there will be library facilities to artificially suspend and allow other operations to be interleaved.
Now, the previously mentioned techniques (points 1-3, above) address your primary concern by prevent the blocking of the main actor. But the deeper observation here is that we really should avoid blocking any actors with “long running” work. But Task.yield
addresses that problem.
This periodic checking for cancelation and yielding is only needed when writing our own computationally intensive tasks. Most of Apple‘s async
API (e.g. URLSession
, etc.), already handle these issues for us.
Anyway, all of this discussion on cancelation begs the question of how one would go about canceling a prior task. Simply save the Task
in a property of the actor-isolated view model and then cancel the prior one before starting the next. E.g.:
private var task: Task<Void, Error>?
func start() {
task?.cancel() // cancel prior one, if any
task = Task { try await populate() }
}
Anyway, these patterns will allow the slow process to not block the main thread, resulting in an uninterrupted UI. Here I tapped on the button twice:
Needless to say, that is without the “cancel prior one” logic. With that logic, you can tap multiple times, all the prior once will be canceled, and you will see only one update, avoiding potentially over taxing the system with a bunch of redundant tasks. But the idea is the same, an smooth UI while performing complex tasks.
See WWDC 2021 videos Swift concurrency: Behind the scenes, Protect mutable state with Swift actors, and Swift concurrency: Update a sample app, all of which are useful when trying to grok the transition from GCD to Swift concurrency.
Upvotes: 65
Reputation: 8836
In Xcode 14, Task{} does run in the background thread for the above example. Let me prove it.
To be highlighted, Adding @MainActor
will make sure it runs on the main thread.
Upvotes: 2
Reputation: 30318
Update 6/10/22: At WWDC I asked some Apple engineers about this problem — it really is all about actor inheritance. However, there were some compiler-level changes in Xcode 14 Beta. For example, this will run smoothly on Xcode 14, but lag on Xcode 13:
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async {
var items = [String]()
for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds
items.append("\(i)")
}
/// explicitly capture `items` to avoid `Reference to captured var 'items' in concurrently-executing code; this is an error in Swift 6`
Task { @MainActor [items] in
self.items = items
}
}
}
struct ContentView: View {
@StateObject var model = ViewModel()
@State var rotation = CGFloat(0)
var body: some View {
Button {
Task {
/// *Note!* Executes on a background thread in Xcode 14.
await self.model.populate()
}
} label: {
Color.blue
.frame(width: 300, height: 80)
.overlay(
Text("\(model.items.count)")
.foregroundColor(.white)
)
.rotationEffect(.degrees(rotation))
}
.onAppear { /// should be a continuous rotation effect
withAnimation(.easeInOut(duration: 2).repeatForever()) {
rotation = 90
}
}
}
}
Again, Task
inherits the context of where it's called.
Task
called from within a plain ObservableObject
class will run in the background, since the class isn't a main actor.Task
called inside a Button
will probably run on the main actor, since the Button
is a UI element. Except, Xcode 14 changed some things and it actually runs in the background too...To make sure a function runs on the background thread, independent of the inherited actor context, you can add nonisolated
.
nonisolated func populate() async {
}
Note: the Visualize and optimize Swift concurrency video is super helpful.
Upvotes: 12
Reputation: 4987
As others have mentioned, the reason of this behavior is that the Task.init
inherits the actor context automatically. You're calling your function from the button callback:
Button {
Task {
await model.populate()
}
} label: {
}
The button callback is on the main actor, so the closure passed to the Task
initializer is on the main actor too.
One solution is using a detached task:
func populate() async {
Task.detached {
// Calculation here
}
}
While detached tasks are unstructured, I'd like to suggest structured tasks like async let
tasks:
@MainActor
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async {
async let newItems = { () -> [String] in
var items = [String]()
for i in 0 ..< 4_000_000 {
items.append("\(i)")
}
return items
}()
items = await newItems
}
}
This is useful when you want the populate
function to return some value asynchronously. This structured task approach also means cancellation can be propagated automatically. For example, if you want to cancel the calculation when the button is tapped multiple times in a short time, you can do something like this:
@MainActor
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async {
async let newItems = { () -> [String] in
var items = [String]()
for i in 0 ..< 4_000_000 {
// Stop in the middle if cancelled
if i % 1000 == 0 && Task.isCancelled {
break
}
items.append("\(i)")
}
return items
}()
items = await newItems
}
}
struct ContentView: View {
@StateObject var model: ViewModel
@State var task: Task<Void, Never>?
init() {
_model = StateObject(wrappedValue: ViewModel())
}
var body: some View {
Button {
task?.cancel() // Cancel previous task if any
task = Task {
await model.populate()
}
} label: {
// ...
}
}
}
Moreover, withTaskGroup
also creates structured tasks and you can avoid inheriting the actor context too. It can be useful when your computation has multiple child tasks that can progress concurrently.
Upvotes: 6
Reputation: 114846
First, you can't have it both ways; Either you perform your CPU intensive work on the main thread (and have a negative impact on the UI) or you perform the work on another thread, but you need to explicitly dispatch the UI update onto the main thread.
However, what you are really asking about is
(By using
Task
) I thought this would execute populate on a background thread but instead the entire UI freezes.
When you use a Task
you are using unstructured concurrency, and when you initialise your Task
via init(priority:operation) the task ... inherits the priority and actor context of the caller.
While the Task
is executed asynchronously, it does so using the actor context of the caller, which in the context of a View
body
is the main actor. This means that while your task is executed asynchronously, it still runs on the main thread and that thread is not available for UI updates while it is processing. So you are correct, this has everything to do with MainActor
.
When you move the Task
into populate
it is no longer being created in a MainActor
context and therefore does not execute on the main thread.
As you have discovered, you need to use this second approach to avoid the main thread. All you need to do to your code is move the final update back to the main queue using the MainActor
:
func populate() {
Task {
var items = [String]()
for i in 0 ..< 4_000_000 {
items.append("\(i)")
}
await MainActor.run {
self.items = items
}
}
}
You could also use Task.detached()
in the body context to create a Task
that is not attached the MainActor
context.
Upvotes: 10
Reputation: 30575
You can fix it by removing the class. You aren't using Combine so you don't need its ObservableObject
and SwiftUI is most efficient if you stick to value types. The button doesn't hang with this design:
extension String {
static func makeItems() async -> [String]{
var items = [String]()
for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds
items.append("\(i)")
}
return items
}
}
struct AnimateContentView: View {
@State var rotation = CGFloat(0)
@State var items = [String]()
var body: some View {
Button {
Task {
items = await String.makeItems()
}
} label: {
Color.blue
.frame(width: 300, height: 80)
.overlay(
Text("\(items.count)")
.foregroundColor(.white)
)
.rotationEffect(.degrees(rotation))
}
.onAppear { /// should be a continuous rotation effect
withAnimation(.easeInOut(duration: 2).repeatForever()) {
rotation = 90
}
}
}
}
Upvotes: 0