Strange behavior with _swift_task_enqueueGlobal_hook

I’ve been debugging some performance issues in my Swift concurrency code and noticed something really strange with the _swift_task_enqueueGlobal_hook.

I’m seeing that tasks are being enqueued multiple times. In some cases I’m getting re-enqueue for the same task. However, when I explicitly use withTaskExecutorPreference, the repeated re-enqueueing stops entirely. Tasks run once and that’s it.

Why does this happen and how can I avoid this without using withTaskExecutorPreference everywhere? Do the default global executor have this same issue?

Here's a minimal reproducible example on Godbolt:

I'm a bit confused by how you're trying to count enqueues in your example, and I guess more fundamentally exactly what you're trying to measure. If you just use the global hook to do the counting, and forward the job to the "normal" executor, like this:

swift_task_enqueueGlobal_hook = { job, original in
    print("Enqueue: \(job.description)")
    original(job)
}

Or if you just forward the enqueue() call to globalConcurrentExecutor in your custom executor's enqueue() implementation, then the enqueue counts in each case seem like they're the same.

Perhaps the issue with the current example is that by forwarding to your custom executor, the task's executor identity is that object, but when you make a call to group.addTask {} the executor preference there is not that object, but rather the default concurrent executor (I think). Maybe that forces an enqueue, which ultimately gets re-routed back via the global hook, inflating the counts. If you change the calls to group.addTask(executorPreference: globalExecutor) under your original implementation, the printed enqueue counts also decrease.

Anyway, I'm not deeply familiar with all the intricacies of that part of the runtime, so maybe someone more knowledegable will enlighten us.

My goal isn't counting enqueues. What I'm trying to understand is why the runtime is enqueuing the same task multiple times, and whether this is intended behavior or something I'm doing wrong =)

Just to clarify, what do you mean by "task" in this context? IIUC (which, to be clear, I may not), the things enqueued by the runtime are "jobs", and a given Task (or "child task" in the case of taskGroup.addTask()) will typically be comprised of a number of jobs that make up its execution. My understanding is that the enqueues you see are at points where the runtime dynamically must either schedule some work on another executor, or relinquish the current executor for some reason and enqueue the job's resumption.

Personally I feel like using the global enqueue hook to reroute all jobs to an executor that runs them inline makes things harder to understand, since the default global executor does not run jobs inline in that manner, and also has a different identity, which I believe affects some of the runtime logic. Can you share more specifics about the code for which you're debugging performance issues?

1 Like

Of course, in the context of the executor, I should have said "job" rather than "task."

I was working on porting the Rust Tokio runtime to Swift for a while, and that's when I first noticed this enqueue behavior. I'm not actively working on it anymore, but this question has been bothering me ever since.

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:

  1. 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.
  2. 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.
  3. 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.

2 Likes

Isn't swift_task_switchImpl only being called because the default DispatchQueue is a serial queue and therefore conforms to SerialExecutor? How do the results look when you use DispatchConcurrentQueue instead, or a custom worker thread?

P.S. I did a quick test with a custom worker thread and got the same multiple enqueues.

I would also recommend checking out GitHub - swiftlang/swift-platform-executors: This package provides platform-native executors for Swift Concurrency. · GitHub which implements native executors on Linux and Windows that don't use Dispatch. You can see how a real executor implementation looks.

I would then like to add that it's much more fun to build your own :)

Thank you so much for this incredibly detailed analysis, I really appreciate the time and effort you put into this. Your explanation makes everything clear now. :slightly_smiling_face:

I'm already aware of and have reviewed the Custom Main and Global Executors proposal, and I'm really looking forward to it.

1 Like

Thanks! I'll definitely look into it more thoroughly.

Quick questions:: is I/O support planned, and how does performance compare to Dispatch?

I don't think so. I believe swift_task_switch is called from roughly anywhere there could be a dynamic suspension point. In my example, nothing about the dispatch queue's serial executor conformance is used – the queue just provides serialization and the thread to run on. The executor identity used by the job comes from the GlobalExecutor instance, not the queue. Note that on Linux I don't even think the SerialExecutor conformance exists for DispatchQueue (or at least if it does I don't know where it comes from).