For this swift playground (view on swiftfiddle or below), I have three loops of process-intensive work. The one that uses threads spawns 50 threads that all work together (shown by the increasing # of loops reported at the end), and the ones with concurrency will only ever execute two tasks at the same time. What dictates how many threads spawn for tasks to run within? As I mention in comments, one method isn't necessarily faster than another since 50 threads competing for resources is far from ideal, but it does seem like 2 is a very low number.
import PlaygroundSupport
import UIKit
PlaygroundPage.current.needsIndefiniteExecution = true
func asyncProcess(_ this:Int) async{
print(" async -- starting \(this)")
let x = Int.random(in: 1000...10000000)
await withUnsafeContinuation{
for _ in 1..<x {}
$0.resume()
}
print(" async -- ending \(this) after \(x) times")
}
func threadedProcess(_ this:Int) {
print("threaded -- starting \(this) on \(Thread.current)")
let x = Int.random(in: 1000...10000000)
for _ in 1..<x {}
print("threaded -- ending \(this) after \(x) times on \(Thread.current)")
}
/// This will dispatch all 50 at once (if running w/out other tasks), and they all run together (which you can see because the ending statements print in ascending order of the number of times the loop runs
func doThreaded(){
for x in 1...50 {
DispatchQueue.global(qos: .background).async { threadedProcess(x) }
}
}
/// This only ever runs two tasks at the same time
func doUngroupedTasks(){
print ("starting ungrouped tasks")
for x in 1...50 {
Task.detached { await asyncProcess(x) }
}
print ("ending ungrouped tasks")
}
/// This is no different than the above
func doGroupedTasks() async{
print ("starting grouped tasks")
await withTaskGroup(of: Void.self){
for x in 51...100 {
$0.addTask(priority: .background) {
await asyncProcess(x)
}
}
}
print ("ending grouped tasks")
}
// comment out here as you see fit
doThreaded()
doUngroupedTasks()
await doGroupedTasks()
In Xcode 14.2 or earlier, Swift concurrency’s “cooperative thread pool” on the simulator is constrained. (See Maximum number of threads with async-await task groups.) If you run it on a physical device, though, you will see a comparable degree of parallelism in GCD’s concurrentPerform
as you will with a Swift concurrency task group.
Regarding the three alternatives:
In doThreaded
, with DispatchQueue
, you are dispatching 50 items, which will take up 50 the threads from the very limited worker thread pool (which, last time I checked, only has 64 threads per QoS). If you completely exhaust the worker thread pool (which is called “thread explosion”), that can lead to all sorts of problems.
I would strenuously encourage you to avoid unbridled dispatching to a concurrent queue (especially if these items of work might be slow). Instead, we would use concurrentPerform
to enjoy maximum concurrency while avoiding thread explosion:
func doThreaded() {
DispatchQueue.global(qos: .utility).async {
DispatchQueue.concurrentPerform(iterations: 50) { x in
self.threadedProcess(#function, x)
}
}
}
This also enjoys maximum concurrency (even more than you will see with direct, unbridled async dispatches to a global queue), but avoids the thread explosion.
Regardless, either way, the actual maximum concurrency in GCD is limited to the capabilities of the CPU cores.
In doUngroupedTasks
, you are using unstructured concurrency (and not waiting for them to return). You may see the “ending ungrouped tasks” message before they actually finish.
In this case, the maximum concurrency is limited to the size cooperative thread pool. On a device, this would be the number of CPU cores. On a simulator, in Xcode 14.2 or earlier, this may be artificially constrained. But on physical device, it uses all the CPU cores.
In the doGroupedTasks
, you enjoy the same degree of concurrency as outlined in the prior point (i.e., maxes out at the CPU’s capabilities, except when running on simulator in Xcode versions before 14.3). But the task group eliminates all of the problems the prior unstructured concurrency example introduced (knowing when they finished, enjoying cancelation propagation, etc.).
A few other observations:
I would avoid calling these immediately in succession. The doThreaded
and doUngroupedTasks
are not waiting for the asynchronous tasks to finish, thus the prior experiment will affect the subsequent one.
I would avoid using simulators or playgrounds for these experiments. They can introduce additional constraints and limitations that will skew your results.
Your tasks might be slow enough in a Playground (which runs excruciatingly slowly), if you move it to an app, particularly a release build, these loops are far too short to reliably observe concurrency. Personally, for demonstrations like this, I would loop for a period of time so that regardless of the environment or hardware, I have reproducible results.
You may need to be careful that the methods that you use for testing Swift concurrency are not actor-isolated. As top-level functions, they will not be actor isolated, but if you put them inside an actor-isolated type, you will want to explicitly mark it as nonisolated
. You probably do not want actor-isolation skewing your parallelism experiments.
FWIW, here is an example with “Points of Interest” intervals, which I can profile (command-i or “Product” » “Profile”) and watch using the “Time Profiler” template in Instruments:
import Cocoa
import os.log
private let poi = OSLog(subsystem: "Test", category: .pointsOfInterest)
class ViewController: NSViewController {
nonisolated func threadedProcess(_ message: StaticString, _ this: Int) {
let id = OSSignpostID(log: poi)
os_signpost(.begin, log: poi, name: #function, signpostID: id, message)
let start = CACurrentMediaTime()
while CACurrentMediaTime() - start < 1 { }
os_signpost(.end, log: poi, name: #function, signpostID: id)
}
let iterations = 50
@IBAction func didTapThreaded(_ sender: Any) {
DispatchQueue.global(qos: .utility).async {
DispatchQueue.concurrentPerform(iterations: self.iterations) { x in
self.threadedProcess(#function, x)
}
}
}
@IBAction func didTapUngrouped(_ sender: Any) {
for x in 0 ..< iterations {
Task.detached { self.threadedProcess(#function, x) }
}
}
@IBAction func didTapGrouped(_ sender: Any) {
Task {
await withTaskGroup(of: Void.self) { group in
for x in 0 ..< iterations {
group.addTask(priority: .background) {
self.threadedProcess(#function, x)
}
}
}
}
}
}
This is the result of didTapThreaded
, didTapUngrouped
and didTapGrouped
, in succession. As you can see, they all enjoyed the equivalent concurrency, maxing out my Mac hardware:
Bottom line, avoid playgrounds and simulators when testing this sort of behavior.