skip to Main Content

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

2

Answers


  1. 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 in concurrently. But that won’t 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 use non-isolated functions:

    actor Foo {
        func performAsyncTasks() async {
            async let value1 = asyncTasks1() // this IS running in parallel
            async let value2 = asyncTasks2() // this IS running in parallel
            let total = await value1 + value2
            print(total)
        }
    
        nonisolated func asyncTasks1() async -> Int {
            await poi.interval(name: #function) {
                var value = 0
                for _ in 1...1_000_000_000 {
                    value += 1
                }
                return value
            }
        }
    
        nonisolated func asyncTasks2() async -> Int {
            await poi.interval(name: #function) {
                var value = 0
                for _ in 1...1_000_000_000 {
                    value += 1
                }
                return value
            }
        }
    }
    

    If I profile that in Instruments, I can see they run in parallel:

    enter image description here

    By the way, the above uses the following POI utility function:

    import os.log
    
    let poi = OSLog(subsystem: "Test", category: .pointsOfInterest)
    
    extension OSLog {
        func interval<T: Sendable>(name: StaticString, block: () async throws -> T) async rethrows -> T {
            let id = OSSignpostID(log: self)
            os_signpost(.begin, log: self, name: name, signpostID: id)
            defer { os_signpost(.end, log: self, name: name, signpostID: id) }
            return try await block()
        }
    }
    
    Login or Signup to reply.
  2. 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search