OK, yep, thanks so much for your suggestions but I actually tried that route already too.
I've gone a bit deeper and I think my example above was overly simplified and didn't actually capture the real issue here, apologies for that. I actually think it might be related to actor isolation and protocols but I'll get to that in a moment.
First off maybe it would help if I explained what I am actually trying to achieve and then hopefully the issue will be clearer.
In my actual use case I have a subsystem (isolated to a global actor) that is responsible for controlling external devices, lets say light bulbs for a concrete example.
I want to be able to loop through a list of Lights and perform some action such as turning them on. That action is asynchronous and might take a period of time due to network etc.
There are several non-sendable types involved so it's important that all the logic happens on the global actor but obviously with suspension points for async stuff like network calls.
Here is an example of some code that works for this:
@globalActor public actor LightActor: GlobalActor {
public static let shared = LightActor()
}
@LightActor class Light {
func turnOn() async {}
}
@LightActor class LightingManager {
var lights = [Light(), Light()]
func turnLightsOn() async {
await withEachLight() { light in
await light.turnOn()
}
}
private func withEachLight(block: @LightActor (Light) async -> Void) async {
for light in lights {
await block(light)
}
}
}
However in this code each light will be turned on sequentially one after the other. I would like the same code to turnOn()
in parallel across many lights. (I understand that the actor isolation means the code won't run in parallel but the slow network requests should.)
I want the same basic semantics as the code above still, so not sending anything off the actor but just moving the loop to not block waiting on each lights activity. So I moved the loop into a task group, this allows me to run tasks (on the same actor) for each light.
@globalActor public actor LightActor: GlobalActor {
public static let shared = LightActor()
}
@LightActor class Light {
func turnOn() async {}
}
@LightActor class LightingManager {
var lights = [Light(), Light()]
func turnLightsOn() async {
await withEachLight() { light in
await light.turnOn()
}
}
private func withEachLight(block: @LightActor @escaping (Light) async -> Void) async {
await withTaskGroup(of: Void.self) { group in
for light in lights {
group.addTask {
await block(light)
}
await group.waitForAll()
}
}
}
}
All good, this compiles and runs as expected.
But now, a new requirement, I have different types of lights so I want to introduce a protocol to abstract that away:
@globalActor public actor LightActor: GlobalActor {
public static let shared = LightActor()
}
@LightActor protocol Light {
func turnOn() async
}
@LightActor class Bulb: Light {
func turnOn() async {}
}
@LightActor class LED: Light {
func turnOn() async {}
}
@LightActor class LightingManager {
var lights: [any Light] = [Bulb(), LED()]
func turnLightsOn() async {
await withEachLight() { light in
await light.turnOn()
}
}
private func withEachLight(block: @LightActor @escaping (any Light) async -> Void) async {
await withTaskGroup(of: Void.self) { group in
for light in lights {
group.addTask {
await block(light)
}
await group.waitForAll()
}
}
}
}
At this point I get the error:
error: global actor 'LightActor'-isolated value of type '() async -> Void' passed as a strongly transferred parameter; later accesses could race
Is it somehow functionally different that I switched to a protocol here? If not then how do I prove to the compiler that it's safe? If it is different then what other options do I have for structuring this code to get my desired results?