Parameter isolation vs nonisolated(nonsending): different suspension behavior with task executor preference

suppose you have two functions like this:

func inheritIsoParam(
    iso: isolated (any Actor)? = #isolation
) async {
    print("param iso")
}

nonisolated(nonsending)
func inheritIsoNINS() async {
    print("nins")
}

and that you have a custom executor that logs when jobs are enqueued:

final class Exec: TaskExecutor, SerialExecutor {
    let q = DispatchQueue(label: "q")

    func enqueue(_ job: consuming ExecutorJob) {
        print("ENQUEUE")
        let j = UnownedJob(job)
        q.async {
            j.runSynchronously(
                isolatedTo: self.asUnownedSerialExecutor(),
                taskExecutor: self.asUnownedTaskExecutor()
            )
        }
    }
}

when running in a task with this custom executor set as the task executor preference, i thought that calls to both of these functions would not result in an enqueue() call to the executor. i was under the impression that both functions inherit their callers executor so should not suspend upon entry. however, it appears i was mistaken, at least in some cases, such as this one:

@concurrent
func test() async {
  let exec = Exec()
  print("1")
  await withTaskExecutorPreference(exec) {
    print("2")
    await inheritIsoParam()
    print("3")
    await inheritIsoNINS()
    print("4")
  }
  print("5")
}

this prints (annotations added):

1
ENQUEUE
2
ENQUEUE  <~~~ why??
param isolated
ENQUEUE
3
nins
4
ENQUEUE
5

as you can see, there is an enqueue that occurs when calling the parameter-isolated function, despite there being no change in isolation. this does not occur when calling the nonisolated(nonsending) variant though, which surprised me.

i noticed that this behavior only appears to occur if the custom executor passes a serial executor when running its jobs. e.g. if the jobs are instead run with runSynchronously(on: self.asUnownedTaskExecutor()) then it prints out:

1
ENQUEUE
2
param isolated
3
nins
4
5

and there's only a single enqueue onto the executor.

why is the behavior so different when a serial executor is involved? and specifically in this case, why does there appear to be a dynamic suspension of the task when calling the parameter-isolated function? is this behavior correct & expected?


godbolt: Compiler Explorer
related issue: withContinuation APIs appear to re-enqueue a job when calling the closure · Issue #85668 · swiftlang/swift · GitHub

5 Likes