Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why withTaskGroup-work runs in parallel inside Actor?

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
like image 309
MaxFactorHub Avatar asked May 28 '26 14:05

MaxFactorHub


2 Answers

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.

like image 77
Paulw11 Avatar answered May 31 '26 05:05

Paulw11


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 the Actor1

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 let is a shorthand way of saying let _ = 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:

enter image description here

like image 38
Rob Avatar answered May 31 '26 06:05

Rob