Interoperability between synchronous contexts and serial structured concurrency

My overal goal is to execute structured concurrency code from a synchronous context. This goal is typically the result of interop with contexts that do not support structured concurrency, such as C APIs.

There’s been discussion related to this topic on frequent occasions (example), but typically these discussions focus on using locks as a mechanism to block the current thread while a Task executes the structured concurrency code and makes the result available to the blocking thread, e.g. using a reference type.

This approach works in a specific context: i.e. the asynchronous job can (must) run on a different thread from the invoking one. This is also why this approach is dangerous: the solution is liable to deadlock if the invoking thread is part of task executor’s thread pool.

What I’m looking for is a mechanism by which a synchronous context can execute such an async job synchronously (immediately) if the current synchronous context is already isolated to a (serial) executor.

Ideally, if there is a current (serial) executor, there ought to be a way to take an async block and run it synchronously & receive the result, all without the use of locks or blocking the current thread.

Something like:

extension SerialExecutor {
    public func runSynchronously<R, E: Error>(_ operation: @Sendable () async throws(E) -> R) throws(E) -> R
}

This would be particularly important in cases where Swift and C APIs interact, e.g.:

  • Swift synchronous context →
  • Swift Task →
    • run Swift asynchronous code →
    • make a C API call →
    • C delegates back into Swift, e.g. via a function pointer →
    • Swift implementation of the C function pointer wants to use structured concurrency →
    • Swift asynchronous code relies on the existing Task/executor context

In this example, a C library could get executed from a Swift Task, thus existing within the execution context of a structured-concurrency-capable executor, the library may delegate back into Swift context where the author may want to continue to rely on this existing structured concurrency context to implement the result.

Theoretically, I see no reason why this shouldn’t be possible within e.g. a well defined custom SerialExecutor, but we lack the API to execute async code directly within a sync context, even where that context has the instruments in place to execute directly.

Is there any way to facilitate this currently? Perhaps we can unsafeBitCast the async away? What do we need to put in place before executing such async code directly from a sync context?

i have not thoroughly digested your entire post, but have you seen the Task.immediate API (evolution doc & Apple documentation)? it allows you to enter an async context from the calling thread which will run synchronously up to the first dynamic suspension point. that, combined with a custom TaskExecutor/SerialExecutor might get you closer to what you have in mind.

3 Likes

Thanks! This is an excellent pointer, I had not yet discovered this new API!

It raises a few important questions — under what conditions (if any) can I safely assume that the Task will have fully completed (rather than suspended) before .immediate returns?

My assumption would be that given a dedicated SerialExecutor, the Task should be able to continue on the executor at every suspension point and thus fully complete before returning, but I’m just guessing. Perhaps someone can clarify the semantics here?

1 Like

The fundamental problem you'll run into here is that the C calling convention (which is, for the purposes of this thread, the same as the Swift synchronous calling convention) is incompatible with the async calling convention. C assumes that a function enters, runs, and exits, in that order. But async functions don't work that way.

An async function enters, runs up to a suspension point (marked by the await keyword), and yields control of the current thread to the current executor for reuse elsewhere. When the suspension point resumes (i.e. the asynchronous work is complete), the executor schedules the next "slice" (technical term: coroutine) of the async function, ad infinitum until the function finally exits and returns control to its caller.

Further, an async function call needs to keep track of its state (e.g. it can't store values on the stack if they need to "survive" a suspension point), so the ABI for async functions reserves an additional CPU register to store the async context. If you could bitcast an async function reference to a synchronous one, you'd crash when you called it because this register wouldn't be set to a valid Swift async context pointer.

3 Likes

Gotcha. I had assumed that under a SerialExecutor, every suspension point would simply resume serially on the same executor until task completion, but I have probably neglected the scenario where a task is suspended while awaiting a foreign executor’s completion.

That's the sneaky bit: if the function never suspends to await work on another task/executor/queue/thread/device/etc., then it's not async in the first place. :grimacing:

Assuming that the current execution context is in the executor’s thread, if the goal is to suspend until the executor completes a given async task, in such a way to not block the current thread (so that it may be used by the executor), it would be nice if there were some sort of yield() API on the Executor, or yield(until: Task) throws(Failure) -> Success, to pass execution back to the executor for processing suspension points up until the given task’s completion.

This also would potentially cause problems because it allows code to be re-entrant that might not have been expecting to be. Consider this contrived example:

/* I’m typing this directly into the forum and forget how to set a custom serial executor, pretend I got it right. */
actor Example {
  private var state: NonCopyableState!
  init(state: NonCopyableState) {
    self.state = state
  }

  isolated func modifyState(_ transform: @Sendable (State) -> State) async {
    self.state = transform(self.state.take()!)
  }

  isolated func serializeToJson() async -> String {
    self.state/*!*/.serializeToJson()
  }
}

func causeProblems(actor: Example) {
  await actor.modifyState { state in
    magicAwaitOnCurrentSerialExecutor {
      print(await actor.serializeToJson()) // uh oh
    }
    return state
  }
}

An actor is a weird kind of lock that might release and re-acquire on await, but it doesn’t do that not on await, and so using it to protect state is reasonable if done carefully. Being able to re-enter the same executor, even if it doesn’t block on anything else, would still run into problems here: either the actor wouldn’t let you in (because it’s currently still “in use”), and you’d be deadlocked; or it would let you in and the guarantee of always having a valid state would be broken.

1 Like

Not an expert, but I think the only requirement is all those async calls should be in the actor's isolation, which is true in your scenario. See the following in SE-0472:

If a potential suspension point does not actually suspend, we still continue running on the calling context.

EDIT: of course you should make sure that those async functions don't call async functions running in other isolations. I think this is also true in your scenario, but in general I think this approach is risky.

EDIT2: I think this approach avoids the issues pointed out by grynspan and jrose because there is no suspension happening.

EDIT3: If this is a frequently requested feature, I wonder if it's possible to introduce a new Task API (say, Task.synchronous) which does not only Task.immediate but also have runtime check to make sure the entire task runs on the current executor (that is, no executor hop) and hence synchronously.

If the Task's API allowed the return of a value to a synchronous context, would this help?

func f () {
  let t = Task {
     return await g ()
  }
  ...
  // need the value now
  let value = t.syncValue  // t.syncValue is of type Task.SyncValue, which is an enum
}

func g () async -> Int {
   ...
}

it’s a way to rephrase the question but not answer it, essentially this is what await t.value does, but in a synchronous context, which is indeed what I am asking about.

The trouble is, how does one implement such a concept.

I believe that when the synchronous context is such that we are already operating within an executor, this should be realistically feasible, since it effectively means “return the execution to the current executor and resume here from the executor once the task has completed and its value determined”.

When the synchronous context is not on an executor, then it is not possible to do this, since there is no executor to defer to and resume from, and as such the only way to implement this concept would be to truly block the current thread with a lock which is automatically released once the task completes and the value made available.

I’d honestly personally prefer a more generically applicable & safe API which doesn’t require any kind of assumptions about what the task itself may or may not be doing. It’s not great for a piece of code to suddenly fail just because you add some valid async code in an actor somewhere else that makes it defer execution to a foreign executor.

Probably this is something that a custom executor could solve for, then one could,

executor.preconditionIsolated()
var result: Result<>!
Task.immediate(executorPreference: executor) {
    result = ... async code ...
}
while result == nil {
    executor.yield()
}

... sync access to async result ...

If there is no suspension happening, the function is not asynchronous and can be implemented without tasks or async/await.

I don't know the OP's scenario for sure, but I meant the scenario where the caller is already in the isolation domain where the callee (an async function) should run. In that case there is no suspension happening (from what I read this is a guaranteed behavior).

How is that possible? Synchronous and asynchronous functions in Swift are different function colors. It's impossible to call asynchronous functions from synchronous functions in a general way.

I think this is where the disconnect lies: if the function you're calling never leaves the current isolation domain, then it doesn't need to be async and does not need to be called with await. It should be possible to just call it directly. If the function you're calling does hop off the current executor/task/thread/etc., then yes it's async but that also means (as @jrose suggested) the compiler cannot guarantee that it will never need to reenter the current isolation domain. If it's possible for it to be reentrant, it is unsafe to block the current serial executor because that will cause a deadlock.

If the question is really "let me assert that this async function never leaves the current isolation domain and let me call it synchronously", that's out of the realm of safe concurrent programming because the compiler cannot guarantee that what you say is true.

1 Like