Apologies for the very late review.
I think the overall direction is uncontroversial, but I have some questions/concerns about the proposed design.
Executor:
-
The call to
ExecutorJob.runSynchronously(on:)
must happen-after the call toenqueue(_:)
.I don't know what this means - I thought it meant the enqueued job must happen after the call to
enqueue
returns. But later in the proposal it says that you can execute jobs synchronously inside ofenqueue
(it's not recommended, but you can). -
(Editing) The section about the
Executor
protocol (link) starts with:First, we introduce an
Executor
protocol, that serves as the parent protocol of all the specific kinds of executors we'll discuss next. It is the simplest kind of executor that does not provide any ordering guarantees about the submitted work.And ends with:
Executors are required to follow certain ordering rules when executing their jobs
It's difficult to follow and I would recommend that this gets tidied up for the benefit of future readers. The ordering rules for serial executors should be part of the serial executor section as it refines the looser
Executor
behaviour.
SerialExecutor:
-
I'm not entirely happy with the design of the
asUnownedSerialExecutor()
requirement, and I wonder if it could be modelled better.It took a little while for me to understand what this is supposed to do; initially I thought this would basically exclude executors which needed to own (and clean up) their execution resources, but the critical piece of information is over in the
UnownedSerialExecutor
type:/// Generally there are extra constraints imposed on core operations /// in order to allow this. For example, keeping an actor alive must /// also keep the actor's associated executor alive; if they are /// different objects, the executor must be referenced strongly by the /// actor.
IIUC this is essentially just
Unmanaged.passUnretained
on the executor (which any caller could easily do themselves), except that it stores an extra bit to tell the runtime how to perform same-context checks. That extra bit is all that this requirement really provides, isn't it?I don't think this is the most obvious design to express that. The word "as" makes it feel like I'm performing some kind of conversion, when I'm not, or that there's some kind of special process the executor needs to perform in order to create that unowned reference (like performing an unbalanced retain on itself), when there isn't - we just need a particular piece of data from it which isn't really related to the idea of an unowned reference.
I think this could be improved by replacing it with a requirement which specifically returned the same-context checking bit, either as a
Bool
or enum. I think it would be more logical, and make it easier to ensure that it was correctly implemented. Consider that if I implementisSameExclusiveExecutionContext
, I will also need to update my implementation ofasUnownedSerialExecutor
to use thecomplexEquality
initialiser - that's a non-obvious change, but if that requirement was more clearly related to the context-checking function, it would be more obvious.Taking the initialiser call out of the
SerialExecutor
conformance also means we'd only need a single initialiser forUnownedSerialExecutor
, which would read this bit (and any bits we introduce in the future), and set the appropriate internal flags:protocol SerialExecutor: Executor { var hasCustomSameExecutionContextCheck: Bool { get } func isSameExclusiveExecutionContext(as other: Self) -> Bool } // or: enum ExecutionContextEqualityKind { case trivial case custom } protocol SerialExecutor: Executor { /* static? */ var executionContextEquality: ExecutionContextEqualityKind { get } func isSameExclusiveExecutionContext(as other: Self) -> Bool } ------- struct UnownedSerialExecutor { init(_ executor: some SerialExecutor) { switch executor.executionContextEquality { case .trivial: // ... implementation detail of UnownedSerialExecutor ... case .custom: // ... implementation detail of UnownedSerialExecutor ... } } // Only one initialiser needed }
(It's a bit of a shame that we need people to manually say whether they have a custom implementation of the same-context check. The language should provide a better way to model these kinds of requirements. In Obj-C you could make it optional and check
respondsToSelector
...) -
isSameExclusiveExecutionContext(other executor: Self)
I think the argument label (
other
) doesn't read very well; it should beisSameExclusiveExecutionContext(as other: Self)
.Also, I think the "exclusive" part could be clearer. Given this text in the proposal:
When performing the "is this the same (or a compatible) serial execution context" checks, the Swift runtime first compares the raw pointers to the executor objects. If those are not equal and the executors in question have
complexEquality
, following some additional type-checks, the followingisSameExclusiveExecutionContext(other:)
method will be invoked:I think the term used here ("serial execution context") would more clearly express that it is known the underlying execution resource will serialise jobs from each given
SerialExecutor
and prevent their jobs from executing in parallel.I also wonder if this is a function where there is no natural
self
, and whether it should instead be a static function taking 2 parameters (as the==
operator does). But that's a minor thing.
JobPriority:
- Does this type have any fixed values? Or do we just get the
rawValue
(aUInt8
) and assume bigger values have a higher priority than smaller values?
ExecutorJob:
-
Is ExecutorJob also
CustomStringConvertible
? Or onlyUnownedExecutorJob
? -
The proposal says:
A job's description includes its job or task ID, that can be used to correlate it with task dumps as well as task lists in Instruments and other debugging tools
Will we have to parse that out of the string (like
<Job ID=...>
? Can it be provided directly, maybe with a property nameddebugIdentifier
(to mirrordebugDescription
fromCustomDebugStringConvertible
)? -
(Future) Is it possible to inspect the task-locals associated with a job? I understand that it would have to account for non-task jobs, but I'd like to know if it's a possible future direction. I think it could be interesting to pass other metadata along to the executor besides the built-in priority levels.
UnownedExecutorJob:
-
Since the unowned version of
runSynchronously
does not validate its precondition (that the job has not already been executed), and violating that precondition can lead to undefined behaviour, I very strongly suggest that it include the word "unsafe" in its name.It is not enough to document that it can cause UB. The documentation should tell you how to use it correctly, but it must be abundantly clear at the call-site that extra consideration is required to audit the safety of code which uses it.
Actors:
-
I'm really uncomfortable with the
unownedExecutor
requirement for actors. I know that it already exists, but I think it goes against everything we've tried to achieve in Swift with regards to safety. In the best case, it's a pointer and we just hope that keeping the actor alive is enough to keep the executor alive as well; and in the worst case, you can return different executors every time and defeat all of the data race safety that we hope to achieve with language-integrated concurrency.I think it it worth exploring safer designs here. For instance, distributed actors have a synthesised
actorSystem
stored property which must be set. I think a similar design could work for the executor of a regular actor:distributed actor Greeter { init(name: String, actorSystem: ActorSystem) { self.name = name self.actorSystem = actorSystem // required (!) initialization of implicit actorSystem property } }
That way, we could guarantee the invariant that executors will live for as long as there are any actors which use them, that the executor cannot be changed after initialisation, and that all accesses to the synthesised property return the same executor instance. That's at least something.
If an actor does not explicitly initialise its executor, it would be initialised to a default executor.
Actor assertions:
- All fine, except that I think
preconditionIsolated
reads weirdly.assert
andassume
are verbs, so they read quite well, butprecondition
is not. PerhapsrequireIsolated
?