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

This is a good question. I always assumed that it is a dynamic/runtime property. Here would be a code snippet for this:

let task = Task.detached(priority: .low) {
  while true {
    let i = #isolation
    let priority = Task.currentPriority
    print("[\(i)] low -> \(priority)")

    if priority != .low { break }

    try await Task.sleep(for: .seconds(1))
  }
}

// Let the low priority task print 'low' a few times.
try await Task.sleep(for: .seconds(5))

// Priority promotion.
_ = await task.result

This will print:

[nil] low -> TaskPriority.low
[nil] low -> TaskPriority.low
[nil] low -> TaskPriority.low
[nil] low -> TaskPriority.low
[nil] low -> TaskPriority.low
[nil] low -> TaskPriority.medium
3 Likes

IIUC, priority escalation is primarily a dynamic capability. see the somewhat-recent pitch for triggering/observing escalation at runtime in SE-0462 which covers some of it.

same, and i've actually never considered that it could potentially be handled statically, which is an interesting idea. i think in general though it has to largely be dealt with dynamically because the structure and priorities of work in a Task dependency graph can change over time and in ways that aren't knowable at compile time. e.g. even if we had good static knowledge of the structure and priority values, there's still cases like this:

Task(priority: .medium) {
  _ = await Task(priority: Bool.random() ? .high : .low) {
    while true { ... }
  }.value
}
3 Likes

It's also worth remembering that multi-hop and after-the-fact priority inversion avoidance can happen. For example, if you take a mutex and then synchronously send an xpc message to another process, if a higher priority thread waits on the mutex then the thread in the other process needs to get boosted.

6 Likes

That implementation now uses signalfd(2) or epoll(7) instead.

Thanks, I took a closer look at the newest code. On older Linux kernels it uses "self pipe" trick (signalHandler() writes to a pipe and epoll monitors it), instead of signalfd. On newer Linux kernels it uses pidfd.

PS: there are a few other places in the code that create pipes, which are unrelated to child termination notification.

  1. C shim code: a pipe is used to check child process starts successfully
  2. StirngOutput, etc.: a pipe to read child process output
  3. eventfd: not exactly pipe, it's used to send shutdown notification from main thread to monitoring thread.