Different behaviour on adding explicit actor isolation to `group.addTask`

Hi, I am running into an use-case where I am seeing different behaviour if I add global actor isolation explicitly to group.addTask. I have declared an actor with custom executor like this:

class SerialDispatchExecutor: SerialExecutor, @unchecked Sendable {
    let queue: DispatchQueue

    init(label: String) {
        self.queue = DispatchQueue(label: label, target: .global())
    }
    
    func asUnownedSerialExecutor() -> UnownedSerialExecutor {
        UnownedSerialExecutor(ordinary: self)
    }

    func enqueue(_ job: UnownedJob) {
        queue.async {
            job.runSynchronously(on: self.asUnownedSerialExecutor())
        }
    }
}

@globalActor
actor SomeActor: GlobalActor, Actor {
    static let shared = SomeActor(id: "global")
    @SomeActor
    static var instances: [String: SomeActor] = [:]
    
    @SomeActor
    static func addInstance(_ act: SomeActor, id: String) {
        instances[id] = act
    }

    var counter = 0
    nonisolated(unsafe) var unsafeCounter = 0
    let executor: SerialDispatchExecutor
    
    init(id: String) {
        self.executor = SerialDispatchExecutor(label: "my \(id)")
    }
    
    nonisolated var unownedExecutor: UnownedSerialExecutor { executor.asUnownedSerialExecutor() }

    func log(external: Int) {
        if counter != external {
            print("Counter \(counter) with \(external)")
        }
        counter += 1
    }
}

I have following code that tests the behaviour of actor:

await SomeActor.addInstance(SomeActor(id: "1"), id: "1")
return await withTaskGroup { group in
    for index in 0..<1000 {
        let task = Task(priority: (index % 2 == 0) ? .high : .low) { @SomeActor in
            await withTaskGroup { group in
                for (_,instance) in SomeActor.instances {
                    group.addTask {
                        await instance.log(external: index)
                    }
                }
            }
        }
        group.addTask {
            await task.value
        }
    }
}

This code prints the output out of order whereas with the DispatchQueue executor I should get output in order. But the output gets fixed once I change this to:

await SomeActor.addInstance(SomeActor(id: "1"), id: "1")
return await withTaskGroup { group in
    for index in 0..<1000 {
        let task = Task(priority: (index % 2 == 0) ? .high : .low) { @SomeActor in
            await withTaskGroup { group in
                for (_,instance) in SomeActor.instances {
                    group.addTask { @SomeActor in
                        await instance.log(external: index)
                    }
                }
            }
        }
        group.addTask {
            await task.value
        }
    }
}

Just adding @SomeActor isolation to group.addTask fixes this. From what I understand the closure accepted by group.addTask is marked with @isolated(any) which makes it to inherit actor context from parent. But this should have the same behaviour whether @SomeActor isolation is added explicitly or not. Can anyone explain why this difference?

Also, the 2nd code that actually works fails to compile with region based isolation: Region based isolation check fail on valid code · Issue #86180 · swiftlang/swift · GitHub

this is, i think, a fairly common misconception, but that is not what @isolated(any) does. the best discussion of this i've seen is here, and to quote a relevant bit:

without the explicit @SomeActor in the signature of the closures passed to group.addTask, those closures are not actor-isolated, so they begin execution on the global concurrent executor before switching to the custom executor for the actor-instance (hence the arbitrarily-ordered results).

as touched on here and here in that same thread, the intent of the taskGroup.addTask API specifically is to allow you to introduce concurrency between the parent and child tasks, so having those closures inferred to be isolated to their parent context by default would undermine the primary use case of the API.

one other thing that stood out to me in the example, which i imagine could also cause confusion, is that the SomeActor type is being used both as a global actor, and distinct instances are being created and used independently. in this construction, the shared instance and other instances do not share the same executor (each instance creates its own), so code running on one actor instance will not execute exclusively with code running on another (i think). additionally, the closure in code like this:

group.addTask {
    await instance.log(external: index)
}

is not isolated to the actor 'instance'. specifying that a closure be isolated to a specific actor instance is a feature that doesn't yet exist in the language.

3 Likes

+1 for @jamieQ reply. As @jamieQ points out, having a global actor type with an accessible initializer is quite confusing and considered an anti-pattern.

Although you could create an actor with an accessible initializer that is both a global actor and a “regular“ actor that would execute exclusively, but this would be an implementation detail.

1 Like

Thanks for the response @jamieQ. The use-case fro isolated(any) is clear to me now.

Regarding my actual use-case, I have a global actor that dispatches messages to individual actors. And yes, in the actual implementation global actor and individual actor types are different and all have their own executors. The individual actor instances process messages independently of each other.

I didn’t create multiple actors in the snippet I provided to not complicate the reproduction sample.

2 Likes