Thanks for clarifying (that sounds like an interesting project!). I took a variation of your example and ran it with a locally-built stdlib that was built with the concurrency runtime debug logging enabled. I additionally made the following modifications:
- The implementation of
GlobalExecutor doesn't run jobs inline, it enqueues them to a serial dispatch queue. I don't think this affects the behavior as it relates to your question, but IMO makes things easier (for me) to think about because there's no reentrancy.
- I added more print statement logging to better see what happens during execution. In theory there are some executor hopping optimizations that could be impacted by this, but in this case I don't think it matters.
- I moved the demonstration code into its own function that runs on the global executor, and boiled it down a bit. This was to try and distill the differences when using an executor preference and to limit any weirdness from top level code running on the main actor by default.
The resulting code looks like this (godbolt):
import Foundation
final class GlobalExecutor: TaskExecutor {
private let q = DispatchQueue(label: "q")
func enqueue(_ job: UnownedJob) {
let jobDesc = job.description
print("GlobalExec::enqueue job \(jobDesc)")
q.async {
print("GlobalExec::run job \(jobDesc)")
job.runSynchronously(on: self.asUnownedTaskExecutor())
}
}
}
let globalExecutor = GlobalExecutor()
typealias Original = @convention(thin) (UnownedJob) -> Void
typealias Hook = @convention(thin) (UnownedJob, Original) -> Void
nonisolated(unsafe) private let _swift_task_enqueueGlobal_hook =
dlsym(dlopen(nil, RTLD_LAZY), "swift_task_enqueueGlobal_hook")
.assumingMemoryBound(to: Hook?.self)
var swift_task_enqueueGlobal_hook: Hook? {
get { _swift_task_enqueueGlobal_hook.pointee }
set { _swift_task_enqueueGlobal_hook.pointee = newValue }
}
swift_task_enqueueGlobal_hook = { job, _ in
globalExecutor.enqueue(job)
}
@concurrent
func test() async {
print("withDiscardingTaskGroup")
await withDiscardingTaskGroup { group in
print("in discarding task group")
for i in 0..<3 {
print("adding task \(i)")
group.addTask() {
print("in task \(i)")
await withUnsafeContinuation { cont in
print("in cont")
cont.resume()
print("resumed cont")
}
print("finishing task \(i)")
}
}
}
print("withTaskExecutorPreference")
await withTaskExecutorPreference(globalExecutor) {
print("in executor preference")
await withDiscardingTaskGroup { group in
print("in discarding task group")
for i in 0..<3 {
print("adding task \(i)")
group.addTask() {
print("in task \(i)")
await withUnsafeContinuation { cont in
print("in cont")
cont.resume()
print("resumed cont")
}
print("finishing task \(i)")
}
}
}
}
}
await test()
Here are the (mostly) raw runtime debug logs interleaved with the print statement logging: Swift concurrency custom executor debug logs · GitHub.
In the case without a task executor preference set, you can see several logs of this form:
[0x16b223000] [Actor.cpp:2514](swift_task_switchImpl) Task 0x14c70c3a0 trying to switch executors: executor 0x0 (GenericExecutor) to new serial executor: 0x0 (GenericExecutor); task executor: from 0x14c6083c0 to 0x0 (undefined)
[0x16b223000] [Actor.cpp:2533](swift_task_switchImpl) Task 0x14c70c3a0 can give up thread?
[0x16b223000] [Actor.cpp:2567](swift_task_switchImpl) switch failed, task 0x14c70c3a0 enqueued on executor 0x0 (task executor: 0x14c6083c0)
where the logging shows that the runtime wants to run jobs on a task executor with identity 0x0 (i.e. the default global concurrent executor), but they are currently on one with identity 0x14c6083c0 (the custom GlobalExecutor object). Since the identities don't match, the runtime enqueues the job rather than running it inline. If I've followed the runtime logic correctly, the decision making here largely boils down to this mustSwitchToRun() function which is called from here.
However, in the withTaskExecutorPreference case, we see logs like this:
[0x16b223000] [Actor.cpp:2514](swift_task_switchImpl) Task 0x14c70c310 trying to switch executors: executor 0x0 (GenericExecutor) to new serial executor: 0x0 (GenericExecutor); task executor: from 0x14c6083c0 to 0x14c6083c0
[0x16b223000] [Actor.cpp:2521](swift_task_switchImpl) Task 0x14c70c310 run inline
Since the current and new task executors line up, the job can be run inline without another enqueue.
So, I think this is evidence that my earlier speculation about executor identities may be at least partially correct. Since your example uses the global hook, but runs all jobs intended to run on the global concurrent executor on an executor with a different identity, the runtime keeps making internal checks and thinking that work needs to be enqueued. When you set the task executor preference, the runtime then knows within that scope that the executor it should use instead of the default global concurrent one is the custom GlobalExecutor implementation, so when it's already running on it, it requires fewer enqueues.
Does that seem like a plausible/satisfactory explanation for the behavior? IIUC it is an eventual goal to allow customization of the default (and main) executor, which I would expect would allow this sort of global replacement without incurring the cost of the runtime not really being aware of it.