-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Fix idle pool cleaner generation race #2289
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 |
|---|---|---|
|
|
@@ -37,7 +37,7 @@ | |
| import java.util.concurrent.ConcurrentLinkedDeque; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.atomic.AtomicBoolean; | ||
| import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; | ||
| import java.util.concurrent.atomic.AtomicLongFieldUpdater; | ||
| import java.util.function.Predicate; | ||
|
|
||
| import static org.asynchttpclient.util.DateUtils.unpreciseMillisTime; | ||
|
|
@@ -52,8 +52,8 @@ public final class DefaultChannelPool implements ChannelPool { | |
| private static final AttributeKey<IdleState> IDLE_STATE_ATTRIBUTE_KEY = AttributeKey.valueOf("channelIdleState"); | ||
|
|
||
| // The partition deques hold the bare Channel; per-checkout idle state (start timestamp + the | ||
| // owned/tombstone CAS flag) lives on the channel's IDLE_STATE_ATTRIBUTE_KEY attribute, which is | ||
| // allocated once per physical connection and reused across every pool cycle (no per-offer holder). | ||
| // generation/ownership CAS state) lives on the channel's IDLE_STATE_ATTRIBUTE_KEY attribute, | ||
| // which is allocated once per physical connection and reused across every pool cycle. | ||
| private final ConcurrentHashMap<Object, ConcurrentLinkedDeque<Channel>> partitions = new ConcurrentHashMap<>(); | ||
| private final AtomicBoolean isClosed = new AtomicBoolean(false); | ||
| private final Timer nettyTimer; | ||
|
|
@@ -132,9 +132,8 @@ private boolean offer0(Channel channel, Object partitionKey, long now) { | |
| if (partition == null) { | ||
| partition = partitions.computeIfAbsent(partitionKey, pk -> new ConcurrentLinkedDeque<>()); | ||
| } | ||
| // Reuse the channel's IdleState instead of allocating a holder per offer; reset() stamps the | ||
| // idle start and clears the owned flag (must happen-before offerFirst publishes the channel, | ||
| // so any thread that observes it in the deque also observes owned == 0). | ||
| // Reuse the channel's IdleState instead of allocating a holder per offer; reset() publishes a | ||
| // new idle generation before offerFirst publishes the channel. | ||
| Attribute<IdleState> idleStateAttribute = channel.attr(IDLE_STATE_ATTRIBUTE_KEY); | ||
| IdleState idleState = idleStateAttribute.get(); | ||
| if (idleState == null) { | ||
|
|
@@ -293,52 +292,62 @@ private static final class ChannelCreation { | |
| * {@link #IDLE_STATE_ATTRIBUTE_KEY} attribute, then reused across every pool checkout so no holder | ||
| * is allocated per offer. | ||
| * | ||
| * <p>{@code owned} is a single CAS flag with two roles, both meaning "this idle entry is claimed, | ||
| * do not lease it": a successful {@code poll()} lease, or a {@code removeAll()} tombstone. The pool | ||
| * upholds the invariant that a channel sitting in a partition deque has {@code owned == 0} unless it | ||
| * was tombstoned, because {@link #reset(long)} clears the flag before {@code offerFirst} publishes | ||
| * the channel and {@code poll()} unlinks a channel from the deque before claiming it. {@code start} | ||
| * doubles as a generation token: it changes on every offer, letting the cleaner detect a channel | ||
| * that was leased and re-offered between its expiry check and its claim. | ||
| * <p>The low bit of {@code state} is the ownership flag; the remaining bits are incremented on | ||
|
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. The remaining bits are incremented by two on every offer, not by one, since bit zero is the ownership flag. Worth saying explicitly so the plus two in reset is not read as a typo.
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. Updated the description to say that the generation bits advance by two on every offer because bit zero is reserved for ownership. |
||
| * every offer. A successful {@code poll()} lease and a {@code removeAll()} tombstone both claim the | ||
| * current generation. The cleaner can therefore claim only the exact generation whose expiry it | ||
| * evaluated, without temporarily claiming a newer entry while checking whether {@code start} | ||
| * changed. | ||
| */ | ||
| static final class IdleState { | ||
|
|
||
| private static final AtomicIntegerFieldUpdater<IdleState> OWNED_UPDATER = | ||
| AtomicIntegerFieldUpdater.newUpdater(IdleState.class, "owned"); | ||
| private static final long OWNED_MASK = 1L; | ||
| private static final AtomicLongFieldUpdater<IdleState> STATE_UPDATER = | ||
| AtomicLongFieldUpdater.newUpdater(IdleState.class, "state"); | ||
|
|
||
| private volatile long start; | ||
| @SuppressWarnings("unused") | ||
| private volatile int owned; | ||
| private volatile long state; | ||
|
|
||
| long start() { | ||
| return start; | ||
| } | ||
|
|
||
| long snapshot() { | ||
| return state; | ||
| } | ||
|
|
||
| boolean isOwned() { | ||
| return owned != 0; | ||
| return isOwned(state); | ||
| } | ||
|
|
||
| /** Atomically claim this entry; returns true only for the caller that transitions 0 -> 1. */ | ||
| static boolean isOwned(long stateSnapshot) { | ||
| return (stateSnapshot & OWNED_MASK) != 0; | ||
| } | ||
|
|
||
| /** Atomically claim the current generation. */ | ||
| boolean takeOwnership() { | ||
|
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. This overload pair is easy to misread at a call site, because the no argument form silently re reads the field while the one argument form validates a caller supplied snapshot, and both are named takeOwnership. The tryTakeOwnership name suggested in the issue for the snapshot form reads better and makes the failure mode obvious.
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. Renamed the snapshot form to tryTakeOwnership. The no-argument takeOwnership method now clearly delegates to the conditional snapshot operation. |
||
| return OWNED_UPDATER.getAndSet(this, 1) == 0; | ||
| return takeOwnership(state); | ||
| } | ||
|
|
||
| /** Undo a claim taken via {@link #takeOwnership()} (used only on the cleaner re-offer race). */ | ||
| void releaseOwnership() { | ||
| owned = 0; | ||
| /** Atomically claim {@code stateSnapshot}, provided it is still the current generation. */ | ||
| boolean takeOwnership(long stateSnapshot) { | ||
| return !isOwned(stateSnapshot) | ||
| && STATE_UPDATER.compareAndSet(this, stateSnapshot, stateSnapshot | OWNED_MASK); | ||
| } | ||
|
|
||
| /** Stamp the idle start and mark the channel leasable again. Called on every offer. */ | ||
| /** Stamp a new idle generation and mark the channel leasable again. Called on every offer. */ | ||
| void reset(long now) { | ||
| long nextGeneration = (state & ~OWNED_MASK) + 2; | ||
|
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.
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 made reset an ownership transfer instead of a non-atomic update. IdleState now starts owned, reset rejects a leasable generation, publishes the timestamp, and CASes the owned snapshot to the next generation. offer also installs the per-channel state with setIfAbsent and returns false when the transfer fails. I added a regression test for duplicate offers. |
||
| // Keep the new generation unavailable until its timestamp is published. | ||
|
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. This comment does not describe what the store actually does. The JMM already keeps the new generation from being observed with a stale timestamp without this store. Volatile accesses are totally ordered in the synchronization order, per JLS 17.4.4 and 17.4.7, so a reader that observes state equal to nextGeneration must also observe start as now or newer, because the store to start precedes the store to state in program order. What this extra store really does is narrow, but not close, the window described in the comment above on the generation read, and it costs an extra StoreLoad fence per pooled request on x86 64. Either drop it, or keep it and relabel it honestly as defence in depth.
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. Removed the extra volatile state store. reset now writes the timestamp and then CASes the owned snapshot directly to the next leasable generation; synchronized reset calls prevent concurrent publishers from corrupting the winning timestamp. |
||
| state = nextGeneration | OWNED_MASK; | ||
| start = now; | ||
| owned = 0; | ||
| state = nextGeneration; | ||
| } | ||
| } | ||
|
|
||
| private final class IdleChannelDetector implements TimerTask { | ||
|
|
||
| private boolean isIdleTimeoutExpired(IdleState idleState, long now) { | ||
| return maxIdleTimeEnabled && now - idleState.start() >= maxIdleTime; | ||
| private boolean isIdleTimeoutExpired(long idleStart, long now) { | ||
| return maxIdleTimeEnabled && now - idleStart >= maxIdleTime; | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -410,7 +419,8 @@ private int reapPartition(ConcurrentLinkedDeque<Channel> partition, long now) { | |
| continue; | ||
| } | ||
|
|
||
| if (idleState.isOwned()) { | ||
| long stateSnapshot = idleState.snapshot(); | ||
| if (IdleState.isOwned(stateSnapshot)) { | ||
| // In-deque + owned ==> a removeAll(Channel) tombstone, or a node a concurrent poll() | ||
| // has already leased and unlinked. Either way: unlink, never close — the owner of the | ||
| // claim is responsible for closing it. Unlinking an already-unlinked node through the | ||
|
|
@@ -419,22 +429,17 @@ private int reapPartition(ConcurrentLinkedDeque<Channel> partition, long now) { | |
| continue; | ||
| } | ||
|
|
||
| boolean isIdleTimeoutExpired = isIdleTimeoutExpired(idleState, now); | ||
| long idleStart = idleState.start(); | ||
| boolean isIdleTimeoutExpired = isIdleTimeoutExpired(idleStart, now); | ||
| boolean isRemotelyClosed = !Channels.isChannelActive(channel); | ||
| boolean isTtlExpired = isTtlExpired(channel, now); | ||
| if (!isIdleTimeoutExpired && !isRemotelyClosed && !isTtlExpired) { | ||
| continue; // healthy idle channel, leave it for poll() | ||
| } | ||
|
|
||
| long startSnapshot = idleState.start(); | ||
| // Claim before closing so we never close a channel poll() is leasing concurrently. | ||
| if (!idleState.takeOwnership()) { | ||
| continue; // poll() (or removeAll(Channel)) won the claim; that owner now handles the channel | ||
| } | ||
| if (idleState.start() != startSnapshot) { | ||
| // The channel was leased and re-offered (fresh start) between the expiry check and | ||
| // the claim, so it is leasable again — release it instead of closing it. | ||
| idleState.releaseOwnership(); | ||
| // Claim the generation evaluated above. A lease and re-offer changes the generation, | ||
| // so this CAS cannot claim the fresh entry or interfere with a concurrent poll of it. | ||
| if (!idleState.takeOwnership(stateSnapshot)) { | ||
| continue; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ | |
| import java.util.Collections; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.concurrent.CompletableFuture; | ||
| import java.util.concurrent.ConcurrentHashMap; | ||
| import java.util.concurrent.ConcurrentLinkedDeque; | ||
| import java.util.concurrent.ConcurrentLinkedQueue; | ||
|
|
@@ -245,6 +246,63 @@ public void channelReofferedAfterExpiryIsNotReaped() throws Exception { | |
| pool.destroy(); | ||
| } | ||
|
|
||
| @Test | ||
| public void cleanerDoesNotCloseChannelReofferedAfterExpiryCheck() throws Exception { | ||
| CapturingTimer timer = new CapturingTimer(); | ||
| DefaultChannelPool pool = idlePool(timer, Duration.ofMillis(1)); | ||
| BlockingActiveChannel channel = new BlockingActiveChannel(); | ||
|
|
||
| pool.offer(channel, KEY); | ||
| idleState(channel).reset(0); | ||
| channel.blockNextActiveCheck(); | ||
|
|
||
| CompletableFuture<Void> cleanerRun = CompletableFuture.runAsync(timer::fire); | ||
| try { | ||
| assertTrue(channel.awaitActiveCheck(), "cleaner must reach the active-channel check"); | ||
| assertSame(channel, pool.poll(KEY)); | ||
| assertTrue(pool.offer(channel, KEY)); | ||
| } finally { | ||
| channel.resumeActiveCheck(); | ||
| } | ||
| cleanerRun.get(5, TimeUnit.SECONDS); | ||
|
|
||
| assertTrue(channel.isActive(), "cleaner must not close a freshly re-offered channel"); | ||
| assertSame(channel, pool.poll(KEY), "freshly re-offered channel must remain leasable"); | ||
|
|
||
| pool.destroy(); | ||
| } | ||
|
|
||
| @Test | ||
| public void staleCleanerClaimDoesNotHideReofferedChannelFromPoll() throws Exception { | ||
|
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. This test never runs the cleaner. It calls takeOwnership with a stale snapshot directly and then asserts that a failed CAS had no side effect, which restates the contract of compareAndSet rather than exercising the pool. Race b from the issue therefore has no end to end coverage. I would fold it into the BlockingActiveChannel test and assert that the partition still holds one entry after the cleaner pass, since that is the assertion that would actually catch the cleaner hiding the entry. Related gap: the soak test concurrentOfferPollRemoveAllIsConsistent runs with idle timeout off and a one hour TTL, so the cleaner only ever takes the isOwned then remove branch and never claims or closes anything.
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. Removed the standalone CAS-contract test and folded the observable guarantee into the BlockingActiveChannel test. It now asserts that the partition still contains exactly one fresh entry after the cleaner pass, before polling it. I also enabled idle expiry in the soak test and added a dedicated expiring channel with a close latch, so the test proves that the cleaner ownership and close path actually runs. |
||
| DefaultChannelPool pool = noReaperPool(); | ||
| Channel channel = new EmbeddedChannel(); | ||
|
|
||
| assertTrue(pool.offer(channel, KEY)); | ||
| DefaultChannelPool.IdleState idleState = idleState(channel); | ||
| long staleSnapshot = idleState.snapshot(); | ||
|
|
||
| assertSame(channel, pool.poll(KEY)); | ||
| assertTrue(pool.offer(channel, KEY)); | ||
|
|
||
| assertFalse(idleState.takeOwnership(staleSnapshot), "cleaner must not claim a newer idle generation"); | ||
| assertSame(channel, pool.poll(KEY), "a failed stale claim must not hide the fresh generation"); | ||
|
|
||
| pool.destroy(); | ||
| } | ||
|
|
||
| @Test | ||
| public void idleGenerationChangesWhenTimestampIsReused() { | ||
| DefaultChannelPool.IdleState idleState = new DefaultChannelPool.IdleState(); | ||
| idleState.reset(1); | ||
| long firstGeneration = idleState.snapshot(); | ||
|
|
||
| assertTrue(idleState.takeOwnership()); | ||
| idleState.reset(1); | ||
|
|
||
| assertFalse(idleState.takeOwnership(firstGeneration)); | ||
| assertFalse(idleState.isOwned()); | ||
| } | ||
|
|
||
| // ---- reap pass unlinks many channels in a single tick (O(n) iterator-remove) ---- | ||
|
|
||
| @Test | ||
|
|
@@ -490,14 +548,51 @@ public void concurrentOfferPollRemoveAllIsConsistent() throws Exception { | |
|
|
||
| // ---- helpers ---- | ||
|
|
||
| private static Object idleState(Channel channel) throws Exception { | ||
| private static DefaultChannelPool.IdleState idleState(Channel channel) throws Exception { | ||
| Field keyField = DefaultChannelPool.class.getDeclaredField("IDLE_STATE_ATTRIBUTE_KEY"); | ||
| keyField.setAccessible(true); | ||
| @SuppressWarnings("unchecked") | ||
| io.netty.util.AttributeKey<Object> key = (io.netty.util.AttributeKey<Object>) keyField.get(null); | ||
| io.netty.util.AttributeKey<DefaultChannelPool.IdleState> key = | ||
| (io.netty.util.AttributeKey<DefaultChannelPool.IdleState>) keyField.get(null); | ||
| return channel.attr(key).get(); | ||
| } | ||
|
|
||
| private static final class BlockingActiveChannel extends EmbeddedChannel { | ||
|
|
||
| private final AtomicBoolean blockNextActiveCheck = new AtomicBoolean(); | ||
| private final CountDownLatch activeCheck = new CountDownLatch(1); | ||
| private final CountDownLatch resumeActiveCheck = new CountDownLatch(1); | ||
|
|
||
| @Override | ||
| public boolean isActive() { | ||
| AtomicBoolean block = blockNextActiveCheck; | ||
|
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. This null guard is load bearing and deserves a one line comment saying why. EmbeddedChannel's constructor calls the subclass isActive once while these fields are still null, so without the guard you get an NPE. The trap is that the NPE is swallowed by a DefaultPromise listener, so removing this check would fail silently rather than loudly, and an IDE will flag the check as always true on a final field and invite someone to delete it.
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 the one-line constructor-dispatch explanation above the null guard so the EmbeddedChannel initialization trap is explicit. |
||
| if (block != null && block.compareAndSet(true, false)) { | ||
| activeCheck.countDown(); | ||
| try { | ||
| if (!resumeActiveCheck.await(5, TimeUnit.SECONDS)) { | ||
| throw new AssertionError("active-channel check was not resumed"); | ||
| } | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| throw new AssertionError("active-channel check was interrupted", e); | ||
| } | ||
| } | ||
| return super.isActive(); | ||
| } | ||
|
|
||
| void blockNextActiveCheck() { | ||
| blockNextActiveCheck.set(true); | ||
| } | ||
|
|
||
| boolean awaitActiveCheck() throws InterruptedException { | ||
| return activeCheck.await(5, TimeUnit.SECONDS); | ||
| } | ||
|
|
||
| void resumeActiveCheck() { | ||
| resumeActiveCheck.countDown(); | ||
| } | ||
| } | ||
|
|
||
| private static Channel channelWithRemoteAddress(String host) { | ||
| return new EmbeddedChannel() { | ||
|
|
||
|
|
||
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.
Minor one. This runs before the reset guard now, so a rejected offer leaves an empty deque sitting in partitions and the cleaner walks it every tick. Was not possible before, since offer0 could not return false. Probably just move the lookup below the reset call.
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.
Agreed. I moved partition creation after the idle-generation transfer and simplified it to a direct computeIfAbsent. A duplicate offer now returns before touching partitions, so it cannot leave an empty deque for the cleaner.