Skip to content

Commit 24339e5

Browse files
authored
Streamline DefaultChannelPool checkout and close paths (#2183)
Motivation: DefaultChannelPool allocates a fresh IdleChannel holder on every keep-alive checkout and scans the partition deque O(n) to remove a channel on close. Modification: Store the bare Channel in the deque with idle state (timestamp + owned flag) on a reused per-channel attribute, and make removeAll an O(1) tombstone that the idle cleaner unlinks lazily. Result: Behavior-preserving; checkout drops 56 -to- 24 B/op and removeAll goes from O(n) to O(1) (depth-independent), per JMH on JDK 11.
1 parent a004274 commit 24339e5

16 files changed

Lines changed: 1831 additions & 107 deletions
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.asynchttpclient.bench;
17+
18+
import io.netty.buffer.ByteBuf;
19+
import io.netty.buffer.Unpooled;
20+
import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder;
21+
import io.netty.handler.codec.http2.Http2HeadersEncoder;
22+
import io.netty.handler.codec.http2.DefaultHttp2Headers;
23+
import io.netty.handler.codec.http2.Http2Headers;
24+
import io.netty.util.AsciiString;
25+
import org.openjdk.jmh.annotations.Benchmark;
26+
import org.openjdk.jmh.annotations.BenchmarkMode;
27+
import org.openjdk.jmh.annotations.Mode;
28+
import org.openjdk.jmh.annotations.OutputTimeUnit;
29+
import org.openjdk.jmh.annotations.Scope;
30+
import org.openjdk.jmh.annotations.State;
31+
32+
import java.util.concurrent.TimeUnit;
33+
34+
/**
35+
* Measures the HPACK-encoded wire size of {@code accept-encoding} for the two value spellings:
36+
* <ul>
37+
* <li>AHC current: {@code "gzip,deflate"} (no space) — built in
38+
* {@code HttpUtils.GZIP_DEFLATE = new AsciiString(GZIP + "," + DEFLATE)}.</li>
39+
* <li>HPACK static table entry #16: {@code "gzip, deflate"} (with space, RFC 7541 App. A).</li>
40+
* </ul>
41+
*
42+
* <p>On a fresh encoder (first request of a connection) the static-table value matches as a single
43+
* indexed byte; the non-matching spelling is literal-encoded and inserted into the dynamic table.
44+
* This bench reports {@code gc.alloc.rate.norm} and the encoded byte count via the returned buffer's
45+
* readableBytes (consumed by the blackhole through the return value size).
46+
*/
47+
@State(Scope.Thread)
48+
@BenchmarkMode(Mode.AverageTime)
49+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
50+
public class AcceptEncodingHpackBenchmark {
51+
52+
private static final AsciiString ACCEPT_ENCODING = AsciiString.cached("accept-encoding");
53+
private static final AsciiString AHC_VALUE = AsciiString.cached("gzip,deflate");
54+
private static final AsciiString STATIC_VALUE = AsciiString.cached("gzip, deflate");
55+
56+
private int encodeOnce(AsciiString value) throws Exception {
57+
// Fresh encoder per call == "first request on a new connection" worst case.
58+
Http2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder();
59+
Http2Headers headers = new DefaultHttp2Headers().add(ACCEPT_ENCODING, value);
60+
ByteBuf out = Unpooled.buffer();
61+
try {
62+
encoder.encodeHeaders(3, headers, out);
63+
return out.readableBytes();
64+
} finally {
65+
out.release();
66+
}
67+
}
68+
69+
@Benchmark
70+
public int ahc_no_space() throws Exception {
71+
return encodeOnce(AHC_VALUE);
72+
}
73+
74+
@Benchmark
75+
public int static_table_with_space() throws Exception {
76+
return encodeOnce(STATIC_VALUE);
77+
}
78+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.asynchttpclient.bench;
17+
18+
import org.openjdk.jmh.annotations.Benchmark;
19+
import org.openjdk.jmh.annotations.BenchmarkMode;
20+
import org.openjdk.jmh.annotations.Level;
21+
import org.openjdk.jmh.annotations.Mode;
22+
import org.openjdk.jmh.annotations.OutputTimeUnit;
23+
import org.openjdk.jmh.annotations.Scope;
24+
import org.openjdk.jmh.annotations.Setup;
25+
import org.openjdk.jmh.annotations.State;
26+
import org.openjdk.jmh.infra.Blackhole;
27+
28+
import java.util.concurrent.ConcurrentLinkedDeque;
29+
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
30+
import java.util.concurrent.TimeUnit;
31+
32+
/**
33+
* Models the per-checkout allocation of {@code DefaultChannelPool}: each
34+
* {@code offer()} wraps the channel in a freshly allocated {@code IdleChannel}
35+
* holder that is pushed onto a {@code ConcurrentLinkedDeque} (which itself
36+
* allocates a linked node per insert). On {@code poll()} the holder is
37+
* discarded. Under keep-alive churn this is one IdleChannel + one CLD node per
38+
* request.
39+
*
40+
* This bench compares the current "allocate a holder per offer" pattern against
41+
* an alternative that stores the bare channel reference + a parallel timestamp,
42+
* avoiding the holder allocation. It is a standalone model (no Netty Channel
43+
* needed) so it can run on the bare JMH classpath; the shapes mirror
44+
* DefaultChannelPool.IdleChannel exactly (one Object ref + one long + one
45+
* volatile int) and CLD node churn is identical for both arms because both push
46+
* one element.
47+
*/
48+
@State(Scope.Thread)
49+
@BenchmarkMode(Mode.AverageTime)
50+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
51+
public class ChannelPoolCheckoutBenchmark {
52+
53+
// Mirror of DefaultChannelPool.IdleChannel: Object ref + long start + volatile int owned.
54+
static final class IdleChannel {
55+
static final AtomicIntegerFieldUpdater<IdleChannel> OWNED =
56+
AtomicIntegerFieldUpdater.newUpdater(IdleChannel.class, "owned");
57+
final Object channel;
58+
final long start;
59+
@SuppressWarnings("unused")
60+
private volatile int owned;
61+
62+
IdleChannel(Object channel, long start) {
63+
this.channel = channel;
64+
this.start = start;
65+
}
66+
67+
boolean takeOwnership() {
68+
return OWNED.getAndSet(this, 1) == 0;
69+
}
70+
}
71+
72+
private ConcurrentLinkedDeque<IdleChannel> currentDeque;
73+
private ConcurrentLinkedDeque<Object> bareDeque;
74+
private Object channel;
75+
76+
@Setup(Level.Trial)
77+
public void setup() {
78+
currentDeque = new ConcurrentLinkedDeque<>();
79+
bareDeque = new ConcurrentLinkedDeque<>();
80+
channel = new Object();
81+
}
82+
83+
/** Current behavior: allocate an IdleChannel holder on every offer. */
84+
@Benchmark
85+
public void currentOfferPoll(Blackhole bh) {
86+
currentDeque.offerFirst(new IdleChannel(channel, 123L));
87+
IdleChannel c = currentDeque.pollFirst();
88+
if (c != null && c.takeOwnership()) {
89+
bh.consume(c.channel);
90+
}
91+
}
92+
93+
/**
94+
* Alternative: push the bare channel ref. Models pushing the Channel itself
95+
* and reading the timestamp/owned flag from a Netty channel attribute
96+
* instead of a per-checkout holder. Only the CLD node is allocated.
97+
*/
98+
@Benchmark
99+
public void bareOfferPoll(Blackhole bh) {
100+
bareDeque.offerFirst(channel);
101+
Object c = bareDeque.pollFirst();
102+
bh.consume(c);
103+
}
104+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.asynchttpclient.bench;
17+
18+
import org.openjdk.jmh.annotations.Benchmark;
19+
import org.openjdk.jmh.annotations.BenchmarkMode;
20+
import org.openjdk.jmh.annotations.Level;
21+
import org.openjdk.jmh.annotations.Mode;
22+
import org.openjdk.jmh.annotations.OutputTimeUnit;
23+
import org.openjdk.jmh.annotations.Param;
24+
import org.openjdk.jmh.annotations.Scope;
25+
import org.openjdk.jmh.annotations.Setup;
26+
import org.openjdk.jmh.annotations.State;
27+
import org.openjdk.jmh.infra.Blackhole;
28+
29+
import java.util.concurrent.ConcurrentLinkedDeque;
30+
import java.util.concurrent.TimeUnit;
31+
import java.util.concurrent.atomic.AtomicInteger;
32+
33+
/**
34+
* Models {@code DefaultChannelPool.removeAll(Channel)} which calls
35+
* {@code ConcurrentLinkedDeque.remove(Object)} — an O(n) full traversal of the partition deque
36+
* performed on every connection close. Compared against a poll/offer (LIFO) pair which is O(1).
37+
*
38+
* Element identity mirrors IdleChannel.equals (compares wrapped value), so remove() must scan.
39+
*
40+
* Run multi-threaded:
41+
* /tmp/run-jmh.sh ChannelPoolDequeBenchmark -t 8 -f 1 -wi 5 -i 8
42+
*/
43+
@State(Scope.Benchmark)
44+
@BenchmarkMode(Mode.Throughput)
45+
@OutputTimeUnit(TimeUnit.MICROSECONDS)
46+
public class ChannelPoolDequeBenchmark {
47+
48+
/** Steady-state number of idle connections per partition (deque length). */
49+
@Param({"4", "32", "128"})
50+
public int poolDepth;
51+
52+
private ConcurrentLinkedDeque<Holder> deque;
53+
private Holder[] elements;
54+
private final AtomicInteger removeCursor = new AtomicInteger();
55+
56+
static final class Holder {
57+
final int id;
58+
Holder(int id) { this.id = id; }
59+
@Override public boolean equals(Object o) {
60+
return this == o || (o instanceof Holder && id == ((Holder) o).id);
61+
}
62+
@Override public int hashCode() { return id; }
63+
}
64+
65+
@Setup(Level.Invocation)
66+
public void setup() {
67+
deque = new ConcurrentLinkedDeque<>();
68+
elements = new Holder[poolDepth];
69+
for (int i = 0; i < poolDepth; i++) {
70+
elements[i] = new Holder(i);
71+
deque.offerFirst(elements[i]);
72+
}
73+
}
74+
75+
/** Current removeAll path: O(n) remove(Object) scanning by equals. Removes the tail (worst case for LIFO insert). */
76+
@Benchmark
77+
public boolean currentRemoveAll() {
78+
int idx = removeCursor.getAndIncrement() % poolDepth;
79+
// remove a NEW Holder equal-by-id, exactly as DefaultChannelPool.removeAll builds
80+
// `new IdleChannel(channel, Long.MIN_VALUE)` and lets the deque scan for it.
81+
return deque.remove(new Holder(idx));
82+
}
83+
84+
/** Baseline O(1) lease/return that poll()/offer() use, for scale reference. */
85+
@Benchmark
86+
public void pollOffer(Blackhole bh) {
87+
Holder h = deque.pollFirst();
88+
if (h != null) {
89+
deque.offerFirst(h);
90+
}
91+
bh.consume(h);
92+
}
93+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.asynchttpclient.bench;
17+
18+
import io.netty.handler.codec.http.cookie.Cookie;
19+
import io.netty.handler.codec.http.cookie.DefaultCookie;
20+
import org.asynchttpclient.cookie.ThreadSafeCookieStore;
21+
import org.asynchttpclient.uri.Uri;
22+
import org.openjdk.jmh.annotations.Benchmark;
23+
import org.openjdk.jmh.annotations.BenchmarkMode;
24+
import org.openjdk.jmh.annotations.Level;
25+
import org.openjdk.jmh.annotations.Mode;
26+
import org.openjdk.jmh.annotations.OutputTimeUnit;
27+
import org.openjdk.jmh.annotations.Param;
28+
import org.openjdk.jmh.annotations.Scope;
29+
import org.openjdk.jmh.annotations.Setup;
30+
import org.openjdk.jmh.annotations.State;
31+
32+
import java.util.List;
33+
import java.util.concurrent.TimeUnit;
34+
35+
/**
36+
* Measures allocations of {@link ThreadSafeCookieStore#get(Uri)} which is on the
37+
* request path: every outgoing request for which a cookie store is configured
38+
* calls it to collect applicable cookies. The current implementation walks
39+
* sub-domains and, for each, runs a Stream pipeline
40+
* ({@code entrySet().stream().filter(lambda).map(lambda).collect(toList())}).
41+
*
42+
* This bench pins the per-get byte cost so a proposal can quantify replacing
43+
* the Stream pipeline + per-subdomain list copies with an imperative scan.
44+
*/
45+
@State(Scope.Thread)
46+
@BenchmarkMode(Mode.AverageTime)
47+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
48+
public class CookieStoreGetBenchmark {
49+
50+
private ThreadSafeCookieStore store;
51+
private Uri requestUri;
52+
53+
@Param({"1", "5"})
54+
public int cookiesPerDomain;
55+
56+
@Setup(Level.Trial)
57+
public void setup() {
58+
store = new ThreadSafeCookieStore();
59+
Uri uri = Uri.create("https://www.example.com/some/path");
60+
for (int i = 0; i < cookiesPerDomain; i++) {
61+
DefaultCookie c = new DefaultCookie("cookie" + i, "value" + i);
62+
c.setDomain("www.example.com");
63+
c.setPath("/some");
64+
store.add(uri, c);
65+
}
66+
// a couple of parent-domain cookies to force the sub-domain walk to find matches
67+
DefaultCookie root = new DefaultCookie("root", "v");
68+
root.setDomain("example.com");
69+
root.setPath("/");
70+
store.add(uri, root);
71+
72+
requestUri = Uri.create("https://www.example.com/some/path/leaf");
73+
}
74+
75+
@Benchmark
76+
public List<Cookie> get() {
77+
return store.get(requestUri);
78+
}
79+
}

0 commit comments

Comments
 (0)