@Geordie_J and @Douglas_Gregor
Hmm, removing the runloops is going to have some interesting impacts on Swift concurrency. We're using CFRunloopRun
in the underlying async-main to drain the main queues and drive the whole concurrency machine if it's linked into the process. We fall back on dispatch_main()
if CFRunloopRun
isn't available, but it's not really the ideal runloop.
Now to the point of swapping in different run loops, I have been taking notes for the past couple years on various requirements as the pop up.
Thus far, the MainRunLoop
protocol that I've come up with looks something like;
protocol MainRunLoop: AnyObject {
init()
deinit()
func shutdownSignal() -> Void
func run() -> Void
}
(names are very much still in the air.)
init
allows you to set up the run loop state if necessary.
deinit
allows you to tear down any state running after the runloop has stopped (if necessary).
shutdownSignal
signals to the runloop, from within a task running on said runloop, that it's time to stop. This would get called after the MainType.main()
returns so that programs don't hang. Right now, we call exit
, which isn't great, but that's to ensure that dispatch_main
stops running.
run
actually kicks the runloop off.
Depending on where my Exit Codes From Main pitch goes, we may need to adjust the shutdown signal to accept an int return code to feed through to the runtimes to return.
If we go with the example from the async main semantics proposal;
func foo() async {}
func bar() async {}
@main struct Main {
static func main() async {
print("Hello1")
await foo()
await bar()
}
}
This gets lowered in SIL/IR, but I'll keep it looking like Swift and do a bit of hand-waving for the purpose of discussion, into something that looks like the following;
@main struct Main {
static func _main3() {
bar()
exit(0) // Implicit -- kills the program because `drainQueue` doesn't return otherwise
}
static func _main2() {
foo()
enqueue(_main3) // Implicit
}
static func _main1() {
print("Hello1")
enqueue(_main2) // Implicit
}
}
// Generated main entry point:
func _main(_ argc: Int32, _ argv: UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
_main1()
drainQueues() // This currently never returns.
}
With the protocol and new setup, there would be an optional main runloop variable to set that conforms to the MainRunLoop
protocol. Devs would then set the run loop in the first synchronous portion of the main function. e.g.
// ...
@main struct Main {
static func main() {
mainRunLoop = MyCustomRunloop()
print("Hello1")
await foo()
await bar()
}
}
My current thoughts are that the new lowering would look something more like (still lots of hand-waving);
@main struct Main {
static func _main3() {
bar()
mainRunLoop.shutdownSignal() // Implicit, replaces the forced exit call returning from `mainRunLoop.run()`
}
static func _main2() {
foo()
enqueue(_main3) // Implicit
}
static func _main1() {
mainRunLoop = MyCustomRunloop()
print("Hello1")
enqueue(_main2) // Implicit
}
}
// Generated main entry point:
func _main(_ argc: Int32, _ argv: UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
_main1()
if mainRunLoop {
mainRunLoop.run()
mainRunLoop.deinit()
} else {
// Default runloop implementation, which is CFRunLoopRun or dispatch_main at the moment
}
return 0
}
If the mainRunLoop
var is set, we use that implementation. Otherwise, fall back on some default. At the moment, we use CFRunLoopRun
as a default if it's linked into the process, otherwise we fall back on dispatch_main
. dispatch_main
presents some issues, most notably, it never returns so there's no way to really get a value back out of it. The program has to call exit
with the return code, which as discussed in my exit-code pitch, has some issues.
Alright, so now that I've written what looks like an entire feature pitch as a response to a thread, why haven't I pitched it yet?
-
I don't know where the runloop override would live. I'm bouncing between a free-floating global and something we dig out of the struct attributed with
@main
, like we do with theMainType.main
function itself. Or does it make sense to put it somewhere else, like on theMainActor
actor? In any case, we certainly want to block folks from overriding it after the first suspension point. Replacing a running runloop implementation while one is already running would be a bad day waiting to happen. -
Custom executors
The runloop and executor are coupled. The executor puts jobs/tasks on the queue while the runloop takes them back off again. Who owns the queue? Who is responsible for managing the queue ordering?
If we look at the MainActor MainExecutor
DispatchGlobalExecutor.inc implementation, which is sort of the original OG custom executor, it sheds some insight on where things stand today;
SWIFT_CC(swift)
static void swift_task_enqueueMainExecutorImpl(Job *job) {
assert(job && "no job provided");
JobPriority priority = job->getPriority();
// This is an inline function that compiles down to a pointer to a global.
auto mainQueue = dispatch_get_main_queue();
dispatchEnqueue(mainQueue, job, (dispatch_qos_class_t)priority, mainQueue);
}
It uses dispatch_get_main_queue
, which drags a reference to a queue out from somewhere. CFRunLoop
and dispatch_main
happen to know how to call dispatch_get_main_queue
to get the main queue and pull tasks off to execute, and both function happen to be getting called from the right thread, but is this global function really the API we want? I don't think so. This feels more like a "happens-to-work" relationship than an "it actually works" relationship. Note the documentation comment on the dispatch_get_main_queue
In order to invoke workitems submitted to the main queue, the application
must call dispatch_main(), NSApplicationMain(), or use a CFRunLoop on the
main thread.
The corresponding UnownedSerialExecutor
getter implementation for the MainActor
for dispatch also does something similar;
ExecutorRef swift::swift_task_getMainExecutor() {
return ExecutorRef::forOrdinary(
reinterpret_cast<HeapObject*>(&_dispatch_main_q),
_swift_task_getDispatchQueueSerialExecutorWitnessTable());
}
(note that dispatch_get_main_queue()
effectively lowers to &_dispatch_main_q
;
)
If the executor owns its queue, I would imagine that the runloop should call something like executor.next()
(like an iterator) or executor.dequeue()
to mirror the enqueue()
API that exists today, and just keep pumping that to get the next task to execute. Once the main queue is empty, the program can terminate. The prioritization behavior and queue management is then left to the executor to decide.
Alternatively, if the runloop mechanism owns the actual queue, we would need to pass a reference to the queue up to the executor to ensure that executors are sending tasks to the expected queue and the runloop queue is responsible for determining the order.
Anyway, there are still plenty of conversations to be had on who owns/manages what and a fair bit more runtime spelunking that I have to do to have meaningful opinions.
This is where my thoughts are with regard to an overridable main runloop. It's still a work in progress.