Skip to content

Priority inversion: userInitiated demux consumer blocks on the utility read-ahead producer #519

Description

@a1go3

Summary

Thread Performance Checker reports a priority inversion on the software playback path: the demux consumer (.userInitiated) blocks on an NSCondition waiting for the read-ahead producer (.utility) to deliver packets.

Thread running at User-initiated quality-of-service class waiting on a lower QoS
thread running at Utility quality-of-service class. Investigate ways to avoid
priority inversions
  Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift:231

The two sides

Producer — SoftwarePacketReadAhead.swift:38:

private let worker = DispatchQueue(label: "engine.sw.packet-prefetch", qos: .utility)

Consumer — SoftwarePlaybackHost.swift:120, which calls readAhead.read(...) at SoftwarePlaybackHost.swift:2336:

private let demuxQueue = DispatchQueue(label: "engine.sw.demux", qos: .userInitiated)

The block is the wait loop at SoftwarePacketReadAhead.swift:231:

while count == 0, !ended, failure == nil, !closed, !seeking,
      token == generation, isCurrent() {
    condition.wait()
}

NSCondition does not donate the waiter's priority, so whenever the FIFO runs dry the .userInitiated consumer is gated on a .utility producer with no priority boost.

When it shows up

Reproducible whenever reads are slow enough for the buffer to empty — for us that is 1080p MKV streamed over WebDAV, where the producer is network-bound rather than CPU-bound. It fires around startup and after seeks, i.e. exactly when the buffer is empty by construction.

The prefetch queue is genuinely elective while the FIFO has depth, so .utility is the right resting state; the inversion is only about the window where the consumer is actually blocked on it.

Possible directions

Whichever fits the design best:

  1. Raise the read-ahead worker to .userInitiated — simplest, at the cost of the elective-work property when the FIFO is full.
  2. Keep .utility at rest and boost only while a consumer is blocked: dispatch produce() as a DispatchWorkItem and have the blocked consumer wait() on it, which propagates the waiter's QoS, instead of waiting on the bare condition.
  3. Re-dispatch the pending produce() at the consumer's QoS when the wait loop is about to be entered with count == 0.

Option 2 keeps the intent of #27 (the elective decode must yield to real-time playback) while removing the inversion in the one window where the work is not elective any more.

Environment

  • AetherEngine 6.76.0
  • iPhone 13 Pro / iOS 26.5, Debug build with Thread Performance Checker enabled
  • Software path, remote source over HTTP (WebDAV), MKV / H.264

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions