Is nonisolated(nonsending) on a function *parameter* mostly useless?

I'm trying to build an API something like MainActor.run, but it accepts an async closure, and isn't for the main actor. There can be multiple such actors, so it's not a global actor. I first tried doing something like this:

actor DBActor {
  func run(_ work: nonisolated(nonsending) async () -> Void) async {
    await work()
  }
}

await myDatabaseActor.run {
  // These safely run within the execution context of the database
  dbObject.modify()
  dbObject.save()
}

This works… but because async closures can be implicitly converted into nonisolated(nonsending) ones, there's a bit of a footgun here.

@MainActor func buttonPushed() {
  Task {
    await myDatabaseActor.run {
      // This closure *looks* like it should run on the database actor,
      // but actually runs on the main actor because it inherits @MainActor
      // from its declaration context. The @MainActor closure is implicitly
      // converted to a nonisolated(nonsending) one.
      // 
      // 🛑 These DB accesses are illegal, because they were performed
      // outside of the database execution context.
      dbObject.modify()
      dbObject.save()
    }
  }
}

I'd like to be able to say "the caller must pass a nonisolated closure; it must not have any inherent isolation that would cause it to hop to another execution context." But I don't see any way to do that right now.

  • I can declare the parameter nonisolated(nonsending), but that only affects how it gets called, not what the user can pass in.
  • I can't declare the parameter nonisolated; it's illegal to use bare nonisolated in that context.

The closest option I have right now is to force the closure to accept an (isolated DBActor) parameter, but that changes the API surface in an undesired way; now every closure passed to dbActor.run has to accept an actor argument that they're not going to do anything with (because none of the useful API surface is actually on the actor).

Is there any other way to do this? I think I want something like nonisolated(nonsending, noconversion), but that clearly doesn't exist right now.


What I'm actually trying to do

I'm trying to build some better concurrency safety around our existing Core Data usage. I've build a ContextActor, which has a custom executor that runs everything on the appropriate context. NSManagedObjectContext already has context.perform(_:), which accepts a synchronous closure. I want to build a version of context.perform that can accept an async closure.

extension NSManagedObjectContext {
  func performAsyncBlock(_ work: nonisolated(nonsending) () async -> Void) {
    let contextActor = ContextActor(self)
    contextActor.run { 
      // This works correctly, and will run within the execution context
      await work()
    }
  }
}

@MainActor func buttonPushed {
  let context = somePrivateQueueContet

  context.performAsyncBlock {
    // 💥: This doesn't work; the closure is implicitly `@MainActor`, so 
    // the context is being accessed on the main queue, not the 
    // context's private queue
    let objects = context.fetch(someQuery)
  }
}
1 Like

Are you sure it works? It shouldn't because nonisolated(nonsending) should only be used with async function. As a result, run should be an async function too. If you are trying to implement a synchronous API, I don't think that's possible.

You're right, that was just a typo; there should be an async on the closure. I'll fix it in the original post to avoid confusion.

actor DBActor {
  func run(_ work: nonisolated(nonsending) () async -> Void) async {
    await work()
  }
}

As run has to be async, you can't call it directly in widget event handler unless you wrap it in Task. From what I read in the forum a better approach is to move the task to a dedicated actor:

  • Create an actor. Start a task in it to handle async events.
  • Create an async sequence in the actor. It generates the async events.
  • Define a set of nonisolated synchronous API in the actor. These API generates async events using Continuation.
  • Call these APIs in widget's event handlers.

See a related post here (Is this a good use of Task.immediate?).

With the newer version of DBActor.run(), I think the above won't compile either. As myDatabaseActor.run's closure runs in myDatabaseActor isolation, it should uses await to call dbObject's methods.

-      dbObject.modify()
-      dbObject.save()
+      await dbObject.modify()
+     await dbObject.save()

It's unlikely to introduce isolation mistakes in code like this (though I understand wrapping async code in task isn't necessarily what you expected).

dbObject isn't an actor. It's some other non-Sendable object that can only be safely used from that database's actor isolation context, presumably one that was created earlier. There's no way in the type system right now to isolate an instance to a particular actor; we'd need something like this:

class DBObject {

  // This object can only be used from its DBActor's isolation
  let isolatingActor: isolated DBActor

  func modify() {}
}

The type system can't express that, but conceptually that's what would want here — I have a DB object that has synchronous functions, which should only be accessed from within the database context, but the type system can't enforce that (other than by enforcing general non-sendability) constraints.

This is actually the problem, though — myDatabaseActor.run's closure doesn't run in myDatabaseActor's isolation. It runs in the Main Actor isolation. But the compiler doesn't warn me about that; it silently wraps the @MainActor closure in a nonisolated closure that just hops over to the main actor.

I don't know if this actually helps with your problem, but you can to some extent "cut off" closure isolation inference like this by using sending or @Sendable in the type signature. For example:

actor DBActor {
  // yes, yes 'sending ... nonsending' is confusing...
  func run(_ work: sending nonisolated(nonsending) async () -> Void) async {
    await work()
  }
}

@MainActor
func mainActorFn() async {
  await myDatabaseActor.run {
    // Now this is inferred as nonisolated(nonsending)
  }
}

Now this isn't perfect, since a client could still provide a closure with different isolation (e.g. by using an explicit global actor annotation) and it gets converted, but it might still be useful. But also, if you're exposing an API where clients supply the async closure, they obviously can perform whatever "hops" they want within it, and you'll have essentially no real control over that.


As a side-note about your motivating use case: I assume you're aware of this and embarking down this path for good reasons, but making an analog of context.perform() where the closure may suspend will potentially expose you to new and exciting bugs due to actor reentrancy. It might be worth pondering why CoreData does not itself expose such an API.

5 Likes

Jamie is correct in that you can use sending or @Sendable to forbid implicit static isolation. However, if you pass an explicit global-actor-isolated closure, it will simply hop onto its executor. Also, it’s important to mention there was a bug until recently where nonisolated(nonsending) parameters that are also either sending or @Sendable didn’t correctly hop to the caller’s executor.

Overall, I would advise against your idea here. Rather, I would implement the operations directly on an actor.

P.S. I was thinking about opening a discussion on isolation hierarchy. Let me know if anyone is interested.

If so, I don't think it's possible to access it synchronously. Whether the closure is inferred as MainActor first and then wrapped in nonisolated(nonsending) or it's inferred as nonisolated(nonsending) directly (that means nonisolated at compile time), it shouldn't be able to access dbObject in DBActor instance isolation synchronously. Actually I wonder how it could be free variables if it's isolated to a DBActor instance? Can you give a complete code sample? (It' s OK it can't compile).

Huh. sending nonisolated(nonsending) does seem to do what I'm looking for. Thanks! I agree it's a nonintuitive spelling, but at least there's a way to spell it.

For new work going forward, perhaps. In my case, I have a huge existing codebase that I'm trying to pull toward safe concurrency patterns, and moving everything into actors would be a much larger refactor than what is feasible in an incremental step. And it's not clear to me that having everything I want to do with my model objects live inside an actor is actually a good design pattern. I feel like I'd have my entire app living inside one actor. But maybe it's possible. I'd have to build it out more to try it out. But it's clear to me that it's not feasible to do right now in my existing codebase.

If callers explicitly global-actor-isolated closures, then they're at least being explicit about it, and it's something I can deal with for now.


Core Data Specific Discussion

Regarding actor reentrancy and Core Data — consider that you already have to deal with this on main-actor functions:

@MainActor func doThings() async throws {
  let person = Person(context: mainContext)

  // Suspending here
  person.name = await person.calculateName() 

  try mainContext.save()
}

I didn't have to use context.perform here at all, and I already have to deal with actor reentrancy. You do have to be aware of where things are suspending, but that's not really unique to Core Data.

What I really want is to be able to define async functions directly on Managed Object subclasses:

class Attachment: NSManagedObject {
  @NSManaged var storedFilename: String?

  nonisolated(nonsending)
  func importData(from url: URL) async throws {
    let data = try await readData(from: url)
    let url = calculateStorageURL(from: url)
    try data.write(to: url)
    self.storedFilename = url.lastPathComponent()
  }
}

// Somewhere else…
context.performAsyncBlock {
  let attachment = Attachment(context: context
  await attachment.importData(from: someURL)
  context.save()
}

There are all sorts of places where async functions on a managed object might be useful, but until nonisolated(nonsending), it was extremely difficult to do so, because every async function would jump away from the execution context where it was called, so every single async function had to use context.perform to jump back onto its context… but then you couldn't call anything async from within context.perform, because it only accepts an async block. It all works fine if you make everything MainActor-isolated, but that's unnecessarily limiting.

1 Like

Could you explain what "implicit static isolation" means and how it affect this case?

If so, I don't think it's possible to access it synchronously.

I promise it's possible to do so. I have lots of existing, compiling code that is doing this. Here's an example, using Core Data.

Code example
class Person: NSManagedObject { 
  @NSManaged var age: Int

  func haveBirthday(gift: Present) {
    age += 1
    print("Thanks for the \(gift.name)")
  }
}

actor ContextActor {
  // This actor has a custom serial executor that runs all
  // of it's work inside `context.perform`. Not shown here
  // for brevity
  let context: NSManagedObjectContext

  func run(_ work: nonisolated(nonsending) () async -> Void) async {
    await work()
  }
}

extension NSManagedObjectContext {
  func performAsyncBlock(_ work: nonisolated(nonsending) () async -> Void) async {
    let actor = ContextActor(context: self) 
    await actor.run {
      await work()
    }
  }
}

func doit() async {
  let dbContext = persistentContainer.newBackgroundContext()
  let contextActor = ContextActor(context: dbContext)

  contextActor.performAsyncBlock {
    // Insert a new 'Person' into the dbContext. It would be
    // a runtime error to call this from the wrong thread, which
    // is why I need `performAsyncBlock` to ensure that it runs
    // on the correct context.
    let person = Person(context: dbContext)
    
    // Do something asynchronous
    let present = await fetchPresent()

    // Here I am, synchronously calling a function on my 
    // database object. No 'await' needed'
    person.haveBirthday(gift: present)

    try! dbContext.save()
  }
}

The bug fix is only on main so far, but I assume it will be part of 6.4, so make sure you have solid tests to confirm you’re using the correct executor.

nonisolated(nonsending) will become the default at some point. The spelling does seem odd at first, but it’s quite good when you think about it.

Yep, we've got existing tests. That's how I found this one, actually.

Oh, I'm very much looking forward to nonisolated(nonsending) being the default, and I'm hoping to enable the upcoming feature flag to make it the default when possible. (Gotta wait for bugs to be fixed, clearly; we're staying on Swift 6.2 until the fixes are out.)

The spelling that I think is weird is sending nonisolated(nonsending). Is it sending? Or is it not?

1 Like

The closure is sent as a value, but it does not send its arguments or return value to a different isolation domain.

This is basically what we are currently discussing here: Isolated closures and @concurrent functions.

Explicit static isolation:

let c1: (isolated SomeActor) -> Void = { _ in }

let c2: @SomeGlobalActor () -> Void = {}

Implicit static isolation:

actor A {
  func f() {
    let c3: () -> Void = { _ = self } // implicit `isolated` self captured
    print(type(of: c3)) // () -> Void
  }
}

@SomeGlobalActor
func ff() {
  let c4: () -> Void = {} // implicitly isolated to @SomeGlobalActor
  print(type(of: c4)) // () -> Void
}

Although the isolation of c3 and c4 is not visible in the type system, they are statically isolated.

I think I understand why this behavior happens with nonisolated(nonsending) closures that are not marked sending or @Sendable. However, I don't like it at all.

It seems to me that at the point of closure invocation, it must be true that a nonisolated(nonsending) function does not cross a boundary. And yet in this case it does?

I think it's technically true that it does not cross a boundary, but in practice if you just wrap a single call in a nonisolated(nonsending) wrapper, then you haven't really done anything. E.g.

@MainActor
func runOnMain() async {
  print("on main")
}

func runAnywhere(_ a: isolated (any Actor)?) async {
  let ninsThunk: nonisolated(nonsending) () async -> Void = {
    await runOnMain()
  }

  // Calling this...
  await ninsThunk()

  // ... is effectively equivalent to calling this...
  await runOnMain()

  // ... and is conceptually the same as an implicit conversion:
  let cvt: nonisolated(nonsending) () async -> Void = runOnMain
  await cvt()
}

Would you want something to behave differently than this?

1 Like

Actually yes this is a good point. And as I was playing around with this a little more, I realized that if you include non-Sendable arguments/return values to these functions, the missing sending becomes a compile-time failure. That seems appropriate to me.

Which makes me think that perhaps there's something about the original question that doesn't quite capture the nature of the issue. Because I cannot recreate this situation in a way that is problematic with a regular actor. I wonder if maybe I'm missing something.