Repository navigation
Conversation
…t stream, so a warm request allocates none of the parser's Every stream got a new PendingRequest, Nghttp2Request, header KeyValueList and array, and a field-range List with its array: about 650 B per request. A request whose body arrived whole has left _pending by the time it is dispatched, so once its handler is done it goes back to the connection with all of them, and the next stream reuses it. A streamed body's request stays in _pending until its stream ends, so it is only disposed, as before. A dispatch swaps two ready lists instead of copying one.
…of the arena; tests cover every retire path From review: - A retired request still pointed into the arena Dispose had just returned to ArrayPool, so a pooled entry could pin it (with a body, up to 16 MiB). Reset now lets go of the request's memories and headers. - The pool keeps at most 32, never one whose field list grew past 64 (a peer's thousands of header lines; nghttp2 does not limit their number), and only on the reactor, the one thread that rents. - Tests: the reuse test sends a host instead of :authority, so a stale authority would show; a parked handler's request goes back once it answers (CompleteAsync); a request whose body is still arriving is never handed on (fails with the BodyReader guard removed: stream 3 is never dispatched); the warm-request test warms both ready lists and counts its handlers.
…r the streamed path, the size check and the cap From review: the second ready list guarded against something that cannot happen. Only nghttp2's callbacks add to it, and they run inside ih2_read, which never runs during the walk. The walk now goes over _readyThisPass by index and clears it after; _dispatching and TakeReady are gone. Three tests close the gaps that let a retire path or a bound regress unseen: - a streamed response's request goes back once its handler is done (ServeStreamedAsync); - a request with 65 header lines is not kept, and a small one after it is; - 40 parked handlers leave 32 requests in the pool. Each fails against a mutation of the code it guards. Comments are cut to one line.
…es a retired request let go of its arena No wrapper and no claim about what can add to the list during a walk: a drain's on_stream_close runs inside it, and a queue doesn't care.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
PendingRequest,Nghttp2Request, headerKeyValueListwith its array, and a field-rangeListwith its array: about 650 B per request.The fix
The same approach #486 takes for
ioxide.http2:_pendingby the time it is dispatched (atCallbackEndStream). Once its handler is done,Retiredisposes it and keeps it on the connection (_pendingPool) with itsNghttp2Request, header list and field ranges.CallbackBeginHeaderstakes one from there for the next stream.CompleteAsyncandServeStreamedAsync._pendingafter its handler returns, until its stream ends, because later DATA and trailers still find it there. It is only disposed, as before. Pooling it at retire could hand a stream that is still live to a new one.Freezefills the request's ownNghttp2Requestinstead of a new one.Resetlets go of the request's memories into the arena that went back toArrayPool, so the pool pins nothing.Queuethat a dispatch drains, instead of aListcopied to an array each pass.Behaviour
Nghttp2Requestis reused for a later stream on the same connection once its handler is done, when its body arrived whole.API
None public.
Tests
New in the Unit suite (
Nghttp2RequestReuseTests.cs), over the same strict test transport the managed stack's tests use:nghttp2 request: the next stream's request holds nothing of the one beforehost. nghttp2 needs one or the other, and with no:authoritya stale one would show.nghttp2 request: a parked handler's request goes back once it answers, holding nothingCompleteAsyncpath. Once back, the request's memories are empty, so the pool pins no arena.nghttp2 request: a streamed response's request goes back once its handler is doneServeStreamedAsyncpath.nghttp2 request: a request a peer sent 65 header lines is not kept for the next streamnghttp2 request: a connection keeps at most 32 requests for later streamsnghttp2 request: a request whose body is still arriving is not handed to the next streamRetire'sBodyReaderguard removed, this fails: stream 3's headers land on stream 1's request as trailers, and stream 3 is never dispatched.nghttp2 request: a warm connection's request allocates none of the parser's objectsAgainst main's
ioxide.nghttp2(the tests only):(The first version of the tests. The tests that check reuse fail there the same way, and the ones that check what is not kept pass, since main never pools.)
Each later test was also run against a mutation of the code it guards and failed there:
Retireturned back intoDisposeon the streamed path, theIsSmallcheck removed, the cap removed, and a retired request keeping its memories.With the fix:
Review
An 8-agent review of this PR and its siblings found the reuse sound: no pooled request is reachable, and each is retired once. It found the following, fixed in 87f5da2:
:authority.It also found an older bug, already on main: in buffered mode the shim never forwards a peer's
RST_STREAM(NO_ERROR), so that request leaks until the connection closes. That one is filed as #493.A later round found that the comment on the in-place walk was wrong, because a drain's
on_stream_closeruns during it. That was harmless, as it never touches the list. TheQueueneeds no such claim (df9298f).Bench
Re-measured on the final code, with the same setup, 6 more rounds:
Nghttp2Buffered: -6.0% server CPU per request (control 0.0%), +5.4% req/s. Ranges: 892-1,083 ns on base, 850-967 ns on the branch.Nghttp2Response: -3.8% server CPU per request (control +2.7%), +3.9% req/s, which is close to the noise.Nghttp2Buffered(base 750 B), and 466 B onNghttp2Response(base 1,130 B).Queuein between. The sameQueueshows no cost on the managed stack (http2: a streamed flush that waits for the write pump allocates nothing, and a connection reuses its request objects #486).The tables below are the first run.
Setup: the same as #486.
h2load -c 32 -m 16 -t 1(with--alpn-list=h3for HTTP/3) pinned to CPU 2, on separate physical cores.Http2/Nghttp2BufferedHttp2/Nghttp2ResponseAllocations, from
dotnet-countersover 20 s under the same load:Nghttp2Buffered, B per requestNghttp2Response, B per requestWhat is left:
Fixes #489.