Task priority elevation for task groups and async let

In the docs for TaskPriority I saw that Swift tries to help prevent priority inversion by upgrading a low priority task when it is awaited in a high priority task. I was even able to confirm this behavior:

Task(priority: .high) {
  print("outer", Task.currentPriority.rawValue)

  await Task(priority: .low) {
    print("inner", Task.currentPriority.rawValue)
  }.value
}

This prints:

outer 25
inner 25

…even though the inner task is of low priority.

Whereas if the outer task is low and the inner task is high, then the inner task just stays as high:

Task(priority: .low) {
  print("outer", Task.currentPriority.rawValue)

  await Task(priority: .low) {
    print("inner", Task.currentPriority.rawValue)
  }.value
}

outer 17
inner 25

So clearly the inner task is upgraded to a high priority even though it was created as low priority.

But then I wondered how this works with task groups and async let, and I'm not sure I understand the results.

Task groups seem to ignore the child priority and always use the parent priority. So, if the outer task is low and the child task is high, both seem to be low:

Task(priority: .low) {
  print("outer", Task.currentPriority.rawValue)
  await withTaskGroup(of: Void.self) { group in
    group.addTask(priority: .high) {
      print("inner", Task.currentPriority.rawValue)
    }
  }
}

outer 17
inner 17

That doesn't seem correct based on my reading of SE-304. Or perhaps Task.currentPriority doesn't mean what I think it should mean in the context of a child task of a task group?

And then for async let, it does work as I expect in this simple set up:

func perform() async {
  print("inner", Task.currentPriority.rawValue)
}

Task(priority: .high) {
  print("outer", Task.currentPriority.rawValue)
  async let inner: Void = perform()
}

That prints:

outer 25
inner 25

…and the inner and outer always print the same no matter what the parent priority is.

But things get weird if I wrap the operation in a Task with a different priority and then async let it:

func perform() async {
  print("inner", Task.currentPriority.rawValue)
}
Task(priority: .high) {
  print("outer", Task.currentPriority.rawValue)
  let task = Task(priority: .low) {
    await perform()
  }
  async let inner: Void = task.value
}

This prints:

outer 25
inner 17

But I would expect both to print 25 since the inner task should have its priority elevated to high. So this seems like a possible vector for priority inversion since a high priority outer task is waiting for a low priority inner task.

Even stranger, if I make the inner task .background, then it does elevate the priority:

func perform() async {
  print("inner", Task.currentPriority.rawValue)
}
Task(priority: .high) {
  print("outer", Task.currentPriority.rawValue)
  let task = Task(priority: .low) {
    await perform()
  }
  async let inner: Void = task.value
}

outer 25
inner 25

Anyone have insight into what is going on here? Are some of these issues Swift bugs?

4 Likes

I think you're seeing a race here - you're kicking off a task and then in parallel async let-ing it, meaning that both the following order of operations are valid:

  • The task gets kicked off, starts executing, prints its current priority (low), finishes, and then has a higher-priority task attempt to access its value (elevating its priority, but it's already finished so it doesn't matter)
  • The task get kicked off, then the higher-priority task attempts to access its value, then it starts executing and prints its current priority (high) and finishes

It does not surprise me that, by changing the kicked-off task to .background, the async let would have a higher probability of starting its execution before the task body itself, but that is also not guaranteed behavior given the parallel nature of this code. If you instead had the task body be longer-running and print out its priority periodically, you should see the correct priority elevation during the course of its execution.

3 Likes

Ah yes, good catch! If I add a sleep inside the task it then behaves as I expect:

func perform() async {
  print("inner", Task.currentPriority.rawValue)
}
Task(priority: .high) {
  print("outer", Task.currentPriority.rawValue)
  let task = Task(priority: .low) {
    try await Task.sleep(for: .seconds(1))
    await perform()
  }
  async let inner: Void = task.value
}

outer 25
inner 25

And I agree. I think the .background just gives it more time to now encounter the race condition. Thanks!

That just leaves the task group as behaving differently from what I expect.

While I personally think the current behavior is likely a bug, have you tried adding a similar sleep in the task group child's body and checking if the priority changes? I wonder if behind the scenes, TaskGroup is doing something similar to the async let case for managing priorities, starting children at a lower priority and then elevating them.

Good point, but the behavior is the same. The outer priority is always used in the child even when sleeping a bit of time before printing the priority.

1 Like

Then this seems like it’s either a bug or some semantic difference with how priorities are treated in task groups that isn’t fully documented at this point - for instance, maybe there’s some concept of a task group child not being able to exceed the priority of its parent task that didn’t make it into the proposals/documentation.

It looks like Swift 5.7 introduced Task.basePriority that does behave how I'd expect:

Task(priority: .low) {
  print("outer", Task.currentPriority.rawValue)

  await withTaskGroup(of: Void.self) { group in
    group.addTask(priority: .high) {
      try? await Task.sleep(for: .seconds(1))
      print("inner", Task.currentPriority.rawValue)
      print("inner.base", Task.basePriority!.rawValue)
    }
  }
}

outer 17
inner 17
inner.base 25

But there's no documentation on what it really represents, and it was introduced outside evolution.

3 Likes