Super excited about this pitch. In the server ecosystem we have been hooking the global and main executors for some time now. I have some questions about the details of the proposal.
SerialRunLoopExecutor
Do we really need the SerialRunLoopExecutor
protocol? The only place we use it in public static var executor: any SerialRunLoopExecutor
but that seems like it could just be defined as public static var executor: any (RunLoopExecutor & SerialExecutor)
. Is there a benefit of having the protocol here?
Default executor
extension Task {
/// The default or global executor, which is the default place in which
/// we run tasks.
///
/// Attempting to set this after the first `enqueue` on the global
/// executor is a fatal error.
public static var defaultExecutor: any Executor { get set }
}
Should this property rather be any TaskExecutor
? This way we could use it with all the APIs that take a taskExecutorPreference
like withTaskExecutorPreference
.
Replacing the main & default executor
The defaultExecutor
and MainActor.executor
properties are both settable and the proposal says they must only be set before the first enqueue. That makes sense I'm interested in how this is enforced though. Is this something that the runtime enforces? Also could we add a small section to the proposal showing this being done?
Adding a Task.preferredTaskExecutor
I know this is a bit orthogonal to this proposal but currently there is no way to get the preferred task executor for the current task. There are some escapes on UnsafeCurrentTask
but those are often not usable. For example in swift-async-algorithms
we are required to spawn a few unstructured tasks and need to inherit the task executor of the caller Support task executors · Issue #341 · apple/swift-async-algorithms · GitHub. My understanding is that we should be able to provide a Task.preferredTaskExecutor
property now without going into unsafe land. cc @ktoso
Private storage
/// Storage reserved for the scheduler (exactly two UInts in size)
var schedulerPrivate: some Collection<UInt>
If this is only two UInts in size why don't we provide this as a tuple of them?
Naming nit: Shouldn't this be called executorPrivate
or rather executorPrivateStorage
?
ExecutorJobKind
Can we make this a nested struct inside the ExecutorJob
so it is ExecutorJob.Kind
?
I was also wondering if we could have used an enum
with associated values here so that we could move all of the kind specific APIs to that. Something like this
switch executorJob.kind {
case .task(let task):
task.allocate(...)
}
Memory management APIs
I would love to understand those more and how those are expected to be used. Who is going to call the various allocate/deallocate methods? If an executor should do this could we provide some samples how that's done? I was kind of expecting those APIs on Executor
since I was assuming the Task
itself calls the executor then but this looks like it is the other way around.
Building support for clocks into Executor
In reality, timer-based scheduling can be handled through some
appropriate platform-specific mechanism,
While this is true. Often systems want the executor to also be responsible for the timer scheduling since it allows for optimisations in the executor instead of delegating to an external system to then inform the executor to enqueue the job. For example in NIO's event loop we manage our own event_fd and kqueue/epoll to handle the execution of jobs but also to handle execution of jobs at a specific time.
As it stands when calling Task.sleep
we are always going through the global default executor even though we have a task preferred executor set. This already turned out problematic in server applications since it causes unnecessary context switches.
I understand that clock handling is a complicated topic and it probably deserves its own proposal.