What’s next for Foundation

@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?

  1. 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 the MainType.main function itself. Or does it make sense to put it somewhere else, like on the MainActor 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.

  2. 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. :slightly_smiling_face:

16 Likes