1

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)?

Sweeper
  • 213,210
  • 22
  • 193
  • 313
rayaantaneja
  • 1,182
  • 7
  • 18

2 Answers2

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])
Sweeper
  • 213,210
  • 22
  • 193
  • 313
  • Hey! Thank you so much for answering my questions. I would say I'm around 60-70% clearer about the whole thing. One question though (for now), if I did in fact want to change the content of the array, how would I be able to do that using Swift concurrency? Earlier I would do so using a completion handler; is there any way to do that using the new syntax? – rayaantaneja Mar 19 '23 at 06:58
  • @rayaantaneja I'm not sure what you mean. Either way I think that deserves its own post. Try looking for existing questions, and if you couldn't find any, post a new question with a [mcve] demonstrating the problem. – Sweeper Mar 19 '23 at 07:08
  • I meant if I wanted to modify the array, such as clearing it when the pictures are uploaded, would that be possible? Also sure I'll do some research and ask another question if I can't find answers. Thanks again! – rayaantaneja Mar 19 '23 at 07:10
  • @rayaantaneja Of course that is *possible*. Do you want to remove the one item at a time whenever an item finishes uploading? Or do you want to clear the entire array only after everything has been uploaded? Make sure you make details like this clear in your new question, if you end up posting one. – Sweeper Mar 19 '23 at 07:14
1

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.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Hey, thanks for letting me know about using capture lists in this situation. What if I **wanted** to mutate the array? How would I achieve asynchronously mutating the class (Like how I would be able to mutate it using a completion handler)? – rayaantaneja Mar 19 '23 at 06:56
  • No offense, you probably should post a separate question on that. Your post already has too many questions embedded in it. (Please, in the future, [limit yourself to a single question per post](https://meta.stackexchange.com/questions/222735/can-i-ask-only-one-question-per-post).) But, in short, we sometimes use actors. We sometimes can get away with a global actor qualifier. I can’t possibly answer this question in the abstract within the constraints of this comments section. Post a separate question with a practical example of the problem you are trying to solve… – Rob Mar 19 '23 at 07:12