How do I stop Tasks from choking up animations?

I'm making a loading screen, but I can't figure out how to make the loading indicator animate smoothly while work is being performed. I've tried a variety of tactics, including creating confining the animation to a .userInitiated Task, or downgrading the loading Task to .background, and using TaskGroups. All of these resulted in hangs, freezes, or incredibly long load times.

I've noticed that standard ProgressViews work fine when under load, but the documentation doesn't indicate why this is the case. Customized ProgressViews don't share this trait (via .progressViewStyle()) also choke up. Finding out why might solve half the problem.

Note: I want to avoid async complications that come with using nonisolated functions. I've used them elsewhere, but this isn't the place for them.

Answered by DTS Engineer in 797623022

It’s hard to offer a specific answer without more details but, in general, the canonical way to do this is to ensure that only work that must be isolated to the main actor runs on the main actor.

I want to avoid async complications that come with using nonisolated functions.

Understood. The main actor is the right choice for all your UI work. You can also use it for ‘command and control’ work, like getting the results of a background operation and use it to kick off the next background operation. However, you’ve gotta avoid doing significant work on the main actor, and that includes both CPU bound work and synchronous I/O.

Consider this rudimentary example:

 1 struct ContentView: View {
 2     @State var lastPhotoID: String = "none"
 3     var body: some View {
 4         VStack {
 5             Text(lastPhotoID)
 6         }
 7         .padding()
 8         .task {
 9             do {
10                 let url = URL(string: "https://photos.example.com/photo/12345")!
11                 let request = URLRequest(url: url)
12                 let (data, response) = try await URLSession.shared.data(for: request)
13                 … check response …
14                 let photo = try decompressPhoto(data)
15                 try savePhoto(photo)
16                 self.lastPhotoID = photo.id
17             } catch {
18                 // …
19             }
20         }
21     }
22 }

There are three points of concern here:

  • Line 12, which uses URLSession to fetch the photo data

  • Line 14, which uses the CPU to decompress it

  • Line 15, which saves it to disk

Line 12 is fine because the URLSession is async. However, lines 14 and 15 are problematic. This code is running on the main action — you can tell that because it can reference lastPhotoID — so the CPU work in line 14 and the synchronous I/O work in line 15 could potentially affect responsiveness.

My preferred way to resolve issues like this is to move that work to an asynchronous function. So, something like this:

func fetchPhoto(url: URL) async throws -> String {
    let request = URLRequest(url: url)
    let (data, response) = try await URLSession.shared.data(for: request)
    … check response …
    let photo = try decompressPhoto(data)
    try savePhoto(photo)
    return photo.id
}

The remaining code on the main actor is all ‘fast’, and so won’t affect responsiveness.

This asynchronous function could be an nonisolated method on your view, but IMO it’s better to come up with an abstraction that gets this work out of your view entirely. The obvious place for such work is in an actor, but that’s not the only game in town. There are lots of design options in this space.

Share and Enjoy

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

It’s hard to offer a specific answer without more details but, in general, the canonical way to do this is to ensure that only work that must be isolated to the main actor runs on the main actor.

I want to avoid async complications that come with using nonisolated functions.

Understood. The main actor is the right choice for all your UI work. You can also use it for ‘command and control’ work, like getting the results of a background operation and use it to kick off the next background operation. However, you’ve gotta avoid doing significant work on the main actor, and that includes both CPU bound work and synchronous I/O.

Consider this rudimentary example:

 1 struct ContentView: View {
 2     @State var lastPhotoID: String = "none"
 3     var body: some View {
 4         VStack {
 5             Text(lastPhotoID)
 6         }
 7         .padding()
 8         .task {
 9             do {
10                 let url = URL(string: "https://photos.example.com/photo/12345")!
11                 let request = URLRequest(url: url)
12                 let (data, response) = try await URLSession.shared.data(for: request)
13                 … check response …
14                 let photo = try decompressPhoto(data)
15                 try savePhoto(photo)
16                 self.lastPhotoID = photo.id
17             } catch {
18                 // …
19             }
20         }
21     }
22 }

There are three points of concern here:

  • Line 12, which uses URLSession to fetch the photo data

  • Line 14, which uses the CPU to decompress it

  • Line 15, which saves it to disk

Line 12 is fine because the URLSession is async. However, lines 14 and 15 are problematic. This code is running on the main action — you can tell that because it can reference lastPhotoID — so the CPU work in line 14 and the synchronous I/O work in line 15 could potentially affect responsiveness.

My preferred way to resolve issues like this is to move that work to an asynchronous function. So, something like this:

func fetchPhoto(url: URL) async throws -> String {
    let request = URLRequest(url: url)
    let (data, response) = try await URLSession.shared.data(for: request)
    … check response …
    let photo = try decompressPhoto(data)
    try savePhoto(photo)
    return photo.id
}

The remaining code on the main actor is all ‘fast’, and so won’t affect responsiveness.

This asynchronous function could be an nonisolated method on your view, but IMO it’s better to come up with an abstraction that gets this work out of your view entirely. The obvious place for such work is in an actor, but that’s not the only game in town. There are lots of design options in this space.

Share and Enjoy

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

Thanks for the prompt response!

I managed to solve the issue after doing all the things I've avoided doing out of laziness and drilling deep into my code, and then realizing that my implementation of the animation was either the root cause or a major contributor

Uninteresting post-mortem

  1. I excised all state for the loading screen into its own view model, granting me more control over its concurrency.
  2. I then designated the animation function @MainActor and the workload nonisolated (I'm a terrible person)
  3. These failed to solve the issue, so after jiggling a lot of knobs I drilled deep into the workload code
  4. This yielded a function that ran on @MainActor deep into the code, so I fixed that and the issue still wasn't fixed.
  5. More knobs were jiggled, no help
  6. Realized the animation itself was poorly implemented since I wrote that when I was very new to coding

Long story short There's a beachball-like image that rotates 360 degrees during loading, but back then I didn't know about .repeatForever(), so instead I jury rigged an abomination:

Task {
	while loading {
		try await Task.sleep(for: .seconds(0.5))
		withAnimation(.linear(duration: 0.5)) {
 			beachballRotation += 90
		}
	}
}

The issues went away after replacing all that with:

withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
	beachballRotation = 360
}

The animation still jumps at the 360 degree mark, when the animation repeats itself. Other than that, everything works smoothly, so I'm sure I'll eventually figure something out.

I bet moving everything to a view model and setting the workload to nonisolated also helped (again, I'm a terrible person and I'm sorry). My guess is timing imperfections + frequent new calls during heavy load processing lead to the sputtering and freezing.


But all of this has reminded me of a question I've always had:

In the process of moving to nonisolated, I've had to disfigure my code by inserting DispatchQueue.main.async closures. This is my go-to solution whenever a purple or yellow warning pops up telling me some async code should be on the main thread. But, every time I do this, there's a little voice at the back of my head reminding me of a warning a younger me received about never crossing the streams. i.e. One should never mix the antiquated DispatchQueue.main.async/GCD with the modern async-await.

I'm a SwiftUI native, and all my concurrency is technically async-await. At last count I have 150 instances where I've crossed the streams. Many of these were done out of desperation because my app makes heavy use of SpriteKit, whose SKScene demands all calls to be on the main queue. Everything seems to work fine for now, but this is still something I'd like to fix before it blows up in my face or Apple finally makes it a bona-fide error.

tl;dr - How does one avoid using DispatchQueue.main.async to encapsulate async-await code that should be on main?

@DTS Engineer if you feel this deserves its own thread, please let me know and I'll make one.

Thanks for sharing that post mortem.

And don’t beat yourself up about this stuff. Swift concurrency is new for everyone!

How does one avoid using DispatchQueue.main.async to encapsulate async-await code that should be on main?

Lemme start by answering that question directly. Consider this code:

enum MainActorState {
    @MainActor static var counter: Int = 0
}

actor MyActor {
    
    func doStuff() {
        MainActorState.counter += 1
                    // ^ Main actor-isolated static property 'counter' can not
                    // be mutated on a non-isolated actor instance
    }
}

That error is expected. The doStuff() method is isolated to the instance of MyActor, so it can’t directly access counter which is isolated to the main actor.

The Swift concurrency equivalent of the main queue dance is this:

actor MyActor {
    
    func doStuff() {
        Task { @MainActor in
            MainActorState.counter += 1
        }
    }
}

The @MainActor attribute on the closure tells the compiler that this code should be isolated to that context.


Taking a step back, I agree with your impression that this ad hoc approach for getting from other contexts to the main actor context is less than ideal. I try to avoid it, and instead use some sort of notification mechanism. There are lots of options in this space — Observable, Combine, async streams, and so on — and it’s not always clear which is best. If you have a specific example you want to discuss, I’d be happy to do so. Probably best to start a new thread though (-:

Share and Enjoy

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

This is perfect!

All I'll have to do is to find and replace all instances of DispatchQueue.main.async { with Task { @MainActor in in my project.

I would've never figured this out in a million years since using @MainActor inside the closure is just plain counterintuitive; doing it without some constant (e.g. list.filter { thing in thing.isTrue }) doubly so. I would've tried sacrificing gerbils when compiling before trying that.

I'll look into Observable, Combine, and async streams if the need arises, but for now getting rid of all GCD calls and making my app pure async-await is enough.

Thank you very much

I use await MainActor.run { main thread stuff } instead of DispatchQueue.main.async when I am in an async func. Is this comparable to Task { @MainActor in main thread stuff }, which is really new to me, or are there differences?

@TonECCT

I found an article about this:

https://www.hackingwithswift.com/quick-start/concurrency/how-to-use-mainactor-to-run-code-on-the-main-queue

The reason I failed to come across this before was that I was searching for variations of "DispatchQueue.main.async concurrency replacements" instead of looking for @MainActor directly. It does a good job going into the nuances between the two and is easily digestible.

What's important to me is that Task { @MainActor in behaves exactly like DispatchQueue.main.async because it waits for the next run-loop, which means it can be replaced 1:1 in most situations.

Using await MainActor.run { } causes the closure to run immediately, without waiting for the next run loop.

Another important difference is the Task version can be run straight from a regular context while MainActor.run needs to be in an async context.

Using await MainActor.run { } causes the closure to run immediately, without waiting for the next run loop.

That’s not right.

The main actor is entirely cooperative. If you run something on the main actor from a non-main-actor context, it can’t run immediately because the main thread [1] might be already doing something. Rather, it enqueues that work and pokes the main thread’s run loop. If the main thread is idle in the run loop, it wakes up and services this queued work. If the main thread is busy doing something, the work waits until the main thread returns to the run loop. At which point the run loop cycles, notices that it has queued work, and does it.

To see this in action, consider this code:

import Foundation

let start = Date.now

func delta() -> String {
    String(format: "t+%.1f", Date.now.timeIntervalSince(start))
}

func main() {
    print("\(delta()) start")
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
        print("\(delta()) main thread will sleep")
        sleep(2)
        print("\(delta()) main thread did sleep")
    }
    DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) {
        print("\(delta()) task will start")
        Task {
            print("\(delta()) main actor will run")
            await MainActor.run {
                print("\(delta()) main actor running")
            }
            print("\(delta()) main actor did run")
        }
        print("\(delta()) task did start")
    }
    RunLoop.current.run()
}

main()

It does the following:

  • At t+0.0 it:

    1. Schedules a run loop timer to start at +t+1.0
    2. Schedules work to run on the global Dispatch queue at t+2.0
    3. Enters the run loop
  • At t+1.0 the run loop timer fires and blocks the main thread for 2 seconds, that is, until t+3.0.

  • At t+2.0 the Dispatch work starts a task that then runs some code on the main actor.

It prints:

t+0.0 start
t+1.0 main thread will sleep
t+2.1 task will start
t+2.1 task did start
t+2.1 main actor will run
t+3.0 main thread did sleep
t+3.0 main actor running
t+3.0 main actor did run

As you can see, the main actor work doesn’t start until after the main thread frees up at t+3.0.


Coming back to your original issue:

Is this comparable to Task { @MainActor in main thread stuff }, which is really new to me, or are there differences?

there is an important difference between MainActor.run(…) and Task { @MainActor in … }. The latter is closer to DispatchQueue.main.async { … }. The former is closer to DispatchQueue.main.sync { … }. Notably, look at the signature of the MainActor.run(…) routine:

static func run<T>(
    resultType: T.Type = T.self,
    body: @MainActor () throws -> T
) async rethrows -> T where T : Sendable

See how it returns the value returned by the closure. That means that the routine must block waiting for the closure to complete. And that is why it’s an async function.

Share and Enjoy

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

[1] I’m assuming you’re in an app context and the thus have a main thread and a run loop. In non-app contexts you might use dispatchMain(), which changes the mechanics of this but not the final outcome.

Always good to know what's empirically true.

The following statements in the article tripped me up:

Even better, if [MainActor.run] was already running on the main actor then the code is executed immediately – it won’t wait until the next run loop in the same way that DispatchQueue.main.async() would have done.

and

Important: If your function is already running on the main actor, using await MainActor.run() will run your code immediately without waiting for the next run loop, but using Task as shown above will wait for the next run loop.

Some more results based on variations of your code:

Task { @MainActor in
     print("\(delta()) main actor will run")
...

t+0.0 start
t+1.0 main thread will sleep
t+2.1 task will start
t+2.1 task did start
t+3.0 main thread did sleep
t+3.0 main actor will run
t+3.0 main actor running
t+3.0 main actor did run
Task { @MainActor in
    print("\(delta()) main actor running")
...
t+0.0 start
t+1.0 main thread will sleep
t+2.1 task will start
t+2.1 main actor will run
t+2.1 main actor did run
t+2.1 task did start
t+3.0 main thread did sleep
t+3.0 main actor running

Both waited for the run loop

@MainActor func main() async {
    print("\(delta()) start")
    Timer.scheduledTimer(withTimeInterval: 0.0, repeats: false) { _ in
        print("\(delta()) main thread will sleep")
        sleep(2)
        print("\(delta()) main thread did sleep")
    }
    await MainActor.run {
        print("\(delta()) main actor running")
    }
}

t+0.1 start
t+0.1 main thread will sleep
t+2.1 main thread did sleep
t+2.1 main actor running

This shows that the statements in the article don't reflect what's empirically true. await MainActor.run() in a function that's already on the main actor will not cause the code to run immediately without waiting for the next run loop.

This shows that the statements in the article don't reflect what's empirically true.

Interesting. You’ll note that I dodged that question (-: That’s because it’s one with a lot of backstory to it, and so I didn’t trust my own intuition. I’m glad you ran your own tests to confirm the behaviour.

The following statements in the article tripped me up

I recommend that you reach out to the author to discuss these results.

Share and Enjoy

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

How do I stop Tasks from choking up animations?
 
 
Q