Background
My app is a puzzle game for number puzzles that are derived from a Latin square. The puzzles are not Sudoku puzzles, but similar in some ways. Currently, the app runs on iOS 15 and later. I'd like to keep it that way for a while at least, if possible.
The app includes a built-in puzzle generator. This works by generating a Latin square at random and then seeing whether it can be turned into a solvable puzzle with a unique solution. The hit rate is very low, so the generation process is very CPU intensive.
To speed up the generation process, I use 4 parallel tasks. As soon as any of the four tasks finds a valid puzzle, the generation process is successful. The three tasks which did not generate a puzzle are simply cancelled.
Up to now, the implementation was GCD-based. The tasks were started using DispatchQueue.global(qos: .userInitiated).async
and synchronized using a semaphore. I am now trying to convert this to structured concurrency using async/await
.
The problem
The new approach to generating a puzzle uses a TaskGroup
. Four tasks are added to the task group, then as soon as the first task has delivered a puzzle, the other three tasks are cancelled.
This works fine on iOS version 16 and above. However, when running on iOS 15, it seems the tasks do not run in parallel. This at least is my conclusion based on the CPU load that is seen when running from Xcode with a simulator:
Minimal reproducible example
The following is a simplified version of the generator. The CPU-intensive generation process is emulated by generating random numbers in the range 0..<100,000,000. The generation process completes when the number generated is 0.
struct Puzzle {
let val: Int
}
struct PuzzleGenerator {
let index: Int
func generatePuzzle() async -> Puzzle? {
var val: Int
repeat {
val = Int.random(in: 0..<100_000_000)
await Task.yield()
} while val > 0 && !Task.isCancelled
if val == 0 {
print("puzzle generated successully by generator \(index)")
}
return val > 0 ? nil : Puzzle(val: val)
}
}
enum GeneratorCommand {
case wait
case generate
case abort
}
struct ContentView: View {
@State private var command = GeneratorCommand.wait
@State private var elapsedSecs: Int?
private nonisolated func generatePuzzle() async -> Puzzle? {
await withTaskGroup(of: Puzzle?.self) { group in
// Add 4 worker tasks to the group
for i in 0..<4 {
let worker = PuzzleGenerator(index: i)
group.addTask(priority: .userInitiated) {
await worker.generatePuzzle()
}
}
// Cancel all remaining tasks when a result is delivered
defer {
group.cancelAll()
}
// Wait for the first task to deliver a result
return await group.next() ?? nil
}
}
var body: some View {
VStack(spacing: 20) {
if command == .generate {
ProgressView()
.progressViewStyle(.circular)
Text("Generating puzzle...")
} else if let elapsedSecs {
if command == .wait {
Text("Puzzle generated in \(elapsedSecs) seconds")
} else {
Text("Generation aborted after \(elapsedSecs) seconds")
}
}
if command == .generate {
Button("Abort") { command = .abort }
.buttonStyle(.bordered)
} else {
Button("Generate puzzle") { command = .generate }
.buttonStyle(.borderedProminent)
}
}
.frame(height: 100, alignment: .bottom)
.task(id: command) {
if command == .generate {
let startTime = Date.now
_ = await generatePuzzle()
elapsedSecs = Int((-startTime.timeIntervalSinceNow).rounded(.up))
if !Task.isCancelled {
command = .wait
}
}
}
}
}
To see the problem, tap the button "Generate puzzle" and observe the CPU load.
To recap, the problem only happens with iOS 15, it works fine on iOS versions 16 and above.
Also strange: with iOS 16 and above, only one generator task reports success (the print statement). With iOS 15, all 4 generator tasks report successful completion. Maybe there is a simple explanation to do with the way This problem is no longer seen when the loop includes Int.random
works (or doesn't work) when used from concurrent tasks?Task.yield()
.
Question
How can I fix the code so that the 4 tasks work in parallel on iOS 15?
This is an issue with the simulator for iOS 15 and before. In those versions, the Swift concurrency runtime only gave itself an execution width of 1, rather than the number of logical cores, as it did on actual iOS 15 devices. As I recall there's no way to override this default in the sim. But...
As @Sweeper said, performing intensive work in Swift concurrency can overwhelm the fixed-width executor, leading to delays in resuming other work. If you need to run long lived computations, it's best to either use a custom concurrency executor or leave it in a DispatchQueue
and interact with it using continuations or a stream.