Is it possible to use withTaskGroup and maintain GlobalActor isolation?

I have a global actor in my project. A class is isolated to it. At multiple points in the class I need to do some work long running tasks across a pool of values (mostly network requests in this case). The tasks should run concurrently but inherit the global actor context. Since updating to Xcode 16 beta 5 I thought this would be possible using the isolation on withTaskGroup but I can't seem to get it to work.

I have tried to produce a very minimal example that highlights the issue I am seeing. I am assuming I am holding it wrong but it would be great to understand the error and how to fix it.

@globalActor public actor MyActor: GlobalActor {
  public static let shared = MyActor()
}


@MyActor class MyClass {
  var itemMap = [1: "Hello", 2: "World"]
  
  func printItems() async {
    await withEachItemValue([1,2]) { value in 
      // needs to be isolated to MyActor
      await self.post(value) 
    }
  }
  
  private func post(_ value: String) async {
    // assume some long running suspension here, like a http request
    print(value)
  }
  
  private func withEachItemValue(
    _ items: [Int],
    _ block: @escaping (String) async -> Void
  ) async {
    await withTaskGroup(of: Void.self) { group in
      for item in items {
        if let value = itemMap[item] {
          group.addTask {
            await block(value)
          }
        }
      }
  
      await group.waitForAll()
    }
  }
}

The compiler error is:

group.addTask {
      `- error: global actor 'MyActor'-isolated value of type '() async -> Void' passed as a strongly transferred parameter; later accesses could race
  await block(value)
}

Am I trying to do something dangerous here?

block should be @Sendable so that compiler knows it is safe to run it concurrently to other code.

1 Like

Yeah OK, sorry I should have mentioned having played with this. Using @Sendable for the closure fixes this specific error but now the block is no longer isolated to the global actor so any property accesses need to be awaited and any captured vars need to be sendable. I am specifically trying not to send the closure, just have it run in the current global actor isolation.

I think I can make the compiler happy about it with:

await withEachItemValue([]) { @MyActor value in  ... }

at every call site but I this feels wrong and maybe I am misunderstanding the correct way to structure this?

If you need to forcefully maintain isolation in the passed closure, you can then mark closure isolated to your global actor. If you are using the latest toolchain, Swift will also infer that it is @Sendable. Your option to specify isolation on the call site also one of possible solutions.

1 Like

Sorry, I think this might be what I'm after but I'm not quite following what syntax you are proposing I use here? Are you able to give an example?

The same way you've isolated MyClass on MyActor, you can isolate any closure on any global actor, like:

private func withEachItemValue(
    _ items: [Int],
    _ block: @escaping @MyActor (String) async -> Void
) async
1 Like

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?

1 Like

That's a bit odd. You can fix this by modifying protocol declaration to @LightActor protocol Light: Sendable. But compiler warnings makes no sense to me, it should be safe, and Light has to be inferred as Sendable. That's also something new, as I've had beta 3 of Xcode and it was saying that Light isn't Sendable: while it also kinda confusing, at least it makes it obvious how to fix.

I've found some issues with the same error message, they aren't similar to your case, but have also odd behaviour:

It makes sense to file an issue with your code sample on GitHub, it looks like bug to me.

2 Likes

Ah amazing, this fixes it for me and gets things compiling again. Thanks @vns for your help.

There's definitely something a bit odd going on here though, I will file an issue.

1 Like

Issue filed here: Global Actor isolation on a protocol fails to infer sendable and leads to misleading error · Issue #75757 · swiftlang/swift · GitHub

2 Likes