[Pitch] Custom isolation checking for SerialExecutor

Hello everyone,
We're continuing our quest for more customizable and correct for all kinds of use-cases custom executors in Swift Concurrency.

I'd like to pitch an improvement to dynamic isolation checks: Custom isolation checking for SerialExecutor.

It's a small API addition but has significant impact on the kinds of situations where dynamic isolation assertions are able to detect isolation correctly.

Motivating example

Today, APIs like assertIsolated, preconditionIsolated and assumeIsolated allow to dynamically restore the isolation information from synchronous code. Synchronous, nonisolated functions, "lose" the static information what they are isolated on, however if they are called from an async function they are running in an isolated context.

A recent example that has surprised people is the following:

// In today's Swift:

import Dispatch

actor Caplin {
  let queue: DispatchSerialQueue(label: "CoolQueue")

  // use the queue as this actor's `SerialExecutor`
  nonisolated var unownedExecutor: UnownedSerialExecutor {
    queue.asUnownedSerialExecutor()
  }

  nonisolated func connect() {
    queue.async {
      // guaranteed to execute on `queue`
      // which is the same as self's serial executor
      queue.assertIsolated() // CRASH: Incorrect actor executor assumption
      self.assertIsolated() // CRASH: Incorrect actor executor assumption
    }
  }
}

This shows an actor using a custom executor, and using the same executor queue DIRECTLY, without using async/await aware APIs. The queue.async method DOES NOT create a Task or form any SerialExecutor tracking, and as such, these preconditions will fail today.

This proposal allows for these checks to pass and allow restoring isolation information even if the Swift concurrency runtime cannot prove it, but the target executor can.

Proposal ToC

Read the full proposal here:

Custom isolation checking for SerialExecutor

Please leave "typo" and editorial changes on the PR directly, and discuss any other topics in this pitch thread -- thank you!

// edit: fixed links

10 Likes

The link in the post seems to just go to the list of PRs, not the file or even this specific PR. Is that intentional?

1 Like

Huh no, I thought I was being smart by copying GitHub’s ToC links, seems that won’t work huh.

Fixing it up now.

One use-case that I have in mind for this is checking if you are on an executor decoupled from if that means isolation. Your proposal adds this method to SerialExecutor and I am wondering if that is something worth providing on the Executor protocol itself in a form of preconditionOnExecutor() for example.

Just to give you an example of how I wanted to use this

struct Foo {
    private let executor: any TaskExecutor

    func foo() async throws {
        // This method must be called on the executor
        self.executor.preconditionOnExecutor()
    }

Do you think this is something that this proposal can cover and we can generalise more? swift-nio has a similar API on EventLoops to assert/precondition that you are on a specific loop.

This seems problematic.

A SerialExecutor guarantees the "there is only one thing running on this thing", while a task executor doesn't guarantee this -- it is only treated as a "source of threads". TaskExecutors are never used in isolation checks for this reason.

You mention an "precondition on any executor" check, but that's not the APIs we offer today -- it would have to be something new. We do offer "preconditionIsolated()" though, because the more powerful use of this is this spelling:

struct Foo {
    private let executor: any TaskExecutor // MultiThreadedEL

    func foo() async throws {
        // Note: These APIs don't exist:
        self.executor.preconditionOnExecutor()
        self.executor.assumeIsolated { !! oh no !! }
    }
}

Now if we were to reuse checkIsolated and just allow TaskExecutor (i.e. any Executor) to implement checkIsolated... This kind of leads to answering the wrong question:

  • If the any TaskExecutor is an MultiThreadedEventLoopGroup for example... it should be answering true if you're on any EL in the group...
    • that does NOT provide isolation guarantees; we'd have to be on a specific EL (!)

So... the specific API of checkIsolated() must be tied to isolation and therefore SerialExecutor.

If we really wanted to, we could introduce checkOnExecutor() when then could be used to power such anyExecutor.preconditionOnExecutor() API... however... we generally try to stick to speaking about isolation, and not executors tbh, so I'm not sure this is a big enough win.

We can definitely discuss this more, but I think it'd need to be another different API, unlreated to isolation.

1 Like

I agree that this is decoupled from isolation and I agree that adding this to TaskExecutor or Executor isn't necessarily the right place. What do you think about renaming the proposed checkIsolation method on SerialExecutor to preconditionOnExecutor instead? This makes it more general purpose and not tied to isolation checking.

In my above example the executor would actually be both a TaskExecutor and SerialExecutor and it makes sense that only the latter conformance brings in the preconditionOnExecutor method.

This is specifically about isolation checking though.

This is specifically for preconditionIsolated, assertIsolated and most importantly: assumeIsolated().

"preconditionOnExecutor" is not a good name if we wanted to go down that route, it'd have to be the explicit preconditionOnSerialExecutor if we want to express it like that. In case we might want to offer an preconditionOnExecutor (any executor) for whatever reason.

Can we step back and form a specific use case and design goal for this proposed API? I think this is what you are saying:

  • I need to assert I'm on ANY thread on the "many threads IO (Task) executor"?
    • It does not have to give me isolation -- as TaskExecutors don't guarantee this -- and therefore this new method cannot power the assumeIsolated() and friends APIs.
    • This means proposing a whole new series of assertOnExecutor APIs, probably on Executor and see if we can implement them by default on SerialExecutor to keep compatibility with the ...Isolated() methods...
      • we'd need to see what the defautl impls would be for Executor -- I don't think we're able to provide a good default implementation other than just crashing. On TaskExecutor there's a chance to pointer compare but that isn't a strong guarantee and may become messy since we only use unretained executors... there may be an ABA issue there :thinking:

Are we sure we really need this assertion, or is code actually asserting for isolation most of the time?