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