NSManagedObjectContext perform Sending value of non-Sendable type

Someone can explain a case where the same code written differently can be compiled successfully or with an error

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool  {
        Task{
            let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType);
           // error:   Sending main actor-isolated value of non-Sendable type '() -> Int' to nonisolated callee risks causing races in between main actor-isolated and nonisolated uses
            _ = await context.perform(schedule:.enqueued){ 1 }
                    
        }
}

but the same code written differently is successfully compiled

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        Task{
            let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType);
            await foo(context)
        }
        return true
    }
    
    nonisolated private func foo(_ context: NSManagedObjectContext) async{
        _ = await context.perform(schedule:.enqueued){ 1  }
    }

I'm not 100% sure the below is correct, but here's my guess.

The central difference between your two code samples is that the closure is formed in different isolation domains.

In your first example, the compiler needs to send the closure { 1 } across an isolation boundary (from the main actor to non-isolated — the closure is main-actor-isolated because it inherits the isolation from the context where it is declared). This is an error because the closure is not Sendable. You can fix this by explicitly marking the closure @Sendable, like so:

_ = await context.perform(schedule:.enqueued){ @Sendable in 1 }

In your second example, the closure is formed in a non-isolated context and therefore doesn't inherit the main actor isolation from its surrounding context.

Other notes:

  1. Arguably, the compiler in the first example should be able to see that the closure doesn't access any actor-isolated state and therefore infer it as @Sendable. I don't know if the compiler could conceivably do this in the future or if there are fundamental arguments against it.

  2. Alternatively, the compiler in the first example should be able to see that the closure isn't used in the caller's context (via region-based isolation) and therefore allow sending a non-Sendable closure. I think(!) the compiler doesn't do this for functions because the closure could be called multiple times, but I don't really understand it.

  3. I thought that any main-actor-isolated type is automatically Sendable (because actors are implicitly Sendable), but apparently this isn't true for closures? Not sure.

3 Likes

AFAIK this is longstanding issue with Task initialization where it fails to check regions correctly.

i concur with Ole's assessment as to the primary cause of the issue. one additional point perhaps worth highlighting is that the two variants will have different runtime behavior under the current language semantics, so are not quite 'the same code written differently'.

in the first case, when the Task executes, it will run on the main actor, create the managed object context and enqueue the operation. in the second, the Task begins its execution on the main actor, but then it suspends and execution continues in a concurrent execution context to run the nonisolated function, which then enqueues the work on the managed object context.

in this example the difference is probably immaterial since the relevant code runs only once, but it can be important in cases where such code paths can be run multiple times and the ordering of execution matters.

2 Likes