[Pitch] Custom Actor Executors

Thanks for the detailed response! I think my example may have been misleading. DB was a really bad name for that actor :/. My real intensions here are to have a more ergonomic experience while using core data and async await. Transactions in core data are not typical DB transactions. All changes are made in memory and then committed usually at the end of a managed object contexts perform block by calling save. Usually one would spin up a completely new managed object context for every db operation. I think using custom executors to manage transactionality would be hard to achieve however I think it would be useful to ensure all database operations happen inside a managed object contexts perform block which is a runtime contract for core data. In the example I posted above I'm really only using an actor as a means to run code on a specific thread after a suspension point not really for isolation as a new one would be created for every "transaction" that needed to be performed. How every using an actor is currently the only way to specify what executor a block of code should be run on.

One day if we have a task level apis to specify an executor that were sticky after suspension points core datas apis might be able to be spelled differently.

Instead of

public func perform<T>(_ block: @escaping () throws -> T) async rethrows -> T

it would be possible to have

public func perform<T>(_ block: @escaping () async throws -> T) async rethrows -> T

This would kick off a new task that would run all it's code on the MOC executor. Apologies if this was off topic. Working with core data with async await isn't the easiest right now and it would be great if custom executors could ease some of that pain.

Though if you're "just" after thread-safety of accesses to such "database context object" then by having operations be methods on an actor you'd be hopping back to that actor always, even if the method was async; so any API that would vend API operations could be made not-Sendable and therefore be prevented to escape "the database actor" like John mentioned. Though you won't get "transactionallity" from just doing that because of reentrancy.

Another thing I wanted to clarify:

Task locals are orthogonal to execution contexts.

They set things on a task and wherever (on whatever executor) it happens to be running, the locals remain available to it. While I would not recommend using task locals to things like putting connections into them (because a missing one will be hard to debug), you're free to use them today for such things already – although I would not recommend using task for necessary for basic operation data, such as connections, and would recommend limiting their use to things like traces or metadata that enhances the context in which a thing executes.

This looks good. The use case I think about most is a library that requires particular threads / periodic thread-local-related cleanup calls as mentioned in the pitch.

It would nice to have another couple of words on The default global concurrent executor is currently not replacable if only to say whether this is still a future direction of the project or if you suspect replacing actor executors is sufficient.

2 Likes

Some scattered comments, mostly on API design:

The SomeType.asSomeOtherType... APIs are rather unusual per Swift API naming conventions. Usually, these are expressed as an initializer (e.g. String(_: Int)) and sometimes there's an equivalent property (Int.description).

It seems both could be fine here—SerialExecutor.unowned reads pretty nicely for example, so I'm unsure why the proposal goes for something more ad hoc than that. Indeed, later in the text, an example seems to show that TaskPriority(_: Job.Priority) exists, and yet Job.Priority.asTaskPriority is proposed as a distinct API.


What does the argument label mean in UnownedSerialExecutor.init(ordinary: SerialExecutor)? Are there extraordinary conversions from one to the other—and even if so, why does the "ordinary" one need a label here?


If Job.Priority is not meant to diverge in the values it can represent from TaskPriority (and it does not seem that it would make sense), then indeed one should alias the other. Or alternatively, how would one feel about just renaming this ConcurrencyPriority (leaving only a legacy type alias for TaskPriority)?


I was going to make a comment that runJobSynchronously(_: Job) could be runSynchronously(_: Job) or synchronouslyRun(_: Job) per API naming guidelines not to repeat the type of the argument. If as discussed above this is best made a consuming function on Job spelled runSynchronously then that naturally addresses the issue, though in that case it'd best have a label for the argument (Job.runSynchronously(on:) maybe?).


Where the design includes both preconditionTaskIsOnExecutor and assertTaskIsOnExecutor, is this not composable using the existing precondition and assert functions with a function Task.isOnExecutor(_:) -> Bool? Something like this at the use site:

precondition(Task.isOnExecutor(foo))
4 Likes

Thanks for the comments!

Partially some of those method names are what existed today and I did not venture to change them all, e.g. the asUnownedSerialExecutor but it's true that we should consider some cleanups here while at it. I'll reply one by one how viable etc they are :slight_smile:

That's one example of the existing APIs I didn't venture to change I guess, agreed that an unowned computed property could work well here.

This one I was the least sure about what to do with, since it's a bit future proofing going on here...

Technically a job can be not-a-task and could have other values here... at least in theory, because in practice they are the same today, and we do even store priority values outside the "well named ones" in there sometimes (!).

Since TaskPriority is an existing API, we cannot typealias it to JobPriority, but we could do the opposite (which reads weird but doesn't matter perhaps).

Personally I guess I'd perhaps suggest:

public struct JobPriority { ... }

extension TaskPriority {
  /// Convert this ``UnownedJob/Priority`` to a ``TaskPriority``.
  @available(SwiftStdlib 5.9, *)
  public init?(_ p: JobPriority) // e.g. JobPriority::Unspecified -> nil
}

and removing the as... conversions.

We are anticipating to use these for opting into some special runtime behavior for optimized switching where creating an unowned reference could promise the Swift runtime some specific characteristics about itself. So those would be "not ordinary" ones.

But as that is not completely designed or proposed yet so perhaps the right thing here is to propose an initializer without an init

I looked into this and thankfully we can make it a self consuming method on Job, so it'd be Job.runSynchronously(on:) indeed :+1: I will be updating the proposal to reflect that.

The intent here was to be able to provide a better error message without getting forced to expose an operation like get current serial executor -> SerialExecutor?. The precondition/assert implementations can try to print "expected ... but was ..." which I think would be valuable when things go wrong.

Another reason is that in some "dispatch queue as custom executor" situations we can't actually answer the "is the current executor this queue" because there is no supported API to ask this question to Dispatch, but there exists the dispatchPrecondition API which is reliable.

I'm still looking into what promises we're able to make about assertions (and the related assume... API) though. Please expect an update to the proposed assert/precondition/assume APIs soon.

5 Likes

I keep reading this and being curious, what other things could there be besides a Task? ^^

Currently, just a NullaryContinuationJob used for sleep and yield AFAIR, but one could imagine other things in the future. Either way, a job isn’t always a task, but a task is always a job.

1 Like

Thanks for all the comments on the pitch!

This pitch is now under review: SE-0392: Custom Actor Executors

5 Likes