What does DispatchQueue.concurrentPerform do, when is it better than a loop of async calls, and how do you use it safely?
Predict first
What do these parallel loops report?
import Foundation
let lock = NSLock()
nonisolated(unsafe) var seen = Set<Int>()
DispatchQueue.concurrentPerform(iterations: 100) { i in lock.lock(); seen.insert(i); lock.unlock() }
print("processed", seen.count, "of 100 before it returned")
let chunks = 4, n = 1000
let sums = UnsafeMutablePointer<Int>.allocate(capacity: chunks)
sums.initialize(repeating: 0, count: chunks)
nonisolated(unsafe) let out = sums
DispatchQueue.concurrentPerform(iterations: chunks) { c in
var local = 0
for i in stride(from: c, to: n, by: chunks) { local += i }
out[c] = local
}
print("chunked sum:", (0..<chunks).reduce(0) { $0 + sums[$1] })
- processed 100 of 100 before it returned / chunked sum: 499500
- processed 100 of 100 before it returned / chunked sum: 0
- processed 0 of 100 before it returned / chunked sum: 499500
Mental model. A concurrentPerform is a crew sized to the number of workbenches: they split the pile, and the supervisor (the caller) waits until the last box is done.
concurrentPerform(iterations:) runs the closure for each index in parallel, using only as many threads as there are cores, and blocks the caller until every iteration has finished: the check shows all 100 indices processed before the call returns. Unlike submitting 100 separate async blocks (which risks thread explosion, Q290), it never oversubscribes the CPU, and it is the right tool for CPU-bound, independent work (image tiles, parsing independent records, hashing). It is not for waiting on I/O: threads would just block.
Rules for using it well. Make iterations independent and coarse: tiny iterations drown in scheduling cost, so split into chunks (the second part processes four strided chunks, each producing its own partial sum, with the right total 499,500). Write results to separate slots (one array element or buffer per iteration) or protect shared state, because the closure runs concurrently; a shared Set needs a lock, as in the first part, while distinct slots need none. Never call it from the main thread for long work (it blocks), and do not nest it inside heavy queue-based work. In modern code a task group does the same job with cooperative cancellation, but for pure CPU loops concurrentPerform remains simple and fast.
The call blocks until all iterations finish; striding the chunks covers every index exactly once.
Common traps
- Mutating a shared array or dictionary inside the closure. It races; write to separate slots or lock.
- Making each iteration tiny. Overhead exceeds the work; chunk the range.
They might follow up with
- Does it return before the work is done? No, unlike
async; it is synchronous from the caller’s view. - What about priorities? It runs at the caller’s QoS, so calling it from a background QoS queue keeps it from competing with the UI.
Related questions
- 10.11.1 What goes wrong with an unbalanced DispatchGroup, and how do you use a semaphore to throttle work without…
- 10.11.5 How does quality of service propagate between queues and blocks, and why does it matter for priority…
- 10.1.1 What is the difference between serial and concurrent queues and between sync and async, and what do QoS and…
- 10.1.3 What are thread explosion and priority inversion, and how do QoS and the system help?
← What goes wrong with an unbalanced DispatchGroup, and how do you use… How does a DispatchSourceTimer differ from Timer, and what are the… →