Is @concurrent now the standard tool for shifting expensive synchronous work off the main actor?

DispatchQueue implements TaskExecutor.

6 Likes

It's because zipUserData itself is a suspension point. In my understanding async/await is required so that compiler can translate this:

    let result = await zipUserData(userData)
    print(result)

to something like:

    DispatchQueue.global(qos: .background).async {
        let result = zipUserData(userData)
        DispatchQueue.main.async {
            print(result)
        }
    }

While I agree on the point about premature optimization, you really don't want to block a Swift Concurrency thread for a long time ever. Experimentally, you might find that it doesn't immediately cause a problem, but try composing a few features like that and things will quickly break down. And if the workload is partially user defined, you may need to assume the worst and have it run on a background thread anyway.

As for parallelization: I hope in the future one can use Swift without Dispatch to run expensive, trivially parallel work and maximize its performance without leaving the cooperative thread pool unresponsive. Thinking, for example, about exporting a bunch of data: you want it done as soon as possible, and it may benefit greatly from parallelization, but you still want to be able to schedule other work in the thread pool.

Yielding could be an option, but for truly sensitive work yielding too much hurts performance and yielding too little will make the cooperative thread pool unresponsive in slower devices.

2 Likes

Sorry, I was replying more generally towards the discussion of having dedicated threads for heavy compute while avoiding GCD too :upside_down_face:

I was suggesting that we effectively schedule (the otherwise-nonisolated work of) detached tasks on exactly that sort of executor

I don't think that this should be an implicit behavior, it kind of conflicts with the general idea that the concurrency thread pool is constrained to a known limit, and users creating tons of such detached tasks would undermine the idea. Echoing @David_Smith's reply, this probably should be a more controlled consideration, where it makes sense that users implement (or at least explicitly choose) their own executors to suite such specific needs — although there could well be a common library of such executor flavors so that it's not a chore.

2 Likes

DispatchQueue is capped to something like 512 threads to avoid thread explosion, no?

1 Like

Building up to anywhere near 512 threads is effectively still thread explosion, but yes, there is also a cap on how many threads you can create in GCD.

7 Likes

I would be very surprised if yielding was more expensive than having additional threads in general. Context switches aren’t free, kernel memory isn’t free, the scheduler decay curve takes into account the number of runnable threads (so your main thread priority will decay faster if many threads are waiting).

Like, we designed it this way for a reason. We genuinely do think not having extra threads hanging around is the right call for most situations.

It is true that picking how often to yield can be a bit tricky in some cases. We should make sure that code path in the runtime is as fast as possible to help make that choice easier.

6 Likes

Fair. I went back to the last project where I had faced this, and after a bit of profiling to ensure I was comparing apples to apples, the Swift Concurrency version of the code is actually slightly faster (by ~10%) than the GCD one if I pick a good yield frequency. Neat.

But it is possible to yield too much: a more conservative yield frequency doubles the execution time, and naively yielding on every iteration makes the code ~60x slower (of course, that's because it's yielding at the μs scale, but that's hardly apparent from the code alone).

I guess my point here is that GCD gives you a very nice behavior out of the box: you can create a concurrent queue, throw in a bunch of long running work, and have near-maximal performance without needing to worry about other work not being able to be scheduled.

I wish there was a tool in Swift Concurrency that allowed me to do just that. Task groups come close conceptually, but its simplest usage (no yielding, or yielding all the time) comes with huge caveats.

Perhaps something as simple as having a low-overhead way of yielding conditionally if a task has been running for a while would suffice. IDK.

7 Likes

Neat idea. Seems worth exploring. Another thought I had was that yielding when there’s no pending work might be optimizable; stick a flag in an inline TSD slot for “is there pending work?” or something.

Haven’t looked into it though. Also not sure how often it’s relevant (ie how often there’s a frequently yielding task and no pending work at the same time)

If it was cheaply possible to yield if there is pending work might be nice on the surface, but would have the risk of ping-ponging between tasks quite easily and might be a footgun.

We use the pattern in subtasks:

iterations += 1
if iterations.isMultiple(of: 10_000) { // some constant to amortise the overhead across multiple iterations
  try Task.checkCancellation() // and/or yield
}

But it would be nice to be able to bake this into an iterator really - an automatically yieldable/canceallable iterator that does it at some reasonable frequency (or after X time passed)?

Most of our use cases involves an iterator processing some large amount of data, so this might be a nice way to make that automatic.

7 Likes

Swift or Dispatch needs built-in support for this pattern:

let p1 = DispatchQueue(width: 4)
let p2 = DispatchQueue(width: 4)
let p3 = DispatchQueue(width: 4)

Task.detached(executorPreference: p1) { ... }
Task.detached(executorPreference: p2) { ... }
Task.detached(executorPreference: p3) { ... }

I call this pattern “islands of cooperation in a sea of preemption”. It recognizes that preemption is expensive (the thread count is bounded), but also that preemption is sometimes necessary (the jobs on p1 don’t cooperate well with the jobs on p2, p3, or the default pool).

OperationQueue's maxConcurrentOperationCount has this superpower.

2 Likes

The problem with using a concurrent queue “out of the box” is that it doesn’t really handle the thread-explosion scenario well. So, it comes down to your definition of “a bunch of long running work”. If just a few items, a concurrent queue is fine. But if you’ve got a lot, you might want to be careful.

The DispatchQueue.concurrentPerform is the “go to” solution for GCD thread-explosion scenarios (often married with striding, to optimize how much work is done on each thread). You can then bridge this back to Swift concurrency with a continuation.

That avoids blocking the cooperative thread pool, mitigates thread explosion, etc.

But the OP’s situation is obviously much simpler, not requiring any of this…

(I'm soo… late to the party, but I had this tab open for a month without posting an answer.)

This is a long post, but the short answer is it depends on the characteristic of the job itself. Single continuos workload? Many smaller jobs that add to a long one? Syscall? One important thing is that both nonisolated(nonsending) and @concurrent will inherit the parent task priority, so be careful to not accidentally run your long job on the user event priority.

As for the longer answer: I would say that there are 2 main use cases for async/await:

  • IO - probably solved with OS libraries, something like “OS notifies us about an event” instead of “I’m busy waiting for an event”.
  • Long intensive work - video processing, AI etc. This is the OP case.

Those 2 cover 99% of usages, anything else should probably avoid any concurrency. Synchronous code is much easier to read, write and maintain. Updating 1000 of objects/properties sounds slow to humans, but it is nothing for a modern system.

Long intensive work can be further divided into a 2 groups:

  • Long sliceable block - a lot of smaller blocks (like generating thumbnails) or a single longer block sprinkled with Task.yield.
  • Long non-interruptible block - calling an external library like FFmpeg; as soon as it starts it will occupy the calling thread, and we won’t be able to do anything about it. Note that the library will probably have its own threading solution, for example FFmpeg will use all of the available cores, but this is not really relevant here.

Long sliceable block, is simpler to deal with:

  • Use lower priority - jobs with the same priority WILL end up pooled/queued together. Under no circumstances this should be scheduled with the same priority as user events. If you want I can write a short demo, but this is pretty self explanatory.
  • Use checkpoints (resumption points) - user can kill the app or it can crash. Checkpoints can be as simple as a standalone SQLite database with 2 tables: Items and Results. The remaining work is a subtraction: RemainingWork = Items - Results. Resumption is a MUST when dealing with long tasks.
  • In the case of multiple independent jobs (like generating thumbnails) do not create a separate task for each item. Instead batch them together, or create a pool of tasks that will drain a single Array/Channel. This is especially important when we have a LOT of short jobs that add up to a long job, as the concurrency cost may out-weight the cost of a single job.

OP directly mentioned @concurrent and the only thing that worries me would be the priority:

// Swift 6.2.1 on Ubuntu 24.04

func nonisolated_fn(_ priority: String) async {
  let i = #isolation
  print("[\(i)] nonisolated: \(priority) -> \(Task.currentPriority)")
}

// Runs within the actor isolation.
nonisolated(nonsending) func nonSending_fn(_ priority: String) async {
  let i = #isolation
  print("[\(i)] nonisolated(nonsending): \(priority) -> \(Task.currentPriority)")
}

// Runs outside of the actor isolation.
@concurrent func concurrent_fn(_ priority: String) async {
  let i = #isolation
  print("[\(i)] @concurrent: \(priority) -> \(Task.currentPriority)")
}

// Call those functions from within an isolation. You can also create @MainActor wrappers.
actor GimmeIsolation {
  func nonisolated_actor(_ priority: String) async { await nonisolated_fn(priority) }
  func nonSending_actor(_ priority: String)  async { await nonSending_fn(priority) }
  func concurrent_actor(_ priority: String)  async { await concurrent_fn(priority) }
}

let a = GimmeIsolation()

print("Main priority: \(Task.currentPriority)") // medium

await a.nonisolated_actor("\(Task.currentPriority) (main)")  //                          [nil] medium -> medium
await Task.detached(priority: .low)    { await a.nonisolated_actor("low") }.result    // [nil] low    -> medium
await Task.detached(priority: .medium) { await a.nonisolated_actor("medium") }.result // [nil] medium -> medium
await Task.detached(priority: .high)   { await a.nonisolated_actor("high") }.result   // [nil] high   -> high

await a.nonSending_actor("\(Task.currentPriority) (main)")  //                          [GimmeIsolation] medium -> medium
await Task.detached(priority: .low)    { await a.nonSending_actor("low") }.result    // [GimmeIsolation] low    -> medium
await Task.detached(priority: .medium) { await a.nonSending_actor("medium") }.result // [GimmeIsolation] medium -> medium
await Task.detached(priority: .high)   { await a.nonSending_actor("high") }.result   // [GimmeIsolation] high   -> high

await a.concurrent_actor("\(Task.currentPriority) (main)")  //                          [nil] medium -> medium
await Task.detached(priority: .low)    { await a.concurrent_actor("low") }.result    // [nil] low    -> low
await Task.detached(priority: .medium) { await a.concurrent_actor("medium") }.result // [nil] medium -> medium
await Task.detached(priority: .high)   { await a.concurrent_actor("high") }.result   // [nil] high   -> high

Long story short:

  • All of the functions inherited the parent (Task.detached) priority.
  • Priority low was promoted to medium, as main.swift that awaited the result was at medium. It did not happen for @concurrent, but this is a race condition with print. It will happen if you run it a few times.

You can take any concussion you want, but let me reiterate: do NOT run semi-long jobs (generating thumbnails etc.) on the user event priority, as the user events will get drowned in “the sea of same priority jobs” and executed with severe delay.

For the long non-interruptible block the only question is: can it be done on the default Swift concurrency executor?

  • Yes it can use the default executor - unlikely, watch “Visualize and optimize Swift concurrency” at WWDC (<-- link to the exact time where they talk about this). But if you end up here then follow the steps I mentioned above (priority, resumption etc…).
  • No it can’t use the default executor - we have no choice, we HAVE to move it somewhere else. Dispatch queue or a standalone thread. We can go back to the standard swift concurrency via Continuation. Custom executor is an option, but for me this API is too new to be used in production, let other people beta-test it first. Remember that custom threads/solutions:
    • Are scheduled by OS, so they ignore the Swift concurrency priority.
    • Can lead to thread explosion.
    • Will not make things magically faster. If some work needs 1000 cycles, then creating 15 threads will not decrease this number, it is still 1000 cycles that have to be done.

If you get it wrong, don’t worry, even Apple employees struggle with this. One of the previous versions of their Subprocess library did a waitpid on the default executor - this is a definition of long non-sliceable work that has no place being there:

  • 1st deadlock was when the child process itself deadlocks - in their case it was as simple as cat "Pride and Prejudice.txt" (btw. there were 5 other deadlock scenarios there). If we deadlock waitpid on the default executor then we have effectively taken down the thread. Do this enough times and things will go bad.
  • 2nd deadlock was connected to the concurrency width (number of threads), for example piping data from the 1st process to the 2nd. If only 1 thread is available then waitpid for the 1st process occupies it, and it is not possible to start the 2nd process. The 1st process fills the pipe and stops, waiting for somebody to read the data already in the pipe. In this case we have de-facto introduced requirements: the app needs concurrency width of at least 2. This is similar to how video games (Witcher 3, Cyberpunk 77) have their own requirements (GTX 1080, Intel i5 etc.).

Anyway, be careful when calling blocking syscalls (waitpid/epoll) on the default executor.

I would recommend to start with the standard Swift concurrency, as it is easier to write. Play around with it to know its characteristics (profiler etc.) then make a decision. OP case is zip/unzip which should not be scheduled on the main thread, but it should be perfectly suitable for a background Task on the default executor. (As long as we are taking about a single zip operation with reasonable size/compression level.)

Btw. we can also process the whole thing on the server and just send the result back to the device. There is a chance that you already have all of the data server side. For example: nobody will run ETL on an iPhone. Pros/cons are pretty obvious here, so I won't mention them.

5 Likes

Thanks for sharing your experience. I haven't thought much about priority before and find it useful. A few minor questions.

I couldn't reproduce it. In my experiments, @concurrent functions inherited Task.detached priority too. See a simplified example here.

I wonder how it's implemented currently? I search waitpid in subprocess repo and find nothing now.

BTW, I think "deadlock" in your post meant "blocking".

Priority

If the “high priority task” awaits on the “low priority task”, then the “low priority task” will get its priority upgraded/promoted. Your results are correct, as this will happen 100% of the time. If it doesn't then it is a bug in the Swift Concurrency.

If you run the code snippet above multiple times it may happen that it will print “low” priority - which is the priority before the promotion. It is a race condition with print, I don't think we need to worry about it. The @concurrent does not matter in this case, it just happened for me in this exact run. Even on my machine I would see the promotion 95+% of the time. I decided to include the un-promoted version because it makes the results more interesting. (In retrospect I see that it may be confusing.)

Also, the priority promotion matters in the @jeremyabannister (OP) case. Imagine that we have to generate 100 thumbnails. We create a few low priority Tasks for this. Then we await them from the user-event priority. All of those thumbnails will have their priority promoted to user-level. Now imagine that user taps a button and triggers a network request. The result of that request will arrive at the user-event priority which is the same as our thumbnails.

This is a made up example, but theoretically possible.

Bottom line is that the priority is fluid. Just because a Task was created with a certain priority does not mean that it will stay there.

Deadlock

It is a deadlock, and it is a very dangerous one that catches EVERY SINGLE PERSON I KNOW by surprise. (Even the dabrahams, not that I claim to know them.)

To communicate between processes we can use a pipe - think of it as buffer in the kernel/OS. The pipe is not infinite - it has a certain size. The size is OS dependent, but it does not matter in practice - if the algorithm depends on the size of the pipe, then the algorithm is wrong.

Imagine the following scenario:

  1. We create a pipe.
  2. We create a child process and use the said pipe to communicate.
  3. We wait for the child process to finish, for example by waitpid.
  4. Child process uses printf a bunch of times, fills the pipe, and then waits for somebody to read the data before it can write more and resume.

This is a deadlock:

  • Parent process waits for the child to complete.
  • Child process waits for the parent process to read the pipe, so it can resume.

This has nothing to do with the async/await, it is just how this works.

waitpid

When you run waitpid it will block the thread until the child process finishes. If we want to run 10 child processes then we have to create (and block) 10 threads. There are a few ways around this.

For example, the current main on Linux:

  1. They start the child process

  2. They convert the process pid into a file descriptor (see pidfd_open) and store it in ProcessIdentifier:

    // Sources/Subprocess/Platforms/Subprocess+Unix.swift
    /// A platform-independent identifier for a subprocess.
    public struct ProcessIdentifier: Sendable, Hashable {
        /// The platform specific process identifier value
        public let value: pid_t
        #if os(Linux) || os(Android) || os(FreeBSD)
        /// The process file descriptor (pidfd) for the running execution.
        public let processDescriptor: CInt
        #endif
    }
    
  3. They submit the file descriptor to epoll that runs in a separate thread (outside of the Swift Concurrency default executor):

    // Sources/Subprocess/Platforms/Subprocess+Linux.swift
    internal func monitorProcessTermination(for processIdentifier: ProcessIdentifier) {
      // Register processDescriptor with epoll
      var event = epoll_event(
          events: EPOLLIN.rawValue,
          data: epoll_data(fd: processIdentifier.processDescriptor)
      )
      let rc = epoll_ctl(
          storage.epollFileDescriptor,
          EPOLL_CTL_ADD,
          processIdentifier.processDescriptor,
          &event
      )
    }
    
  4. When the child process exits the epoll resumes with the precise information which child process finished.

This uses a single auxiliary thread for epoll (which is pretty expensive), but you can start as many child processes as you want, and this single thread will handle all of them. In a way the resource requirements do not scale with the number of the child precesses - they stay exactly the same.

Btw. epoll is a Linux thing where you submit multiple files, and the kernel/OS notifies you if something happened to (at least) one of them. For processes the “something happened” means exit, but you can submit other file types (including pipes).

If epoll is not available then they use a signals, but then they have to iterate all of the started child processes to know which one exactly finished:

private func signalHandler(
    _ signalNumber: CInt,
    _ signalInfo: UnsafeMutablePointer<siginfo_t>?,
    _ context: UnsafeMutableRawPointer?
) {
    let savedErrno = errno
    var one: UInt8 = 1
    _ = _subprocess_write(_signalPipe.writeEnd, &one, 1)
    errno = savedErrno
}

private func _reapAllKnownChildProcesses(_ signalFd: CInt, context: MonitorThreadContext) {
  loop: for (knownChildPID, continuation) in storage.continuations {
    // …
  }
}

This is a good solution. The only problem is “iterating all known processes”, but it will never be a problem in practice even with 100s of child processes. A few month ago there was a bug where they forgot to actually install the signalHandler, so it didn't work. Not sure if it was fixed.

1 Like

Yes, that's what confused me. I didn't see the un-promoted behavior in my experiments (I ran your code on Ubuntu 24.04, using Swift main-snapshot-2025-11-03):

$ while true; do .build/aarch64-unknown-linux-gnu/debug/task_priority; sleep 0.01; done > /tmp/log
$ wc -l /tmp/log
7371 /tmp/log
$ grep "low -> .*low" /tmp/log 
(no output)

So what's the recommended pattern if the code in main thread needs to get nofitication when the background task is done? Perhaps add a function call at the end of the background task to set a flag in @MainActor isolation, or use asnyc sequence if there is a dedicated actor for that background operation? Neither is as simple as await.

Just for fun, I thought of a small experiment to demonstate it. Run the two commands in separate terminals. The sleep 3600 in first command is a setup to a) not read dd output, and b) live long enough so that user can check if dd process exits. The second commmand is used to check dd process. On my Ubuntu 24.04 if count is equal to or less than 64, dd process exits; otherwise it doesn't.

term 1 $ dd if=/dev/zero bs=1024 count=64 2>/dev/null | sleep 3600
term 2 $ watch "pstree -p | grep -C 1 sleep"

Thanks for the code walk through. One very minor question: do you happen to know how signalHandler() is registered with OS? I search the entire repo but doesn't see calls like sigaction(). The signalHandler() function seems not referenced by any other code.

Communication without raising the priority

When we want to return a single value, then we can pass a Continuation to the low priority Task. Continuation is just a struct:

// github.com/swiftlang/swift/tree/main/stdlib/public/Concurrency/CheckedContinuation.swift
@available(SwiftStdlib 5.1, *)
public struct CheckedContinuation<T, E: Error>: Sendable { … }

Continuation is not “tracked” by the Swift concurrency runtime, so when we pass it to the low-priority thread it will not raise its priority. This does seem like an anti-pattern (high priority Task waiting on the Continuation resolved on the low priority Task = priority inversion), but it does make sense when we consider actor reentrance. When the actual high-priority work arrives (user taps the button -> invokes URL request), the user work will have higher priority.

When we have multiple results (for example generating thumbnails), we can use AsyncStream, but the default bufferingPolicy is .unbounded which may balloon our memory if the consumer is too slow. This is the code from the Swift stdlib (slightly modified, removed comments etc.):

// github.com/swiftlang/swift/tree/main/stdlib/public/Concurrency/AsyncStream.swift
@available(SwiftStdlib 5.1, *)
public struct AsyncStream<Element> {

  @available(SwiftStdlib 5.1, *)
  @backDeployed(before: SwiftStdlib 5.9)
  public static func makeStream(
    of elementType: Element.Type = Element.self,
    bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded
  ) -> (stream: AsyncStream<Element>, continuation: AsyncStream<Element>.Continuation)
  { … }

  public struct Continuation: Sendable {
    public enum BufferingPolicy: Sendable {
      case unbounded
      case bufferingOldest(Int)
      case bufferingNewest(Int)
    }

    public enum YieldResult {
      case enqueued(remaining: Int)
      case dropped(Element)
      case terminated
    }

    @discardableResult
    public func yield(_ value: sending Element) -> YieldResult { … }
  }
}

We can use BufferingPolicy.bufferingOldest or .bufferingNewest, but as the name suggests they will start dropping the elements when the buffer is full. This is also visible in the return type of the Continuation.yield(_:) method, as YieldResult can be dropped(Element). Note that yield is marked with @discardableResult, so it is VERY important to check the returned value when using the bounded buffer.

What we actually want is a communication channel that has await on both ends:

  • Consumer waits for items
  • Producer slows down if the consumer is not keeping up

This is basically swift-async-algorithms/AsyncChannel. It also supports multiple producers writing to the same channel, so we can have multiple “worker” Tasks. The default buffer is 1 per producer, but it can be configured to something bigger.

Example for generating thumbnails (not tested!):

  • Create low priority Task(s) for thumbnails - make sure that the priority stays the same (no promotion).
  • Create AsyncChannel (with bigger buffer) for communication.
  • Send path: URL of each generated thumbnail via the channel.
  • When the user taps the button and invokes the URL request, its result will have user-initiated priority (as it is awaited from the Task with user-initiated priority) which is higher than the thumbnails - this makes the app feel more responsive.

If we awaited our thumbnail Task(s) from the user-initiated priority, then they would get promoted and the URL response would arrive at the same priority.

Tbh. In this example using AsyncStream is still a decent choice, just don't drop any elements.

Priority promotion race

As far as I know (and I don't know much), this is a race between:

  • Task printing its priority.
  • Priority promotion because higher priority Task awaits it.

Swift guarantees that both of those events will ALWAYS happen, but the order is not guaranteed. This depends on the implementation details of the concurrency runtime. In the current version the priority promotion wins 95+% of the time (at least on my machine). In practice this does not matter. If this type of stuff matters, then the code has to explicitly guarantee the ordering on its own.

WARNING: Things below may not be exactly correct. Especially when using custom executors.

If we assume that our Task/function does not have any suspension points, we may get:

  1. Task starts
  2. Task gets the priority promotion
  3. Task finishes

The priority promotion did not change anything, as the Task was already executing.

If we look at the example where there is a suspension point:

  1. Task starts
  2. Task gets the priority promotion
  3. Task calls an actor method - await suspension point
  4. actor method finishes
  5. Scheduler selects the next Task to resume
  6. Scheduler looks at the priority AFTER the promotion
  7. Task is selected and it resumes
  8. Task finishes

Here the priority promotion did matter as we had to re-enter the scheduler.

If we assume that all of the above is true, then for a Task that satisfies the following conditions:

  • It is the highest priority Task in the system.
  • It is the only Task at the given priority.

Task.yield will do nothing. It will enter the scheduler, but since the same Task has the highest priority it will resume immediately. The scheduler is not a state machine, it will NOT remember that this task yielded 5 times in a row and pick a lower priority task. It is possible to starve those tasks.

If there are multiple Tasks at the highest priority then the scheduler chooses the one to run. If the scheduler is flooded with the thumbnail Tasks then the probability of selecting the URL request response (assuming it has the same priority as thumbnails) is up to the scheduler implementation details.

Anyway, in cooperative multitasking the priority is used by the scheduler to select the next Task. Only the Task can decide when to give up the execution (suspend/yield). In preemptive multitasking the Task with higher priority may get scheduled more often or get longer time slice.

Swift subprocess -> signalHandler

Yeah… I noticed it a few months ago before the release. Just to check if it is still present I tested the current main branch:

import Subprocess

print("sleep 5")

let result = try await Subprocess.run(
    .path("/usr/bin/sleep"),
    arguments: .init(["5"]),
    output: .discarded
)

print(result.terminationStatus)

And it worked. But when I modified the subprocess like this (I'm on Ubuntu 24.04.3 LTS which would normally use the pidfd_open/epoll path):

// Sources/Subprocess/Platforms/Subprocess+Linux.swift
internal func _isWaitprocessDescriptorSupported() -> Bool {
  return false
}

It never finished. I will ping the maintainers (@iCharlesHu and @FranzBusch), though in the past they clearly stated that any feedback on this repository is not welcome.

They can add a SUBPROCESS_FORCE_SIGNAL_TERMINATION_WATCHER compilation flag to force signals. This will allow them to run unit tests for this code path regardless of the kernel version that runs those tests. I would also refactor this code and introduce the protocol TerminationWatcher. If they can't (or don't want) protocols then “protocol as an enum” will work:

enum TerminationWatcher {
  case pidfd(PidfdTerminationWatcher)
  case signal(SignalTerminationWatcher)

  func watch(processIdentifier: ProcessIdentifier) {
    switch self {
    case let .pidfd(w): w.watch(processIdentifier)
    case let .signal(w): w.watch(processIdentifier)
    }
  }
}

Anyway, swift subprocess is still a good example of a long Task that should NOT run on the default executor.

Leaving aside the rest of your comment how did you come to this conclusion? Neither Charles nor I have stated that we do not welcome feedback. On the contrary, Charles has run multiple review threads in this forum to get feedback from the community on the API shape. See:

1 Like

A few quick comments.

Yes, that's what I asked about in my question. Your continuation solution is interesting and hacky (I verified it worked). However, I later realized the idiomatic way to do it was perhaps the follwoing:

var done = false
Task(priority: .low) {
        await doSthOnGlobalExecutor()
        done = true
}

Your continuation approach does have an advantage: it emulates await behavior so it's possible to write code in "synchronous" style. In contrast, the above approach needs a mechanism (e.g. the new Observation API) to run code when done is changed. That said, I think the above approach is far more better in all other aspects.

Is priority promition determined at runtime? I thought it was at compile time (I don't know anything about this either).