Equivalence of Task and MainActor.assumeIsolated

In situations where some async code needs to be run from a method in a @MainActor type, is there any difference between using

...
Task {
  do_something()
}

and

...
MainActor.assumeIsolated {
  do_something()
}

In my testing the observable behavior seems identical but I am curious if it's worse (or even wrong) to use one vs. the other and if yes, why?

The former will run your code on whatever actor you originated from (if any). So if, in the future, your code changes and that Task no longer starts running from the main actor, you won't know.

Also, the task allows you to await asynchronous function calls.

With the latter, you're running code on whatever place you originated from and you'll get a crash if that's anything but the main actor. It also only allows you to run synchronous code so you can't await anything.

MainActor.assumeIsolated also is only available from a synchronous context so you can't use it from within an async function or a Task. It also doesn't spawn any new tasks; it just runs your closure while making sure that you're on the main actor.

The two mechanisms basically have nothing in common, your observation is most likely what it is because you're:

  • Spawning your task from the main actor and within a synchronous context
  • You're not calling any asynchronous code

Have you checked the docs and think they don't sufficiently explain what assumeIsolated does, or have you not checked those? I'm primarily asking because if these don't do a good enough job explaining the difference: assumeIsolated(_:file:line:) | Apple Developer Documentation then we should improve the docs even more. Maybe we can add an example?


Yes though in a very specific way: it will crash if you're no on the right actor. The documentation explains this:

You call this method to assume and verify that the currently executing synchronous function is actually executing on the serial executor of this actor.

If the current context is not running on the actor’s serial executor, or if the actor is a reference to a remote actor, this method will crash with a fatal error (similar to preconditionIsolated()).

2 Likes

Yes, I have and I didn't find it to be so bad.

  • For Task I read that the init method ...Runs the given nonthrowing operation asynchronously as part of a new top-level task on behalf of the current actor.... So I concluded that when the type is @MainActor the closure will run on it (the main actor). The Task Overview also explains fairly well some of the mechanics which make it less puzzling why it's ok to not name the task object in Task { do_something(); } and have the newly created object be immediately GC-ed (or eligible to be GC-ed).
  • the MainActor.assumeIsolated documentation is also fairly clear. If anything, I find the summary of MainActor.assumeIsolated to be suboptimal because it makes it sound as if all it does is the verification (unlike MainActor.run whose summary explicitly says it will run the closure). I would have preferred it if it said something like Execute the operation on the MainActor or crash if the calling context isn't on the MainActor's serial executor.

So in my particular case, I knew that my method is on a @ManActor type and had initially used a Task { ... } then was reading around and came across assumeIsolated and thought, wait, this sounds like it will work as well.

For me, if anything—and this is, of course, a personal preference—there is lack of a concise, succinctly written document that introduces and explains the terminology of isolation, actors, asynchrony, and all that, and I just constantly feel that I have to consult multiple disparate sources of knowledge to piece things together. If some such 5-10 page document existed (I have not found one) I would love to read it and keep it as a reference.

1 Like

But that's the point: It would be flat out wrong to say that, as that's not what it does. It is indeed basically only doing the check and if that check fails, it traps your application, i.e. you get a crash.
I mean, I get what you say, since the call is wrapped in a closure, it is executed, but the assumeIsolated method does not any scheduling or the like. If you're on the main actor, it effectively just calls it as if you were to not have the wrapping at all.

There is indeed a very important difference between these.

Task { … } creates a second path of execution, while preserving your original path of execution (in the function containing the Task creation). By contrast, assumeIsolated continues the original path of execution, without creating another one.

Putting this a different way, Task introduces concurrency into your code, while assumeIsolated is sequential (though asynchronous).

1 Like

So the closure is executed inline? Or something else?

I find it really hard to reconcile all the advice here with the documentation (the swift book and reference). For example, the Discussion for assumeIsolated states that ... This method allows to *assume and verify* that the currently executing synchronous function is actually executing on the serial executor of the MainActor. If that is the case, the operation is invoked with an isolated version of the actor, / allowing synchronous access to actor local state without hopping through asynchronous boundaries....

What does an isolated version of the actor mean here, is this explained somewhere? isolated sounds like a keyword but I can't find a single reference for it in the book (I do find references to nonisolated), only in various SE proposals. Obviously, it exists (various methods signatures have it in the documentation) but I am missing the story behind it. Back to reading a bunch of proposals, I guess.

Honestly I'm more puzzled by "version" than by "isolated". I have no good idea of what an actor "version" is. Here's what I think that sentence is trying to say.

  1. The function checks that the current execution environment is what is necessary for the relevant serial executor. In this case, this includes a check that (at the implementation level) the function is running on the main thread.

  2. Since assumeIsolated applies only to serial executors, that means no other main thread/main queue/main actor code can be currently executing.

  3. Therefore, it's safe to treat the current execution state as isolated to the main actor.

  4. Since we are (assumed to be) inside the main actor's isolation boundary, calls to isolated (@MainActor isolated) functions or closure can be synchronous. Recall that "undecorated" functions (lacking both nonisolated and async keywords on the function declaration) are synchronous when called inside the actor, but are asynchronous when called from outside the actor.

If I have that wrong, someone will jump in to correct me.

So, yes, the assumeIsolated closure will be executed inline, aka synchronously.

I should also point out: it's a Swift Concurrency quirk of terminology that spinning off a new task in a serial executor like MainActor's is regarded as concurrency, even though the two paths of execution cannot execute truly in parallel (i.e. in different threads). However, this is still concurrency, because parts of each task/path of execution can be interleaved — at awaited suspension points.

The next part of the same sentence explains what it means:

"the operation is invoked with an isolated version of the actor, allowing synchronous access to actor local state"

That's what assumeIsolated does, it tells the compiler to assume you are running the closure as part of the isolation domain of the actor in question, think of it as if the closure was written as an isolated method on the actor itself. If at runtime that's not true, it will trap as to let undefined/unsafe behavior infest your program.

Look at it the other way, without being isolated to that actor you must do an async call to jump into the isolated domain of that actor. By using assumeIsolated you tell the compiler "trust me bro, we are already in the actor isolation domain, is all fine".

1 Like

Sure, but you didn't really address the terminological problem here. What's a "version" of an actor? There is only one MainActor type, and there's only one instance of that type. What is being "versioned" here?

I think the sentence is actually trying to say:

the operation is invoked in the isolation domain of the actor, allowing synchronous access to actor local state

But it's not … you know … my job to rewrite confusing documentation.

1 Like

Producing high-quality technical documentation is incredibly challenging. I have a lot of sympathy for the work of the documentation maintainers who strive to create clear and informative materials. While specific details—such as individual method descriptions—are occasionally good and can be valuable, the overall quality of the concurrency documentation is forgettable and uninspiring.

A lot of the critical information seems to be scattered across the various proposals. While these proposals contain valuable insights, their writing style is a mishmash of inconsistent terminology and lacking crucial details which hinder clear understanding of the concurrency abstractions.

If anyone is interested in improving the situation, they'd have to start by establishing the minimal, yet clear and consistent vocabulary (that is, define the core abstractions), and, present a concise narrative to explain how concurrency works and can be effectively used. This process of crystalizing—removing all fluff until the minimal, most succinct and concise description has emerged—is really hard and time-consuming but we can hope...

1 Like

Just getting back to the original question, these two constructs are fundamentally different. They are interchangable only in very narrow circumstances.

Task {
  do_something()
}

This is starting a new, independent context of execution using the same static isolation as where it is created. Because no async code is written here, the Task is really just deferring the execution of do_something() until the next turn of the main run loop. This is (basically) identical to:

DispatchQueue.main.async {
  do_something()
}

Now, because we know we're on the MainActor and no async work is happening, we could also just execute the method synchronously, without enqueuing it:

do_something()

This gets us to the next form:

MainActor.assumeIsolated {
  do_something()
}

This is promising the compiler that the surrounded context actually is MainActor-isolated. But we know that must be the case, because do_something() is being executed synchronously within the Task. So this is more equivalent to:

dispatchPrecondition(condition: .onQueue(.main))
do_something()

Again, in this very specific situation, given the context I can see, it looks like these two versions differ only in timing of execution.

2 Likes

(Bold markup in the above quote is mine)

I know almost nothing about Swift Concurrency and how to properly use it, but running the following code snippet proves your explanation wrong:

import Foundation
print("We are outside task and main thread = \(Thread.isMainThread)")
Task {
    print("We are inside task and main thread = \(Thread.isMainThread)")
}
Thread.sleep(forTimeInterval: 1) // give time for the task to complete before exiting app

Please run it on a real hardware, not in an iOS emulator in Xcode, as it has only one thread available as far as I know. Playgrounds may also be constrained.

1 Like

If you run the snippet from within a context that is statically @MainActor isolated (such as a method of an @MainActor type, as mentioned in the original topic), then the task will inherit the isolation and both print statements will output true.

It appears this doesn't happen for top-level code; I'm not sure if that's a bug or not.

1 Like

Ah, static isolation... Most likely Task is inheriting its static isolation context. My code snippet is obviously non-isolated.

2 Likes

I thought there was a guarantee that top-level code would execute on the main actor, and if I try to await a call to an @MainActor function at the top level I get a warning:

warning: no 'async' operations occur within 'await' expression

So it seems there's some knowledge of a static isolation guarantee... but I guess this doesn't get propagated to new Tasks created at the top level.

1 Like

The following passes in top-level code:

precondition(#isolation === MainActor.shared)
1 Like

What are you seeing when you run this code? I see nothing but "true" here...

import Foundation

func onMain() {
  print(Thread.isMainThread)
}

onMain()

let task = Task {
  onMain()
  precondition(#isolation === MainActor.shared)

  onMain()
  return 5
}

print(await task.value)

Everything seems to be as-expected so far...

1 Like

I see

true
false
false
5

I'm on Windows, Swift 6.0.2. Precondition is not failing, but Thread.isMainThread returns false.

Seems like an anomaly in Windows version of Swift. Here is another thread:

2 Likes