-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Run HTTP/2 stream openers outside lock #2274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -129,41 +129,56 @@ public boolean offerPendingOpener(Runnable opener) { | |
| * Race-free against {@link #failPendingOpeners}: that method sets {@code closed} and drains the queue under | ||
| * {@code pendingLock}. An opener enqueued before the drain runs is caught by the drain; an enqueue attempt | ||
| * sequenced after it observes {@code closed} here (the lock provides the happens-before) and is rejected. | ||
| * Either way no opener is left stranded. | ||
| * Either way no opener is left stranded. Opener callbacks always run after releasing {@code pendingLock}, | ||
| * because opening a stream can invoke user code and must not serialize other request submissions. | ||
| * | ||
| * @return {@code true} if the opener was run inline or queued; {@code false} if rejected because the | ||
| * connection is draining/closed or the pending queue is full (caller must fail the request) | ||
| */ | ||
| public boolean offerPendingOpener(NettyResponseFuture<?> future, Runnable opener) { | ||
| boolean runOpener = false; | ||
| synchronized (pendingLock) { | ||
| if (draining.get() || closed.get()) { | ||
| return false; | ||
| } | ||
| if (tryAcquireStream()) { | ||
| opener.run(); | ||
| runOpener = true; | ||
| } else { | ||
| if (pendingCount >= MAX_PENDING_OPENERS) { | ||
| return false; | ||
| } | ||
| pendingOpeners.add(new PendingOpener(future, opener)); | ||
| pendingCount++; | ||
| } | ||
| return true; | ||
| } | ||
| if (runOpener) { | ||
| opener.run(); | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| private void drainPendingOpeners() { | ||
| List<PendingOpener> ready = null; | ||
| synchronized (pendingLock) { | ||
| // Open as many queued requests as there are now-free stream slots. A single stream completion | ||
| // frees exactly one slot (so this usually runs one opener), but a SETTINGS frame that RAISES | ||
| // SETTINGS_MAX_CONCURRENT_STREAMS frees several at once — drain them all here rather than waking | ||
| // only one and stalling the rest until the next completion (a missed-wakeup; the Issue #2160 | ||
| // silent-timeout class). tryAcquireStream() enforces the cap and the draining/closed gate, so | ||
| // this never over-opens; every poll is under pendingLock, so a non-empty queue always yields a | ||
| // non-null opener. | ||
| // this never over-opens; reserve each slot and dequeue its opener atomically under pendingLock. | ||
| while (!pendingOpeners.isEmpty() && tryAcquireStream()) { | ||
| pendingCount--; | ||
| pendingOpeners.poll().opener.run(); | ||
| if (ready == null) { | ||
| ready = new ArrayList<>(); | ||
| } | ||
| ready.add(pendingOpeners.poll()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice catch on the contention, and keeping tryAcquireStream inside the lock was the right call. One thing on this loop though: every opener is polled out of the queue and pendingCount decremented before any of them run. If the first opener throws, the rest are already gone from pendingOpeners, so they never run, never get re-queued, and their reserved stream slots are never released. Before this change a throwing opener left the queue intact and the next releaseStream drained it. I queued three openers with the first one throwing, then raised SETTINGS_MAX_CONCURRENT_STREAMS so all three drained in one pass: Two requests stranded and two slots leaked, which is the silent-timeout class this file is built to prevent. It is not reachable through openHttp2Stream today because Netty swallows throwables in open() and in DefaultPromise.notifyListener0, but addPendingOpener(Runnable) and offerPendingOpener(Runnable) are public and take arbitrary Runnables. Poll one and run one is smaller than what is here, keeps the openers outside the lock, drops the per drain ArrayList, and restores the old throw behaviour: The ArrayList and List imports stay as they are, failPendingOpeners still needs them. I ran this locally: all 49 tests in Http2ConnectionStateTest pass including both new ones, and the probe above goes back to ran=[1, 2, 3].
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. I replaced the ready-list batch with a poll-one/run-one loop. Each stream slot and opener are now reserved atomically under pendingLock, then that opener runs after the lock is released. If it throws, later openers remain queued and no slots are pre-reserved for callbacks that did not run. |
||
| } | ||
| } | ||
| // Stream opening can invoke user callbacks such as onRequestSend. Run after releasing pendingLock so | ||
| // a slow callback does not serialize unrelated submissions to this HTTP/2 connection. | ||
| if (ready != null) { | ||
| for (PendingOpener pending : ready) { | ||
| pending.opener.run(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ | |
| import java.util.concurrent.CyclicBarrier; | ||
| import java.util.concurrent.ExecutorService; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.Future; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.atomic.AtomicInteger; | ||
|
|
||
|
|
@@ -270,6 +271,63 @@ public void pendingOpenerRunsOnRelease() { | |
| assertEquals(1, executionCount.get(), "Pending opener should have been executed on release"); | ||
| } | ||
|
|
||
| @Test | ||
| public void immediateOpenerRunsOutsidePendingLock() throws Exception { | ||
| Http2ConnectionState state = new Http2ConnectionState(); | ||
| state.updateMaxConcurrentStreams(2); | ||
| CountDownLatch openerStarted = new CountDownLatch(1); | ||
| CountDownLatch releaseOpener = new CountDownLatch(1); | ||
| ExecutorService executor = Executors.newFixedThreadPool(2); | ||
|
|
||
| try { | ||
| Future<Boolean> blockingOffer = executor.submit(() -> | ||
| state.offerPendingOpener(blockingOpener(openerStarted, releaseOpener))); | ||
| assertTrue(openerStarted.await(5, TimeUnit.SECONDS), "first opener should start"); | ||
|
|
||
| Future<Boolean> competingOffer = executor.submit(() -> state.offerPendingOpener(() -> { })); | ||
| assertTrue(competingOffer.get(5, TimeUnit.SECONDS), | ||
| "a running opener must not hold pendingLock"); | ||
|
|
||
| releaseOpener.countDown(); | ||
| assertTrue(blockingOffer.get(5, TimeUnit.SECONDS)); | ||
| state.releaseStream(); | ||
| state.releaseStream(); | ||
| } finally { | ||
| releaseOpener.countDown(); | ||
| executor.shutdownNow(); | ||
| assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void queuedOpenerRunsOutsidePendingLock() throws Exception { | ||
| Http2ConnectionState state = new Http2ConnectionState(); | ||
| state.updateMaxConcurrentStreams(1); | ||
| assertTrue(state.tryAcquireStream()); | ||
| CountDownLatch openerStarted = new CountDownLatch(1); | ||
| CountDownLatch releaseOpener = new CountDownLatch(1); | ||
| state.addPendingOpener(blockingOpener(openerStarted, releaseOpener)); | ||
| ExecutorService executor = Executors.newFixedThreadPool(2); | ||
|
|
||
| try { | ||
| Future<?> drain = executor.submit(state::releaseStream); | ||
| assertTrue(openerStarted.await(5, TimeUnit.SECONDS), "queued opener should start"); | ||
|
|
||
| Future<Boolean> competingOffer = executor.submit(() -> state.offerPendingOpener(() -> { })); | ||
| assertTrue(competingOffer.get(5, TimeUnit.SECONDS), | ||
| "a drained opener must not hold pendingLock"); | ||
|
|
||
| releaseOpener.countDown(); | ||
| drain.get(5, TimeUnit.SECONDS); | ||
| state.releaseStream(); | ||
| state.releaseStream(); | ||
| } finally { | ||
| releaseOpener.countDown(); | ||
| executor.shutdownNow(); | ||
| assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); | ||
| } | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These two cover the lock release nicely. One gap alongside them: neither drains more than a single opener, and multiplePendingOpenersExecuteInOrder just below releases one slot at a time, so the ready list never holds more than one entry. The batch drain this PR introduces has no coverage. Could you add a case that queues three openers and then calls updateMaxConcurrentStreams with a higher value so all three drain in one pass, asserting execution order and the final activeStreams count?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added coverage for both multi-opener paths. One test raises the limit from one to four and verifies all three queued openers run in order with four active streams. A second test makes the first opener throw and verifies the remaining two stay queued and drain on the next release. |
||
|
|
||
| @Test | ||
| public void multiplePendingOpenersExecuteInOrder() { | ||
| Http2ConnectionState state = new Http2ConnectionState(); | ||
|
|
@@ -897,4 +955,18 @@ public void releasePermitOnceIsAtomicUnderConcurrency() throws InterruptedExcept | |
| } | ||
| assertEquals(rounds, totalReleases.get(), "exactly one release per round"); | ||
| } | ||
|
|
||
| private static Runnable blockingOpener(CountDownLatch started, CountDownLatch release) { | ||
| return () -> { | ||
| started.countDown(); | ||
| try { | ||
| if (!release.await(10, TimeUnit.SECONDS)) { | ||
| throw new AssertionError("timed out waiting to release opener"); | ||
| } | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| throw new AssertionError("interrupted while waiting to release opener", e); | ||
| } | ||
| }; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small doc point. "Either way no opener is left stranded" now has a third state it does not cover: an opener already dequeued into the ready list but not yet run, which failPendingOpeners can no longer see. Unreachable in practice given the event loop confinement, but the reasoning block down at failPendingOpeners no longer tells the whole story. The poll one run one form suggested above removes that state entirely, so this sentence stays accurate as written.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ready-list intermediate state is gone. The drain now removes only the opener it is about to run, so the existing no-stranding reasoning remains accurate.