skip to Main Content

I wanted to make a simple function that uploads only those images that follow a certain order. I tried using Task Groups for this as that way I can return back to the suspension point after all child Tasks have completed. However, I ran into an error I don’t understand.

class GameScene: SKScene {
    var images = ["cat1", "mouse2", "dog3"]
    
    func uploadCheckedImages() async {
        await withTaskGroup(of: Void.self) { group in
            for i in images.indices {
                let prev = i == 0 ? nil : images[i - 1]  // << Error: Actor-isolated property 'images' cannot be passed 'inout' to 'async' function call 
                let curr = images[i]  // << Error: Actor-isolated property 'images' cannot be passed 'inout' to 'async' function call
                if orderIsPreserved(prev ?? "", curr) {
                    group.addTask { await self.uploadImage(of: curr) }
                }
            }
        }
    }
    
    func orderIsPreserved(_ a: String, _ b: String) -> Bool {
        return true
    }
    
    func uploadImage(of: String) async {
        try! await Task.sleep(for: .seconds(1))
    }
}

I have a handful of questions related to this error.

  1. Why does a SKScene subclass raise this error? When I don’t subclass SKScene this error disappears. What’s so special about SKScene that raises this error?

  2. Where is the Actor and why only Task Groups? Isn’t this a class? I thought it may have to do something with "Oh a task has to guarantee so and so things" but when I switch withTaskGroup(of:_:) to a regular Task { }, this error again disappears. So I’m not sure why this is only happening with Task Groups.

  3. Can I ease the compilers worries about it being passed as inout? Since I know that this function isn’t altering the value of images, is there any way I can ease the compilers worries about "don’t pass actor-isolated properties as inout" (sort of like using the nonmutating keyword for structs)?

2

Answers


  1. Why does a SKScene subclass raise this error?

    Where is the Actor?

    If you go up the inheritance hierarchy, you’d see that SKScene ultimately inherits from UIResponder/NSResponder, which is marked with a global actor – the MainActor. See from its declaration here.

    @MainActor class UIResponder : NSObject
    

    That’s where the actor is. Since your class also inherits from SKScene, which ultimately inherits from UIResponder, your class also gets isolated to the global actor.

    why only Task Groups?

    It’s not just task groups. A more minimal way to reproduce this is:

    func foo(x: () -> Void) {
        
    }
    
    func uploadCheckedImages() async {
        foo {
            let image = images[0]
        }
    }
    

    Can I ease the compilers worries about it being passed as inout?

    Yes, there are a lot of way, in fact. One way is to make a copy of the array:

    func uploadCheckedImages() async {
        let images = self.images // either here...
        await withTaskGroup(of: Void.self) { group in
            // let images = self.images // or here
            // ...
        }
    }
    

    Making images a let constant also works, if you can do that.

    How is a race-condition possible without any writes?

    I think the compiler is just kind of being too restrictive here. This may or may not be intended. It seems like it’s reporting an error for every l-value captured in the closure, even when it’s not being written to. This error is supposed to be triggered in situations like this.

    Your code is fine. If you add an identity function and pass all the l-value expressions into this function, so they no longer look like l-values to the compiler, then the compiler is perfectly fine with it, even though there is absolutely no functional difference.

    // this is just to show that your code is fine, not saying that you should fix your code like this
    
    // @inline(__always) // you could even try this
    func identity<T>(_ x: T) -> T { x }
    
    await withTaskGroup(of: Void.self) { group in
        for i in images.indices {
            let prev = i == 0 ? nil : identity(images[i - 1])
            let curr = identity(images[i])
    
    Login or Signup to reply.
  2. You can avoid this problem by giving your task group a copy of the array in question, avoiding accessing properties of the class from within the withTaskGroup closure.

    One easy way to do this is to use a capture list, replacing:

    await withTaskGroup(of: Void.self) { group in
        …
    }
    

    With:

    await withTaskGroup(of: Void.self) { [images] group in
        …
    }
    

    Note, one generally does not need to worry about the efficiency of this “copy” process (whether achieved with the capture list or by explicitly assigning the value-type array to a new local variable), because it cleverly employs the “copy on write” mechanism behind the scenes, completely transparent to the application developer.

    With “copy on write”, it really only copies the array if necessary (i.e., you mutate or “write” one of the arrays), and otherwise gives you “copy”-like semantics without incurring the cost of actually copying the whole collection.


    You can also avoid this problem by letting the compiler know that the original array can not mutate, e.g., replacing:

    var images = […]
    

    With

    let images = […]
    

    Obviously, you can only do this if the images really is immutable.

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