Long-running synchronous code in Swift Concurrency

Can someone help me understand why this sample using Swift Concurrency with slow synchronous code causes the async let tasks to run serially? My understanding was that while it is not good to run slow synchronously blocking code on the concurrency thread pool, it could still run on different threads in the thread-pool at the same time. This post seems to say the same.

In this case, there are 3 tasks that could be run concurrently on different threads in the thread pool. But you can see from the logs below that the tasks run serially.

I know I could use withCheckedContinuation and explicitly dispatch the synchronous code to a GCD global queue to run concurrently, but I'm wondering why this example using only Swift Concurrency does not run the tasks concurrently. Do I need to use GCD to force long-running synchronous code to run off the concurrency thread pool? Or is there some other flaw in my code sample?

func loadAsync(_ name: String) async -> Int {
    return longRunningSync(name)
}

func longRunningSync(_ name: String) -> Int {
    print("did start \(name)")

    var count = 1
    for _ in 0...100_000 {
        count = count * count
    }

    print("did finish \(name)")
    return 1
}

// expect these to run in parallel
async let task1 = await loadAsync("1")
async let task2 = await loadAsync("2")
async let task3 = await loadAsync("3")
_ = await [task1, task2, task3]

console logs:

did start 1
did finish 1
did start 2
did finish 2
did start 3
did finish 3
2 Likes

You are awaiting before your async let. Drop those async and I think it should work as expected.

2 Likes

Besides the above correction:

If your algorithm will deadlock without parallel concurrent execution, then yes you have to use GCD. Swift concurrency supports but does not guarantee parallel execution. Some targets (notably iOS simulator, WASM, and possibly future embedded platforms) do not support parallel concurrent execution. Swift supports cooperative asynchronous execution. Any long running operation needs to periodically call Task.yield() to give up execution to let the other tasks make forward progress. If you are dealing with something like synchronous I/O, you have to use a different operating system thread (outside Swift concurrency).

3 Likes

Sorry for what is possibly an obvious question: are you saying code that uses Swift Concurrency will always execute serially on a simulator? But possibly concurrently on a physical device?

I’m curious about the details of this, is this documented anywhere?

Many thanks.

:man_facepalming: Thank you for pointing that out, that fixes things when I run on an iPad. Interestingly, it still runs serially on simulator as @hooman predicted

updated:

// expect these to run in parallel
async let task1 = loadAsync("1")
async let task2 = loadAsync("2")
async let task3 = loadAsync("3")
_ = await [task1, task2, task3]

iPad Logs:

did start 2
did start 1
did start 3
did finish 1
did finish 2
did finish 3

simulator logs:

did start 1
did finish 1
did start 2
did finish 2
did start 3
did finish 3
1 Like

I updated with my sample code running on simulator and iPad, and it looks like simulator does not run in parallel. That is very interesting. Has implications for how I run unit tests designed to catch threading issues...

Simulators cooperative pool only use one thread IIUC. Run on device when you want to get real concurrency.

2 Likes

Thanks for the details all! Also, yikes, this frightens me :bat::jack_o_lantern::fearful:

2 Likes

Just a side note for people experimenting for the 1st time:

if you modify “longRunningSync” even simply adding “print” :

var count = 1
for _ in 0...100_000 {
count = count * count
print (“\count“);
}

as print will be passed to I/O, it can wait on scheduler (OR other…) and now longRunningSync as become async ;)

I can reproduce it, but I wonder why? print function is synchronous, so I don't expect it would have any effect. I understand print function uses lock, but I think that's irrelevant in this case?

simply a note.. if you add print, you can see output from different task, so the landscape is again different.

we saw in 1st case:

did start 1 did finish 1

did start 2 did finish 2 ..

… but the do run.

I know this is an old question, but it has been resurrected with recent replies, so let me try to clarify what is going on:

You are correct that async let permits concurrent execution. But whether this results in parallel execution depends upon whether loadAsync is actor-isolated or not. For example, consider:

import os.log

actor Experiment {
    let poi = OSSignposter(subsystem: "Test", category: .pointsOfInterest)

    func loadAsync(_ name: String) async -> Double {
        pi(name)
    }

    func pi(_ name: String) -> Double {
        let state = poi.beginInterval(#function, id: poi.makeSignpostID(), "\(name, privacy: .public)")

        var value: Double = 0
        var denominator: Double = 1
        var sign: Double = 1
        var increment: Double = 0

        repeat {
            increment = 4 / denominator
            value += sign * 4 / denominator
            denominator += 2
            sign *= -1
        } while increment > 0.000000001

        poi.endInterval(#function, state, "\(value)")

        return value
    }

    func runExperiment() async {
        // expect these to run in parallel
        async let task1 = await loadAsync("1")
        async let task2 = await loadAsync("2")
        async let task3 = await loadAsync("3")
        _ = await [task1, task2, task3]
    }
}

(Unrelated, rather than relying on print statements, I’m going to Profile the app using Instruments’ “Time Profiling” template so we can actually visualize what is going on in the “Points of Interest” tool. I have also replaced longRunningSync with something significantly computationally intensive (namely a deliberately inefficient calculation of π): In an optimized/release build, we need something slow enough to meaningly observe and the 100,000 iteration loop was insufficient on my hardware.)

So with those two observations aside, you can see that this actor isolated rendition runs these three calculations sequentially despite using async let pattern. The same would be true if Experiment was a class isolated to a global actor (such as, but not limited to, the main actor), too:

Note, the problem is not async let, but rather the actor-isolation of loadAsync.

But, alternatively, imagine that this whole Experiment type was not actor-isolated:

nonisolated
final class Experiment: Sendable {
    // otherwise, the same as above
}

(Note, prior to Xcode 26, that explicit nonisolated qualifier would be unnecessary/redundant. But nowadays, with “Approachable Concurrency” and its default main actor isolation build setting, we might need/want to explicitly qualify this as nonisolated.)

Anyway, we now experience the parallelism that async let affords us:

Now, my advice for parallel execution it not necessarily to make the entire type, nonisolated. My point is merely that the exact same functions will behave differently based upon what execution context they were defined. If the functions are actor-isolated, then that inhibits any parallelism.

But you can mix and match. For example, we might declare only our calculation as async functions that are nonisolated/@concurrent, but otherwise enjoy actor isolation for the broader type:

actor Experiment {
    func loadAsync(_ name: String) async -> Double {
        await pi(name)
    }

    // In Xcode 26, we would use `@concurrent` qualifier instead
    @concurrent func pi(_ name: String) async -> Double {…}

    // In earlier Xcode versions, we would just make it `nonisolated`
    nonisolated func pi(_ name: String) async -> Double {…}

    func runExperiment() async {…}
}

That will also perform the calculations in parallel.

But, in short, async let (or a task group) enables concurrent execution, but you need to make sure that the work in question is not actor-isolated, as that will inhibit parallel execution.


With that behind us, there is a deeper question:

Do I need to use GCD to force long-running synchronous code to run off the concurrency thread pool?

This is explicitly what is advised in WWDC 2022’s Visualize and optimize Swift concurrency (they’re focusing on locks there, but the same idea applies here). The considerations for “long calculations” is also discussed in SE-0296:

So, you can either:

  • keep it in Swift concurrency and periodically Task.yield; or
  • move it to GCD and bridge back with a continuation.

In my experience, this periodic await Task.yield() adds a material amount of overhead, often more than wiping out all the gains of my parallelized algorithms. You may want to benchmark your use-case and confirm. But for massively parallelized compute tasks, I have been pretty happy with the GCD-bridging pattern.

All of this having been said, it is a bit of a judgment call as to at what point you make this leap. Perhaps this is sacrilegious to say, but I must confess that if I have, say, a single compute task that might momentarily block a single thread, I just don’t worry about all of this, as I know that my target devices have multiple CPU cores and can handle that without a sweat. (I just make sure this work is not performed on the main actor.) I personally have only made the leap to this GCD-bridging pattern when engaging in massively parallelized algorithms (and use concurrentPerform to mitigate thread-explosion scenarios).

2 Likes

Just to throw in some experience, where you put the yield can also matter a ton. In one experiment I profiled, something like this:

foo()
await Task.yield()
bar()
baz()

Ran about nine times slower than this:

foo()
bar()
await Task.yield()
baz()

Disclaimer that this was on a single-core processor with three-ish concurrent tasks, so probably not the most representative example, but the point stands: when you call yield can be important.

1 Like

For what it is worth, the other potential issue (as discussed above) is the constrained cooperative thread pool in the simulator for Xcode versions prior to 14.3. See Maximum number of threads with async-await task groups.

But this was resolved in Xcode 14.3.