[Pitch] Unsafe Assume on MainActor

Excellent proposal and something I was also going to bring up with custom executors and in general for "any actor" actually :slight_smile:

First of all, I absolutely agree on the motivation and general shape! I literarily was writing up the same kind of motivation for a similar API this week :joy:

It is important to help people migrate from the precondition(am I executing where I think I am?) towards Swift Concurrency, and in order to do so we need to offer such preconditions. This style of programming is not limited to Dispatch either, as Swift NIO programs often do the same with "am I on the expected event loop?" -- although this would not be global actors.

Some quick comments:

  • The name sounds good unsafeAssumeOnMainActor
  • I'd like to push this a little... It is very unfortunate to introduce APIs specialized only to the main actor. Can we push this a little and see if we could express this for any global actor?
    • Then we'd do unsafeAssumeOn(MyGlobalActor.self) - this may not be trivially expressible in the type system - BUT, I believe this is a good use-case for a function macro that forms the @MyGlobalActor () throws -> T properly.
    • I wanted to propose "assert on the expected executor" as well in the near future, since that is important for performance sensitive libraries moving to adopt swift concurrency from e.g. swift-nio and an event-loop heavy system. But that'll be a separate discussion, with a similar dance through perhaps "assume (isolated ThatActor) -> T". That'll be in a different proposal though, just a heads up that IMHO this operation is very common and useful!
  • nitpicks:
    • debugFileName should likely be #fileID instead?
    • I'd drop the Num and drop the debug from the param names, normally in such APIs we call this file/line, so here either file: String = #fileID and line: UInt = #line would be good. Reason: The "debug" in the name is confusing IMHO, since those ARE kept even in release builds.

The restriction that this API is only to be used in synchronous code I'm still thinking about but it is probably fine -- if you can have async code, then put @MainActor on the method after all :+1:

Very nice proposal, hope we can push it a little bit more!

12 Likes

Here's the thing. If the proposed function is synchronous, then the limitation should be lifted and permit any actor. After all you could pass a global synchronous nonisolated but sendable function to any actor which will execute it. That function could be tailored to be permitted to run on a specific set of actors, not just the MainActor or global actors.

Wasn't the first parameter of a function the way to express actor protection?

(MyActorType) -> โ€ฆ

However I find that confusing and would rather prefer something generic:

<T, A: Actor>(โ€ฆ @A () throws -> T) rethrows -> T

While the concept of this escape-hatch is good for migration purposes, I'm not sure that we should continue executing with a known data race (isn't that undefined behaviour?); why not always abort execution?

FWIW, that's how dispatchPrecondition works (docs):

Use this function to detect conditions about the current execution context that must prevent the program from proceeding even in shipping code.

  • In playgrounds and -Onone builds (the default for Xcodeโ€™s Debug configuration): if condition evaluates to false, stop program execution in a debuggable state.
  • In -O builds (the default for Xcodeโ€™s Release configuration): if condition evaluates to false, stop program execution.
  • In -Ounchecked builds, condition is not evaluated, but the optimizer may assume that it would evaluate to true. Failure to satisfy that assumption in -Ounchecked builds is a serious programming error.
1 Like

I have been thinking about something similar but specific to SwiftNIO and its EventLoop, EventLoopPromise and EventLoopFuture types. Full pitch and implementation can be seen in this PR.

In essence it adds three new types, called CurrentEventLoop, CurrentEventLoopPromise and CurrentEventLoopFuture. These types are, contrary to the non Current versions, non-Sendable but also take non-Sendable callbacks for async events because they guarantee to be called back on the same EventLoop aka thread/DispatchQueue.
To go from e.g. an EventLoop to CurrentEventLoop you also need to call a method, similar to unsafeAssumeOnMainActor, which checks at runtime that you are actually on the given EventLoop and traps otherwise.

The downside of this approach is that we need to implement a lot of these types manually. I would love if we could come up with something in the compiler to help with that.

1 Like

I've run into a need for something like this before. I had a function something like this:

/// If called from the main thread, execute immediately; otherwise, execute asynchronously
static func runOnMainThread(callback: @escaping () -> Void) {
  if (Thread.isMainThread) {
    callback()
  } else {
    DispatchQueue.main.async(execute: callback)
  }
}

I found that I couldn't annotate the callback function @MainActor. Eventually I discovered this, but even this doesn't work with -warn-concurrency:

@preconcurrency
private static func unsafeExecuteWithoutActorCheck(_ callback: () -> Void) {
  callback()
}

static func runOnMainThread(callback: @MainActor @escaping () -> Void) {
  if (Thread.isMainThread) {
    unsafeExecuteWithoutActorCheck(callback)
  } else {
    DispatchQueue.main.async(execute: callback)
  }
}
2 Likes

If you'd convert the callback into async then your whole static function becomes simply MainActor.run. As far as I know, a function does not have to suspend if it's known to run on the expected actor and can just execute the work right away.

Whatโ€™s the primitive that powers unsafeAssumeOnMainActor? It seems this should be available to any custom global actor.

Itโ€™s been a while since I looked at the project, but I think the function that called runOnMainThread was itself called at least sometimes from a UIKit lifecycle method, so it couldnโ€™t be async.

1 Like

Considering your motivation example,

There's another option - make the "main thread" wrapper version of "generateDiffs" :

    func apply() {
        generateDiffs(from: rawContent) { diffs in
            updateUI(diffs)
        }
    }

    public func generateDiffs(from content: Content, withCompletion completion: @escaping @MainActor ([Difference]) -> ()) {
        generateDiffs(from: content, on: .main) { diff in
            DispatchQueue.main.async {
                completion(diff)
            }
        }
    }

If I did that, I'd also make a slight change to the "generateDiffs" itself:

    public func generateDiffs(from: Content, on queue: DispatchQueue?, withCompletion completion: ([Difference]) -> ()) {
        // ... whatever it did before
        
        if let queue {
            queue.async { completion(...) }
        } else {
            completion(...)
        }
    }

used as:

    public func generateDiffs(from content: Content, withCompletion .....) {
        generateDiffs(from: content, on: nil) { diff in
            DispatchQueue.main.async {
                completion(diff)
            }
        }
    }

So it doesn't have to jump through async twice.

Edit: edited code example slightly.

1 Like

It would be really nice if the type system could express this ability, namely to constrain a generic function, a generic type, or their parameter to a global actor provided as generic argument, something which was left as a future direction in SE-0316.

The implementation suggested here (withoutActuallyEscaping + unsafeBitCast to carve off or change a global actor annotation) seems to me as well like it could be generalised to any global actor, provided that the precondition can be guaranteed dynamically (or unsafely by the caller).

I have a use case (and apparently came up with the same implementation in my prototype! :smiley:) for a function of form

@MainActor
func runTransaction<R>(
  _ f: @TransactionActor (T) -> R
) -> R

where f is run synchronously under a different global actor providing access to API which isn't otherwise available to @MainActor (except with await of course). This is something I can do with MainActor already partly because it's special. But with custom executors it would be nice to express similar constructs generically as well.

+1.

MainActor.run is an asynchronous method; it would not work in a synchronous context like the one described.

FYI I believe you can already do something quite similar using @MainActor(unsafe); in fact the proposed unsafeAssumeOnMainActor function can be written in terms of that:

@MainActor(unsafe) private func unsafeAssumeOnMainActor<T>(
  _ perform: @MainActor () throws -> T
) rethrows -> T {
  try perform()
}

Would love to see this pitch tweaked to focus on better usability + discoverability for the existing attribute instead โ€” for example afaik the attribute only works on functions right now, not closures, so maybe extending its scope would be a better solution. This would work super well for other global actors too.

2 Likes

I agree that this should be a separate discussion.

Just as a note for folks in general: With -enable-actor-data-race-checks the compiler will automatically insert a check for the expected executor into the prologue of synchronous functions / closures that are isolated. Part of the reason for this is to provide dynamic checks for the isolation issues that the compiler only warned about.

The behavior behind those checks is exactly the same as what is being used by my implementation of unsafeAssumeOnMainActor. Thus, those checks can be controlled with the SWIFT_UNEXPECTED_EXECUTOR_LOG_LEVEL environment variable. This compiler flag is admittedly not quite well known.

So, let me expand a bit this bit of my rationale in Alternatives Considered:

The reason for focusing only on MainActor is that there were no other "actors" prior to Swift concurrency, except for the MainActor as represented by the main thread. Providing an escape-hatch for code from the same era as Swift concurrency is not a goal of this proposal.

The reason why I say there were no other "actors" prior to Swift concurrency is that there is no executor, like a DispatchQueue, that is exactly matches any actor other than the MainActor. My recollection is that we cannot currently provide an existing DispatchQueue to be used as the executor for a global actor. So, there is no way this is ever correct:

let loggingQueue = DispatchQueue(...)

@LoggingActor func doLog() {}

func caller() {
  dispatchPrecondition(.onQueue(loggingQueue))
  unsafeAssumeOnActor(LoggingActor.self) {
    doLog()
  }
}
4 Likes

Thanks for pointing this out. The unsafe variant of MainActor checks the context in which the function is called. It will emit an error if the context has adopted any concurrency features (e.g., the call is in an actor method), or if compiled with -strict-concurrency=complete. That mode is suppose to be a preview of Swift 6, so as a long-term adoption tool I don't know if it will hold-up.

That's one of the downsides of trying to use @MainActor(unsafe) for this: the dependence on the strictness of concurrency checking. The other downside is that it doesn't do any runtime check that you are on the right actor. If you remembered to compile with -enable-actor-data-race-checks when using @MainActor(unsafe) then you will the check, and then you'll have a pretty close approximation of this unsafeAssumeMainActor.

Yea seems like a bug that @MainActor(unsafe) closures get treated like @MainActor, even with -strict-concurrency=minimal.

3 Likes

+1 from me. I also like the additions / changes @ktoso mentions about allowing any global actor.

And, presumably, we don't want to change these rules (i.e., make @MainActor(unsafe) fulfill the "unsafe assume on main actor" role) because we wouldn't want to take away the tool that folks are using to achieve "silence issues in the concurrency transition period but give me an error when the transition is complete" behavior?

1 Like

I'm running into similar boundary issues with @MainActor. While I agree with the sentiment that a more generalized solution is ideal, I feel like this issue is more often due to the special nature of the MainActor and in particular with Apple's UI APIs.

Most of the time, it's perfectly fine for a background class to do an asynchronous query (in some fashion) to get data off the main actor (or any actor) with some locks. Using this approach unfortunately causes a deadlock when the task is run on the main thread.

Just to explore some additional approaches to this problem:

  • If MainActor were not bound to the main thread, I wonder if we'd have as much of an issue?

  • For the specific case of main-thread bounded MainActor, if there was a way to detect that we're on the main thread, then synchronously execute all the remaining tasks, then I think common use-cases would also be solved. (I'm sure there's details there on what exactly "remaining tasks" means, especially if tasks spawn other tasks that then kick back to the MainActor).

I put together some short code that demonstrates the current state of attempting to get around this problem: GitHub - dannys42/MainActorWoes: Demonstrating MainActor issues on Swift 5.7 with strict concurrency enabled

We've discussed this API a bit more with @kavon and others in the Swift team and we'll need to think a bit more about the implementation details, but we all agree it is a valuable thing to offer.

Circling back to this piece:

now that [Pitch] Custom Actor Executors is pitched, I can confirm that the above can actually be correct, thus a bit more thought needs to be given about what we want to assert on and how.

And it should also be noted that "are we on the main thread" is an incorrect check to perform when checking for "are we on the main actor" because in Dispatch "main queue" does not necessarily have to mean "main thread", and because of upcoming MainActor executor customization, we cannot make other assumptions about what "main actor" runs on, other than comparing executor references.

I'll think more about the semantics we're able to offer and follow up here soon enough.

3 Likes

Didn't know this, makes sense! If we don't want to change the semantics of that annotation, maybe adding a new annotation like @MainActor(assumed) could work too? I'm not opposed to the proposal as it currently stands, but I feel like an annotation would 1) be more in line with the existing syntax, 2) avoid higher-kinded type voodoo if we generalize this to other global actors, and 3) be more versatile, avoiding excessive nesting of the "golden path".

For example:

generateDiffs(from: rawContent, on: DispatchQueue.main) { @MainActor(assumed) diffs in
  updateUI(diffs)
}

reads cleaner to me than unsafeAssumeOnMainActor โ€” but I do see the merit of both approaches.

2 Likes

A follow up on these APIs is now part of the custom executors pitch, and under review here: SE-0392: Custom Actor Executors

3 Likes