I understand that all the parallel work inside the actor somehow changes to serial as a part of some synchronization process. We can see that the async let-work that should be done in parallel is done sequentially in the Actor1, most likely due to the internal synchronization of the actor. But, withTaskGroup-work runs in parallel despite AnActor internal synchronization, but WHY?)
Edit: At the same time, I want to say that I understand how synchronization works when called from outside the internals of an actor when using await, but I don’t understand how synchronization works inside an actor, to call asynchronous parallel tasks inside an actor.
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView().task {
//await AnActor().performAsyncTasks() // uncomment this alternately and run
//await Actor1().performAsyncTasks() // uncomment this alternately and run
}
}
}
}
actor Actor1 {
func performAsyncTasks() async {
async let _ = asyncTasks1() // this not running in parallel
async let _ = asyncTasks2() // this not running in parallel
}
func asyncTasks1() async {
for i in 1...10_000_0 {
print("In Task 1: \(i)")
}
}
func asyncTasks2() async {
for i in 1...10_000_0 {
print("In Task 2: \(i)")
}
}
} // the printed text are in series with Task 1 and Task 2 in console
actor AnActor {
var value = 0
func performAsyncTasks() async {
value = await withTaskGroup(of: Int.self) { group in
group.addTask { // this running in parallel, why?!
var value1 = 0
for _ in 1...10_000 {
print("Task1")
value1 += 1
}
return value1
}
group.addTask { // this running in parallel, why?!
var value2 = 0
for _ in 1...10_000 {
value2 += 1
print("Task2")
}
return value2
}
return await group.reduce(0, +)
}
print(value)
}
} // the printed text are mixed with Task 1 and Task 2 in console
The behaviour you are seeing is due to the nature of the async task you are performing when that task is bound to an Actor.
The underlying thread execution model in iOS does not allow for pre-emption. That is, the CPU is never "taken away" from a task. When a task relinquishes the CPU then there is an opportunity for some other task to begin executing.
This code:
for _ in 1...10_000 {
print("Task1")
value1 += 1
}
is CPU bound - There is no opportunity for any other task to run on the Actor until the for loop completes.
Async/Await is normally used where there is some asynchronous operation; A network operation, for example.
If we make a small change to your functions:
func asyncTasks1() async {
for i in 1...10_000_0 {
print("In Task 1: \(i)")
try? await Task.sleep(nanoseconds: 100)
}
}
func asyncTasks2() async {
for i in 1...10_000_0 {
print("In Task 2: \(i)")
try? await Task.sleep(nanoseconds: 100)
}
}
you will see the output of the two functions intermingled because each function is relinquishing the CPU after each print, allowing the actor to execute the other function until it relinquishes the CPU.
Now, as for why you see a different behaviour with withTaskGroup - This is because the task group is not bound to the Actor, even though you are creating it in a function that is bound to an Actor. A task group can use multiple threads to perform tasks. That is its primary function to allow a series of independent operations to execute with a simple rendezvous when they are all completed (or cancelled).
If you remove the await sleep that was added and make a small change to your task group code:
func performAsyncTasks() async {
let a1=Actor1()
value = await withTaskGroup(of: Int.self) { group in
group.addTask { // this running in parallel, why?!
var value1 = 0
await a1.asyncTasks1()
return value1
}
group.addTask { // this running in parallel, why?!
var value2 = 0
await a1.asyncTasks2()
return value2
}
return await group.reduce(0, +)
}
You will now see that the two loops complete sequentially because they are bound to the Actor1 instance.
Consider your first example:
actor Actor1 {
func performAsyncTasks() async {
async let _ = asyncTasks1() // this not running in parallel
async let _ = asyncTasks2() // this not running in parallel
}
func asyncTasks1() async {
for i in 1...10_000_0 {
print("In Task 1: \(i)")
}
}
func asyncTasks2() async {
for i in 1...10_000_0 {
print("In Task 2: \(i)")
}
}
}
You said:
We can see that the
async let-work that should be done in parallel is done sequentially in theActor1
Yes, generally async let can let routines run concurrently. But, in this case, that will not happen here only because these two functions are both isolated to the same actor, and there are no await suspension points.
Someone said:
If an
async letis a shorthand way of sayinglet _ = await asyncTasks1()…
It is not. See SE-0317.
If you want to see parallel execution, you can use async let. You just need to get them off the current actor. In this case, use nonisolated async functions, as outlined in SE-0338:
import os.log
actor Foo {
private let poi = OSSignposter(subsystem: "Test", category: .pointsOfInterest)
func bar() async throws {
async let value1 = first() // this IS running concurrently
async let value2 = second() // this IS running concurrently
let total = try await value1 + value2
print(total)
}
}
private extension Foo {
func first() async throws -> Int {
let state = poi.beginInterval(#function, id: poi.makeSignpostID())
defer { poi.endInterval(#function, state) }
return try await inefficientCount()
}
func second() async throws -> Int {
let state = poi.beginInterval(#function, id: poi.makeSignpostID())
defer { poi.endInterval(#function, state) }
return try await inefficientCount()
}
// This is `nonisolated` & `async` to get this off this actor. See SE-0338.
// Also, routines doing slow calculations should periodically `yield` and check for cancelation.
nonisolated func inefficientCount() async throws -> Int {
var value = 0
for i in 1...1_000_000_000 {
if i.isMultiple(of: 10_000_000) {
try Task.checkCancellation()
await Task.yield()
}
value += 1
}
return value
}
}
If I profile that in Instruments, I can see they run in parallel:

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With