[Pitch] Custom Main and Global Executors

Sorry for the double post but I missed the discussion around setting the executors.

I like this idea and I think it works well. Thanks @Joe_Groff for bringing this up. What if we can make it part of the @main annotated struct e.g.

@main
struct MyApp {
  static let defaultExecutor: TaskExecutor = ...
  static let mainExecutor: MainExecutor = ...

  static func main() async throws { ... }

For Embedded we could even define a new @main entry point that changes the symbol names if we wanted to.

Just to expand on this. I don't see it as a problem to set the executor statically once. That executor can still do runtime feature detection to decide what underlying scheduling mechanism it uses.

1 Like

Perhaps, but I don't think Span is ready yet? (That's why I went with some Collection<Int> — though it could have been some Collection<UInt8>.) Maybe I'm wrong and we could use it here?

In this specific case, I don't think there's a problem because of the change in approach vis-a-vis the allocation functions. I agree that we should aim to be as idiomatic as possible on the Swift side.

I've already made them non-optional, which does match other allocation functions in Swift. I think for embedded applications this is probably fine too, since most allocations made here will be small and failure to allocate is very likely going to result in a situation where we couldn't continue anyway.

The executorPrivate storage (since renamed from schedulerPrivate) is only two words in size. An executor that needs more than two words and that needs the memory to be released when the task itself is deallocated will want to use the allocation API. So yes, they are for additional items that don't fit into the executorPrivate storage.

An example where the C++ equivalent is presently in use is in the Dispatch-based global executor, if you specify a deadline with a tolerance, the executor creates a Dispatch timer source and allocates, using the allocator we're exposing here, a small control block to hold a pointer to the job itself and the timer source; this block is used as the context pointer for the timer source, which lets the callback clear up the source and the control block before triggering the job's execution.

1 Like

We thought about this previously and discarded it, partly because it makes it hard to control which executor you're using from a command line argument or environment variable.

The point here is that you can have e.g. an IOUringExecutor, an EpollExecutor and a SelectExecutor and select which one you wish to use dynamically. Making the choice of executor fully static means wrapping them all up into some kind of UberExecutor, which sounds like not such a great pattern — mostly it would exist to work around the fact that we'd forced the executor choice to be statically determined.

4 Likes

The proposal has been accepted so I think we can start using it [Accepted] SE-0447: Span: Safe Access to Contiguous Storage.

Thanks that's super helpful. If we could add something like as a brief example to the proposal that would help a lot with understanding why we need it.

I think this is the same argument as the Epoll vs IOUring executor. It can also be worked around by having a DynamicExecutorFromArguments or similar.

I personally feel like it is worth to statically encode that the executor can be set once and require users to create such dynamic executors that are configured by reading the environment or doing feature detection. For example, having a NIOExecutor that does feature detection at runtime to determine the underlying strategy seems totally reasonable to me.
FWIW, this isn't something that I feel super strong about. If we feel the ergonomics are better by doing runtime detection of setting the executor more than once that's also totally fine.

Looking at this some more, I don't think Span is right either (and while SE-0447 has been accepted, I don't think the lifetime annotations are done yet). Also, I now think there will be some implementation problems with just using a tuple. Maybe the solution is just to have a custom type here.

I agree that that might be reasonable, but I also think forcing it is weird.

This wasn't the only reason we discounted the static approach, I might add; the other issue with it is that it isn't compatible with top-level code (because there's nowhere sane to declare the variables — making global variables with particular names magic is much worse than doing that in the @main).

2 Likes

That's a good point but maybe that's also a good thing since it nudges users over to use @main instead of top level code :stuck_out_tongue: Anyways, as I said this isn't something I feel super strong about. Thanks for talking it through though!

One thing I wonder is how much we can avoid the need to dynamically replace the main and global executors at all. The places where the main actor is implicit are fairly rare IIRC, so in most cases code can explicitly use a different global actor instead of replacing the one MainActor. And as for the default global executor, there was just another pitch to make the default actor configurable; although the scope of that pitch is currently only a binary choice between the default global executor and the MainActor, it seems like a reasonable extension to allow the default executor for a module or file to be set to any executor. With those features, how often would code need to dynamically take control of the one shared main and/or default global executor, rather than change the defaults for itself at build time?

For the case where a global actor's implementation needs to be chosen based on dynamic conditions, I wonder if we could generalize the design of global actors to allow for a global actor implementation to be existential. That would allow you to write something like:

@globalActor
public struct IOActor {
  static var shared: any Actor = if ioUringSupported {
    IOURingActor()
  } else {
    LibUVActor()
  }
}

That would allow for this sort of dynamic executor implementation choice to be used for global actors more generally, not only the two special executors, and it would also provide some safety by construction, since the actor initialization would occur as part of the normal initialization on first use of the global, rather than needing to occur as part of the main program in the somewhat nebuluous "before any jobs are scheduled" process state.

5 Likes

Really nice feature for me. Huge +1 from me.
But my question is what if I want to change my global executor to use a custom made one from maybe a foreign framework or system library which provides custom threads, how will I set or change swift default global executor ie the global concurrency pool threads count to avoid spawning too many thread for my system :thinking:
Could I use some sort of command line flag or the swift package manifest setting/option?

Interesting.

For the main actor in particular, I think it's more useful being able to replace this when thinking about support for a new platform, or on some platforms, a new UI toolkit. So, for example on Windows, one might conceivably have a main actor implementation for pure Win32, then another different one for MFC, and maybe a third for Qt-based apps (a similar notion might work on Linux too). I guess you're saying that those could just be Win32Actor, MFCActor and QtActor respectively rather than buying into the special main actor thing that we have in the language, and that's true I suppose, except that by allowing the replacement of the main actor, dependencies that use @MainActor would automatically be wired up to the appropriate actor.

As for the default executor and being able to set it per module, I worry that that's going to have weird performance characteristics. For instance, what happens if I use NIO, which is going to want to use its executor as the default executor, and then I also use some other code that doesn't specify NIO's executor? I'm surely going to end up with, effectively, multiple thread pools running in my program. I realise that might happen anyway if someone uses something other than Swift Concurrency, but if we allow a global override of the kind we're proposing here then that won't happen.

3 Likes

The pitch as written proposes that you would be overriding the default global executor for the entire process, so that new global executor would be responsible for deciding how many threads to use.

There might still be issues if you used something other than Swift Concurrency that also created its own thread pool, of course.

So you are implying that if I supply a new global executor that the swift runtime won't spawn any more thread no matter the circumstance. Cool!
What about before changing the global executor, wouldn't the runtime spawned threads already for its global concurrency pool and what will happen to those threads if the global executor eventually get changed?

From my understanding, there isn't much special about the MainActor, aside from it being a global actor provided by the standard library, and being where main/top level code begins execution. A lot of its significance for Apple frameworks comes from the conventions around main thread usage there, which might not even be entirely applicable to other platforms. The tradeoff with hooking it globally is that only one person gets to do that, so as your application grows and needs to ingest preexisting pieces that use raw Win32, MFC, and Qt, relying on one true MainActor implementation seems like it could be a scaling liability when components with different expectations written against different implementations collide.

No. The idea here is that you get to specify the executor once, before any threads or anything have been created. Trying to specify it after that point is not going to be supported (regardless of whether we end up with the current set-it-dynamically-but-only-once approach, or the static-declaration approach that some people seem to be advocating).

That used to be kind of true, except ongoing proposals are aiming to enable "everything in this module is on the main actor" by default (on a module by module basis) ([Pitch] Control default actor isolation inference) which does make code implicitly on the main actor be pretty ubiquitous. So it seems to be that more than not-special, it is going to become more special than currently.

That's true that one person gets to set it, but also it's "wherever the app/system" runs -- and that's going to be one of those platforms / runtimes, and very rarely there's multiple "assumption of main thread" that are conflicting but active at the same time...? Usually those frameworks take full ownership of the programming model, don't they?

1 Like

Agreed. To continue the Windows example, if you want to run Win32 and MFC code in the same application, you have to use MFC's message pump (or some MFC things — like idle-time processing and keyboard shortcuts in non-modal dialogs — won't work properly). If you wanted to add Qt to the mix, you'd have to run Qt's event loop (there is some support for tying that in to MFC and Win32 message pumps); if you ran a plain Win32 or MFC message pump, Qt wouldn't work. So you really do have to pick just one.

(On Windows, you could also run the message pumps/event loops on separate threads and keep the various windows isolated to those threads, but that's equivalent to having separate actors instead of using the main actor.)

2 Likes

Right, I referred to that pitch above. Since there's little special about the MainActor besides convention, generalizing that pitch to allow for "everything in this module is on [custom executor]" would be a reasonable extension that would address some of the same use cases as this pitch in a way that's potentially more composable when the default is truly local and doesn't need to globally override the default(s).

If you reach the point where you need to separate out the threads/actors for these different components, it could be a lot harder to do so with a globally-overridden actor, since the dependencies on the formerly-one-true actor implementation are implicit and dynamic (and libraries you don't control could be written in "this must be run with the MainActor set to Win32Actor" mode or something like that which you can't fix after the fact). Making the dependency on a specific actor implementation explicit might be somewhat less convenient in the short term but would make it easier to integrate other event systems if/when the need arises.

There appears to be some discussion of whether MainActor is that special at all and whether another global actor would be fine to use instead, given that devs could simply not write MainActor.

For us tapping into the real main actor is essential due to it being equivalent to the main thread, which is more than just any old global actor when interacting with non-Swift systems.

As one example, our Android code uses MainActor (and DispatchQueue.main) to interact with the UI, which is also tied to the jvm’s main thread. There’s no good way around this, especially when you want the code to also work on other platforms like iOS which have strict MainActor / main thread requirements.

1 Like

Agreed. Even on platforms where the main thread is technically not particularly special (Windows, for instance), in practice there's a lot of code out there written with a very definite notion of there being a main thread.

3 Likes

@stackotter and I have come across an example where hooking into MainActor dynamically would be useful.

When using a GUI framework like GTK, the main thread is never serviced and @MainActor functions never run. Instead, you'd need a separate actor (let's call it @UIActor) to coordinate scheduling work with GTK so that it always runs with exclusive isolation with UI updates. This is annoying, but possible; I have successfully implemented such a UIActor.

However, we can't mark SwiftCrossUI's View protocol as @UIActor, because that causes problems in other backends -- particularly in UIKitBackend's UIViewRepresentable, since UIView is @MainActor. There's no way (as far as I'm aware) to tell the compiler that @MainActor and @UIActor are the same thing when using the UIKitBackend, and even if we used assumeIsolated in UIViewRepresentable's implementation, any user who wants to use any other UIKit API (e.g. calling a method on UIApplication.shared inside a button callback) will run into the same issue.

Additionally, I believe this is technically possible on macOS, but I'm not sure why one would do this:

import SwiftCrossUI
import AppKitBackend
import GtkBackend

struct MyGtkApp: App {
  let backend = GtkBackend()

  var body: some Scene { ... }
}

struct MyAppKitApp: App {
  let backend = AppKitBackend()

  var body: some Scene { ... }
}

public func main() {
  if someDynamicRuntimeCondition() {
    MyGtkApp.main()
  } else {
    MyAppKitApp.main()
  }
}

In which case whether @MainActor works out-of-the-box or not is decided dynamically at runtime.

3 Likes

An alternative approach we’ve seen is swift-winui’s aptly named MainRunLoopTickler, but that doesn’t feel ideal given that you have to constantly interrupt the UI framework’s main loop to poll for work and you implement your own work scheduling (how many jobs should run each time you poll? how frequently should polling occur? etc) which would likely interfere with the underlying UI framework’s scheduling algorithm, potentially running into starvation issues etc. One of the main issues being that if you process multiple jobs in a tick you group those tasks into one big high priority (due to polling deadlines) main loop interruption, and if you don’t then you have a fixed amount of jobs that can be processed per second. Additionally, the polling means that the UI framework’s scheduler doesn’t get a chance to prioritise rendering etc over main run loop jobs during a burst of activity, cause the polling jobs request specific deadlines. These issues can all partially be solved by improving the custom job scheduler, but there’s always the fundamental issue that the custom scheduler is oblivious to the UI framework’s load, and the UI framework’s scheduler is oblivious to the custom scheduler’s backlog or task priority. Obviously under low load, none of these issues would manifest. But under high load you’d likely see some differences in performance which could be really tricky to debug.

3 Likes