I filed an issue about this.
You can kinda workaround this by implementing your "own" MainActor
like this:
@_silgen_name("swift_task_enqueueMainExecutor")
func _enqueueMain(_ job: UnownedJob)
@globalActor
final actor MyMainActor: GlobalActor, SerialExecutor {
static let shared: MyMainActor = MyMainActor()
nonisolated var unownedExecutor: UnownedSerialExecutor {
asUnownedSerialExecutor()
}
nonisolated func enqueue(_ job: UnownedJob) {
_enqueueMain(job)
}
nonisolated func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
and then use that instead of the MainActor
defined in the standard library. This is of course not very plausible nor portable but it will get the job done. With this actor I can see the main executor enqueue hook being called.
But I did some digging and might have found something (now I'm a hobbyist and not a compiler / runtime engineer so I might be totally off the track). The current implementation of the MainActor
can be found here. So when someone wants to run some task on the MainActor, they will implicitly use the executor associated with the MainActor which is obtained via a builtin
@inlinable
public nonisolated var unownedExecutor: UnownedSerialExecutor {
#if compiler(>=5.5) && $BuiltinBuildMainExecutor
return UnownedSerialExecutor(Builtin.buildMainActorExecutorRef())
#else
fatalError("Swift compiler is incompatible with this SDK version")
#endif
}
This builtin (implementation) calls the swift_task_getMainExecutor
(or emits a call to, I don't know the parlance regarding this), which returns an ExecutorRef
with a SerialExecutorWitnessTable
. The witness table is returned from _swift_task_getDispatchQueueSerialExecutorWitnessTable
which references the DispatchQueueShim
which is an implementation of the SerialExecutor
protocol. From the implementation we can see that this executor enqueues the jobs directly to dispatch queues
func enqueue(_ job: UnownedJob) {
_enqueueOnDispatchQueue(job, queue: self)
}
since _enqueueOnDispatchQueue
is implemented in the runtime as
void swift::swift_task_enqueueOnDispatchQueue(Job *job,
HeapObject *_queue) {
JobPriority priority = job->getPriority();
auto queue = reinterpret_cast<dispatch_queue_t>(_queue);
dispatchEnqueue(queue, job, (dispatch_qos_class_t)priority, queue);
}
So if I have understood the process correctly, when someone calls a @MainActor
function, the executor is fetched with the builtin as described above. This executor is basically the main dispatch queue and the job is enqueued directly to it, bypassing the swift_task_enqueueMainExecutor
method.
I don't know the reason why it is implemented this way, maybe for performance reasons, but this way the jobs enqueued to the MainActor
will never go through the swift_task_enqueueMainExecutor
which in turn will never call the swift_task_enqueueMainExecutor_hook
.
At some point in time the implementation of the MainActor
was like this
@globalActor public final actor MainActor: SerialExecutor, GlobalActor {
public static let shared = MainActor()
@inlinable
public nonisolated var unownedExecutor: UnownedSerialExecutor {
return asUnownedSerialExecutor()
}
@inlinable
public nonisolated func asUnownedSerialExecutor() -> UnownedSerialExecutor {
return UnownedSerialExecutor(ordinary: self)
}
@inlinable
public nonisolated func enqueue(_ job: UnownedJob) {
_enqueueOnMain(job)
}
}
and with this implementation the hook is called.