iosswiftswiftuiios15structured-concurrency

TaskGroup is not running in parallel with iOS 15


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.

Question

How can I fix the code so that the 4 tasks work in parallel on iOS 15?


Solution

  • 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.