Swift Concurrency allows us to move a lot of work to the background:
@concurrent
nonisolated func heavyBackgroundWork() async { ... }
But sometimes, we have MainActor-isolated work that we just cannot move off the main thread.
Time slicing
I recently went down a rabbit hole of different approaches to time slicing.
Time slicing is where we still process work synchronously on the main actor, but in small chunks, allowing other scheduled main actor work to interleave with our own.
We might start off with some pseudo work like this — if you run this code in your app, it will literally make your app unusable:
@MainActor
func heavyWork() {
for i in 0...1_000_000 {
print("Number \(i)")
}
}
Yield
One option to improve performance is to simply call Task.yield()
, which potentially allows Swift to process other MainActor-scheduled work between your units of work.
Task(priority: .medium) {
await heavyWork()
}
// ...
@MainActor
func heavyWork() async {
for i in 0...1_000_000 {
if i % 10 == 0 {
try Task.checkCancellation()
await Task.yield()
}
print("Number \(i)")
}
}
This can definitely help, but Task.yield()
doesn’t guarantee we won’t cause hangs in our app.
Sleep
The next option is to sleep. Sleeping 20ms will definitely fix any lag, as it forces our blocking work to only run within smaller units of time.
@MainActor
func heavyWork() async {
for i in 0...1_000_000 {
if i % 10 == 0 {
try await Task.sleep(for: .milliseconds(20))
}
print("Number \(i)")
}
}
However, this isn’t the most efficient way to break up the work — different iPhones have different frame rates, and different processors with different capabilities. We can improve on this a lot.
Calculating frames
This is a little more involved, but we can attempt to calculate the FPS of a UIView.
We can then decide, with a frameBudget variable, what percentage of a frame we want to spend on a unit of our blocking work.
For example, if a single frame only has 16ms to render, we can decide our heavyWork function should only process units of work up to 2% of that 16ms budget.
E.g: heavyWork(frameBudget: 2 / 100)
Our heavyWork function will then pause for the duration of a frame (16ms), guaranteeing our unit work doesn't run more than once within the same frame.
@MainActor
func heavyWork(frameBudget: Double) async {
let fps: Int = view.window?.windowScene?.screen.maximumFramesPerSecond ?? 60
let frameDuration: Duration = Duration.seconds(1) / fps
let maxSliceDuration: Duration = {
let frameNanos: Int64 = Int64(1_000_000_000 / fps)
let maxWorkNanos: Int64 = Int64(Double(frameNanos) * frameBudget)
return Duration.nanoseconds(maxWorkNanos)
}()
let clock = ContinuousClock()
var sliceStart = clock.now
for i in 0...1_000_000 {
// Check if we've exceeded our frame budget
if sliceStart.duration(to: clock.now) >= maxSliceDuration {
// If so, sleep for the duration of a single frame
try await Task.sleep(for: frameDuration)
}
print("Number \(i)")
}
}
This allows us to take synchronous main actor work and split it into discrete units with predictable performance overhead across all iOS devices.
It guarantees our work will never cause frame hitches. For example, if we restrict our heavyWork to 1% of a frame’s budget, that’s basically dust — it’ll have no noticeable effect on performance.
Summary
I recommend profiling your functions on a real iPhone device, and also using os_signposts (Points of Interest) to check how long your work takes.
The one drawback with time slicing is that our function loses atomicity (in the pseudo sense) — during the Task.sleep, it’s possible our main-actor-isolated state could have been mutated, potentially causing a race condition depending on what you’re doing.