Non-sendable type '() throws -> ()' exiting actor-isolated context in call to non-isolated instance method 'perform(schedule:_:)' cannot cross actor boundary

Hi,

Overview

When I execute the code (given below) I get the following warning:
Non-sendable type '() throws -> ()' exiting actor-isolated context in call to non-isolated instance method 'perform(schedule:_:)' cannot cross actor boundary

Questions:

  1. What should I do to fix the warning?
  2. Is this a bug in my code or the framework?

Environment:

Xcode: Version 14.2 (14C18)
macOS: 13.2.1 (22D68)

Build Setting

Strict Concurrency Checking is set as Complete

Code

import CoreData

actor Car {
    func f1(context: NSManagedObjectContext) async {
        await context.perform {
        }
    }
}
  1. You can't "fix" the warning. You're doing something concurrency-unsafe. If you want it to be safe, you have to do something else instead. :slightly_smiling_face:
  2. It's not a framework bug. It's not even really a bug in your code, but rather a design problem.

The reason it complains is that the closure to perform would otherwise be able to expose mutable state of the actor to code outside the actor:

actor Car {
    var risky: Int
    func f1(context: NSManagedObjectContext) async {
        await context.perform {
            risky = 0
        }
    }
}

If you don't actually need to operate on any of Car's protected mutable state, then you can say so:

actor Car {
    nonisolated func f1(context: NSManagedObjectContext) async {
        await context.perform {
            doSomethingUnrelatedToCar()
        }
    }
}

However, even then you have a problem if context has to cross a concurrency boundary to be passed into f1, I doubt that a managed object context is Sendable, so you may have another parcel of trouble in that regard.

1 Like

Thank you so much @QuinceyMorris

Please bear with me as I try to understand a better. Sorry if my questions sound silly, just trying to understand as often this part trips me.

Just trying to confirm my understanding.

Edited
I understand context.perform { } would be executing code in the context's thread.

Questions:

  1. But how did the compiler catch context.perform { } would be leaving the actor boundary ?
  2. Is it because the closure .perform is an async function (could suspend and execute in different threads) and accepts a non-sendable closure?

For example (trying to confirm my understanding):

func test1(closure: @escaping () -> ()) async {
}

func test2(closure: @Sendable @escaping () -> ()) async {
}

actor Car {
    func f3() async {
        // Non-sendable type '() -> ()' exiting actor-isolated context in call to non-isolated global function 'test1(closure:)' cannot cross actor boundary
        await test1 {
        }
    }
    
    func f4() async {
        // No warning
        await test2 {
        }
    }
}

Code in the actor crosses out of the actor boundary when it hits a suspension point. await is always a (potential) suspension point, and for actors, entry to and exit from isolated functions is also a suspension point.

So yes, when the compiler saw your await, it knew you were crossing the actor boundary.

In your later example, making the closure @Sendable ensures that it doesn't carry any non-Sendable values across the boundary, only Sendable values. Sendable values are safe in this case because they cannot change the internal mutable state of the actor.

1 Like

@QuinceyMorris Thanks a lot for that detailed explanation.

I guess the problem in the original code is because the perform's closure is non-sendable it potentially allows the capturing of non-mutable variable (declared outside the closure) causing it to be unsafe when invoked from an actor (isolated).

I wish there was an overloaded function perform that accepted a @Sendable closure.

Did you try (in your code's equivalent of the function f1) making f1 nonisolated? That should move the Sendable requirement "up" one level, so that perform's lack of sendability isn't an issue.

(I'm not sure that will work, but that's what I'd try next.)

1 Like

Yes you are right, making f1 non isolated removes that issue with Sendable.

May be this seems like an XY problem on my part, my bad. Apologies.

I better explain what I intended to do.

Real objective:

  • I have a function f1 that modifies some objects in a context
  • This function could be called from different threads
  • It might be called in parallel via a notification, so wanted to synchronise it

Late realisation (I could be still wrong):

Now after explaining it I think (I could be wrong) I don't need an actor in the first place and the below could be safe to be called from different threads because context.perform { } takes care of that so I don't need to do anything special.

Real code

class Car {
    
    // This function could be called from places, could be called from multiple threads
    func f1(context: NSManagedObjectContext) async {
        await context.perform {
            // insert / update / delete some objects
        }
    }
}

This is more a question about NSManagedObjectContext, but a quick glance at the documentation suggests that performed closures are run with an internal queuing mechanism that sounds a lot like what an actor does. In that sense, context and perform may be thread-safe.

It's not a small question, though. NSManagedObjectContext might be safe to use in isolation, but if (hypothetically) you stored an instance of it in class Car along with some of the earlier results of getting data from the context, then you might still want an actor to protect the combination of the context and the associated cached data.

For types like NSManagedObjectContext that have asynchronicity and concurrency semantics outside of Swift concurrency, it's always going be hard to be sure that the two kinds of concurrency will inter-operate safely.

1 Like

That is a very good point, thank you so much for highlighting the potential risks of storing managed objects as properties in the class.

A lot of things to consider, luckily I am not storing the managed object in the class (could have very easily accidentally done that, thanks!!).

IMHO I think best to access managed objects inside the context.perform { } to be safe.

Sorry for taking so much of your time, I learned a lot with your answers. Thanks once again!!!