[Pitch 4] Custom Main and Global Executors

This is a fourth pitch for Custom Main and Global Excecutors. The previous pitch included a delayed enqueuing proposal, which was separated out into SE-0505: Delayed Encoding for Executors.

Changes in this version:

  • The SchedulingExecutor and all discussion of Clocks was removed.
  • ThreadDonationExecutor was separated out from RunLoopExecutor, and MainExecutor now requires the former rather then the latter.
  • The "Alternatives Considered" section was updated.
5 Likes

I continue to be in support of this proposal. A few minor comments:

public static var preferredExecutor: (any TaskExecutor)? { get }

What executor is this returning? Is this returning the task executor preference set with the withTaskExecutorPreference or something else? If it is returning the former then I would prefer naming this preferredTaskExecutor instead and the documentation clearly the link between the two APIs.

/// true if this is the main executor.
var isMainExecutor: Bool { get }

This only returns true if the given executor is the actual used main executor not only if it conforms to MainExecutor, right?

Providing Task.currentExecutor

There is a concern that currentExecutor could in future be misused
to schedule jobs directly when using the #isolation feature would be
preferable (and potentially more performant).

I continue to think this concern is somewhat moot. The current executor in Swift right now is determined in this order: serial executor for the current isolation, task preferred executor, then the default executor. This proposal is adding API to retrieve the default and task preferred executor. The serial executor can already be extracted with existing public API by using a nonisolated(nonsending) method to get the #isolation. From the isolation you can get the actor, and on the actor you can call withSerialExecutor to get the executor. So user's can and most likely will replicate a currentExecutor method like this:

extension Task {
  static nonisolated(nonsending) func currentExecutor() async -> any Executor? {
    if let actor = #isolation {
      return actor.withSerialExecutor { $0 }
    } else if let taskPreferred = Task.taskPreferredExecutor {
      return taskPreferred
    } else {
      return Task.defaultExecutor
    }
  }
}

In my opinion, it's better if the Concurrency module provides such a method since it can be kept in-sync with any runtime changes to the executor logic.

4 Likes

Yes. I think I prefer the currently proposed naming (Task.preferredExecutor) because Task.preferredTaskExecutor seems a little tautologous. I honestly don't think anyone will be confused by this.

That's certainly the intent, though some MainExecutors might choose to simply return true if they have no other purpose.

I don't really want to re-open the debate on this at this point, as I think all that will do is further delay this proposal. It'll exist internally, because it's useful for the implementation, so if we want to make it public in future as part of a separate proposal, or if we want to expose it as SPI, we can do so.

2 Likes

Thanks for the update. With SchedulingExecutor removed from this iteration, what is the current recommended path for APIs that rely on time-based or delayed dispatching under custom global executors?

The SchedulingExecutor API surface was moved to SE-0505. There is a separate pitch for the latest iteration of that.

I haven't been following this pitch closely in its previous iterations, but I've been in this same area as part of the effort to improve concurrency for Embedded Swift as well as building out the platform abstraction layer for it.

My primary concern is that the mechanism proposed introduces additional indirection that will be prohibitive for Embedded Swift clients, as well as adding overhead for the non-embedded case. The most direct place this shows up is in the definition of the ExecutorFactory protocol, which is using existentials for defining custom executors:

/// An ExecutorFactory is used to create the default main and task
/// executors.
public protocol ExecutorFactory {
  /// Constructs and returns the main executor, which is started implicitly
  /// by the `async main` entry point and owns the "main" thread.
  static var mainExecutor: any MainExecutor { get }

  /// Constructs and returns the default or global executor, which is the
  /// default place in which we run tasks.
  static var defaultExecutor: any TaskExecutor { get }
}

The use of existentials means we will always have indirect calls whenever we're scheduling work on an executor, which is both a direct cost and an optimization barrier. Instead, this protocol could capture the two executor types in an associated type:

public protocol ExecutorFactory {
  associatedtype MainExecutorType: MainExecutor
  static var mainExecutor: MainExecutorType { get }

  associatedtype DefaultExecutorType: TaskExecutor
  static var defaultExecutor: DefaultExecutorType { get }
}

The canonical way to conform to this protocol would be something like this:

struct MyExecutorFactory: ExecutorFactory {
  static var mainExecutor = MyMainExecutor()
  static var defaultExecutor = MyTaskExecutor()
}

where we now have the ability to globally allocate the instances that back the main and default executor.

The proposal also notes that the existing "hook functions" will still be there for use by Embedded Swift:

As we are not proposing to remove the existing "hook function" API from Concurrency at this point, it will still be possible to implement an executor for Embedded Swift by implementing the Impl functions in C/C++.

I understand the desire to not bring these hooks into scope, but we should understand how the layers fit together before we add another potentially-incompatible one.

The existing hook mechanism is not great for Embedded Swift, or in general for static builds: you end up compiling in the default executors for the platform, then assigning some global function pointers to override them at runtime. So you pay an indirection (always) as well as the code-size cost for the default executor, and it's subject to mistakes if you assign at the wrong time.

The platform abstraction layer takes a different approach: the Swift code calls a set of pre-declared C entrypoints that aren't implemented in the Swift runtime at all. Instead, one links in an implementation of these C functions in the final binary. You get direct calls, no dead code from unused implementations, duplicate/conflicting overrides turn into link errors, and the ability to use LTO to "see through" the calls. The proposed mechanism for overriding the main and default executors can work along with this approach, by effectively spitting out @c entrypoints that call through the DefaultExecutorFactory where the DefaultExecutorFactory typealias is declared, e.g.,

@c
func _swift_task_enqueueGlobal(job: COpaquePointer) -> Void {
   DefaultExecutorFactory.defaultExecutor.enqueue(ExecutorJob(UnownedJob(job)))
}

The existing executors should be able to be linked in with the concurrency runtime and go through these hooks, or else we haven't expressed the whole of the interface.

For non-static builds, the Swift standard library has load-time mechanisms for replacing functions, and we should consider using it (or Swift's own dynamic-replacement machinery) rather than assigning to global function pointers, even if it means deprecating/removing the existing concurrency hooks. The module/translation unit defining the DefaultExecutorFactory for non-static builds should hook that mechanism.

Doug

4 Likes

For non-embedded we already have to use existentials everywhere anyway because of the various other existing mechanisms for overriding executors (admittedly the existentials are often hidden behind C++ types like SerialExecutorRef/TaskExecutorRef and Swift types like UnownedSerialExecutor/UnownedTaskExecutor, but they're still existentials under the covers).

I don't know how Embedded Swift intends to handle withTaskExecutorPreference(someExecutor) { ... } or executors on actors without using existentials, but we should probably talk about that?

This seems like a reasonable ask, although I have questions about how much this is really going to buy us. We would know the concrete types of the default executors, but if they happen to not be the current executor for some reason then we're surely back to existentials?

Not just Embedded Swift; while I did mention that specifically, they are also actively used by various things today, so we really had to keep them working, at least until this work has landed and people have switched over to it.

I'd go further β€” I'm no fan of it for any kind of build, Embedded or not. I understand why it was added, but I would be very happy to see it removed. However, as I say, I'm led to believe that there are things that rely on it today, and so I didn't want to remove it as part of this work.

This is very much in the spirit of the ExecutorImpl.h header that I'd previously added, which β€” I think β€” provides exactly such an interface, with the Swift side of that in ExecutorImpl.swift, which doesn't get linked into the Embedded Swift Concurrency library.