NIOHTTP2: fix outbound stream starvation - #559
Conversation
glbrntt
left a comment
There was a problem hiding this comment.
Can you add a unit test (or tests) for this? Given the approach is now round-robin you should be able to write deterministic tests for this.
46973b8 to
284c076
Compare
284c076 to
e61464b
Compare
| let served = self.receivedFrames().map { $0.streamID } | ||
|
|
||
| // Every stream must be served once per round so that none can be starved. | ||
| let firstRound = Array(served.prefix(streamIDs.count)) | ||
| XCTAssertEqual(Set(firstRound), Set(streamIDs)) | ||
| XCTAssertEqual(served, firstRound + firstRound + firstRound) |
There was a problem hiding this comment.
Can we be more concrete here?
I expect served is just [1, 3, 5, 1, 3, 5, 1, 3, 5] which makes the round-robin nature of this explicit.
263267b to
09b4e44
Compare
glbrntt
left a comment
There was a problem hiding this comment.
Thanks, this looks good now but it has regressed allocations. That's expected (Set + CircularBuffer) and acceptable because it's at the connection level, you will however need to update the allocation limits. swift-nio has some scripts to do this but they changed a little while ago so you'll need to figure out the right invocation.
glbrntt
left a comment
There was a problem hiding this comment.
great, thanks @nishaddeokar!
Problem
nextStreamToSend()returnedself.flushableStreams.first, and sinceSet.firstalways yields the same stable element, under load the buffer kept handing the whole connection window to one stream while the rest starved to near-zero.Fix
Rotate through the flushable streams instead of always picking the same one.
flushableStreamsstays the source of truth for membership, and alongside it we keep a queue of those streams (flushableQueue) in the order to serve them. We pop the front to pick the next stream, and append it to the back once it's been served, so every flushable stream gets a turn per sweep and none can be starved. Both the pop and the append are O(1). Removing a stream from the middle of the queue would be O(n), so we avoid this. A stream that stops being flushable is left as a stale entry and simply skipped when it reaches the front (lazy deletion), with aqueuedStreamsset to stop us ever enqueuing a duplicate.Results
iperf3 -P 32through an HTTP/2 CONNECT proxy (32 streams on one connection), on Linux, per-stream receiver throughput:.first.randomElement()Using Jain's fairness index to quantify fairness:
.first: 6862.5² / (32 × 2,259,097.7) = 0.6514.randomElement(): 4928.2² / (32 × 758,994.4) = 0.9999Round-robin: 4960.3² / (32 × 768,893.7) = 1.0000
Why round-robin over .randomElement()
randomElement()also fixes the fairness problem, but round-robin gives a deterministic guarantee that every flushable stream is served once per sweep rather than being fair only on average. Round-robin also avoids the system call caused byrandomElement()on Linux, leading to better throughput.