tl;dr
I have always (and have advised others) to keep slow and synchronous work out of Swift concurrency. This was explicitly advised in WWDC 2021 & 2022 videos, as well as SE-0296.
I keep this sort of work in GCD and bridge back to Swift concurrency with continuations, and that works great. But I wonder if this advice really still holds today (e.g., as of Swift 6.2). I notice (in macOS, at least) that I can slam the Swift concurrency thread pool with .medium or .low priority tasks performing synchronous work, but the main thread remains responsive even if I never Task.yield() in all of that synchronous work. That surprised me.
The long version:
SE-0296 warns us about long computations, saying (emphasis added):
The proposal doesnāt define āin a separate contextā, but WWDC 2022ās Visualize and optimize Swift concurrency they suggest GCD:
In WWDC 2021ās Swift concurrency behind the scenes, also discusses this contract with the Swift concurrency system to never impede forward progress on a thread:
OK, so this is all fine and Iāve been doing this for quite a while without incident. Itās ugly to reintroduce GCD into my code, but works great and the UI remains responsive.
But Iāve written a little test app (included at the end of this question) to explore this behavior and notice that although Iām hammering my CPUs with 40 slow and synchronous jobs (on a Mac Studio with 20 processors), and my main thread remains responsive (the overlapping ā signposts āpointsā just happily tick away even though the device has been slammed):
Note, I used .medium priority for this background task. If I used .high, that did prevent the UI from responding (though, curiously, not identified as a hang in Instruments). Still, it looks like slow and synchronous work performed with a .medium or .low priority will keep the main thread responsive, which surprised me. I expected that if I tied up the threads that the main thread would be blocked.
So, two questions:
-
Despite the observations above, I assume that it is still advised to simply keep very slow and synchronous work (that does not periodically yield to the Swift concurrency system, at least) out of the Swift concurrency system and bridge it back with a continuation? Or are there Swift changes since 2021/2022 that render this advice moot?
-
Also, I assume that if I define a custom executor (a la SE-0392) like below, that this is exempted from this advice? I.e., I am assuming this ānever impede forward progressā advice only applies for tasks running on the concurrency threadpool.
So the following is fine, right?
actor Foo { private let queue = DispatchSerialQueue(label: "Foo") nonisolated var unownedExecutor: UnownedSerialExecutor { queue.asUnownedSerialExecutor() } // I assume this is fine func somethingVerySlowAndSynchronous() {ā¦} }I.e., I assume the ānever impede forward progressā is a concurrency threadpool concern, not a general Swift concurrency concern. Is that right?
Itās not entirely relevant, but here is the code snippet that generated the above Instruments log:
import SwiftUI
import os.log
struct ContentView: View {
let poi = OSSignposter(subsystem: "Test", category: .pointsOfInterest)
@State var maxDuration: Duration = .zero
@State var maxDurationInstant: ContinuousClock.Instant = .now
var body: some View {
VStack {
Text("\(maxDuration.seconds)")
.monospacedDigit()
}
.task {
try? await tickOnMainThread()
}
.task(priority: .medium) {
await lotsOfBackgroundTasks()
}
}
func tickOnMainThread() async throws {
var last = ContinuousClock.now
while !Task.isCancelled {
try await Task.sleep(for: .seconds(1.0 / 100.0))
let now = ContinuousClock.now
let duration = last.duration(to: now)
poi.emitEvent("tick", "\(duration)")
if duration > maxDuration || maxDurationInstant.duration(to: now) > .seconds(1) {
maxDuration = duration
maxDurationInstant = now
}
last = now
}
}
@concurrent
func lotsOfBackgroundTasks() async {
await withTaskGroup { group in
for i in 0 ..< 40 {
group.addTask(priority: .background) { spin(index: i) }
}
}
}
// simulating some slow and synchronous calculation
nonisolated
func spin(for duration: Duration = .seconds(5), index: Int) {
poi.withIntervalSignpost(#function, id: poi.makeSignpostID(), "\(index)") {
let start = ContinuousClock.now
while start.duration(to: .now) < duration {
// this is intentionally blank
}
}
}
}
extension Duration {
var seconds: Double {
let (seconds, attoseconds) = components
return Double(seconds) + Double(attoseconds) / 1e18
}
}
And, FWIW, I know that I could be a good citizen and Task.yield() within the spin function that is simulating some long process, but Iām deliberately trying to tease out the behavior with this anti-pattern.
While I implied that I always use GCD for slow and synchronous work, I must confess that I take a slightly more pragmatic approach, and only go through this GCD rigamarole when really slamming the device. For infrequent, reasonably short, ad hoc, requirements (e.g., quickly save a file synchronously, or whatever), I donāt go through all of this. I just make sure to constrain the degree of parallelism to make sure this doesnāt bite me later.
