Skip to content

Commit 89a9640

Browse files
committed
Streamline DefaultChannelPool checkout and close paths
1 parent a004274 commit 89a9640

16 files changed

Lines changed: 1765 additions & 107 deletions
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
package org.asynchttpclient.bench;
11+
12+
import io.netty.buffer.ByteBuf;
13+
import io.netty.buffer.Unpooled;
14+
import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder;
15+
import io.netty.handler.codec.http2.Http2HeadersEncoder;
16+
import io.netty.handler.codec.http2.DefaultHttp2Headers;
17+
import io.netty.handler.codec.http2.Http2Headers;
18+
import io.netty.util.AsciiString;
19+
import org.openjdk.jmh.annotations.Benchmark;
20+
import org.openjdk.jmh.annotations.BenchmarkMode;
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.State;
25+
26+
import java.util.concurrent.TimeUnit;
27+
28+
/**
29+
* Measures the HPACK-encoded wire size of {@code accept-encoding} for the two value spellings:
30+
* <ul>
31+
* <li>AHC current: {@code "gzip,deflate"} (no space) — built in
32+
* {@code HttpUtils.GZIP_DEFLATE = new AsciiString(GZIP + "," + DEFLATE)}.</li>
33+
* <li>HPACK static table entry #16: {@code "gzip, deflate"} (with space, RFC 7541 App. A).</li>
34+
* </ul>
35+
*
36+
* <p>On a fresh encoder (first request of a connection) the static-table value matches as a single
37+
* indexed byte; the non-matching spelling is literal-encoded and inserted into the dynamic table.
38+
* This bench reports {@code gc.alloc.rate.norm} and the encoded byte count via the returned buffer's
39+
* readableBytes (consumed by the blackhole through the return value size).
40+
*/
41+
@State(Scope.Thread)
42+
@BenchmarkMode(Mode.AverageTime)
43+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
44+
public class AcceptEncodingHpackBenchmark {
45+
46+
private static final AsciiString ACCEPT_ENCODING = AsciiString.cached("accept-encoding");
47+
private static final AsciiString AHC_VALUE = AsciiString.cached("gzip,deflate");
48+
private static final AsciiString STATIC_VALUE = AsciiString.cached("gzip, deflate");
49+
50+
private int encodeOnce(AsciiString value) throws Exception {
51+
// Fresh encoder per call == "first request on a new connection" worst case.
52+
Http2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder();
53+
Http2Headers headers = new DefaultHttp2Headers().add(ACCEPT_ENCODING, value);
54+
ByteBuf out = Unpooled.buffer();
55+
try {
56+
encoder.encodeHeaders(3, headers, out);
57+
return out.readableBytes();
58+
} finally {
59+
out.release();
60+
}
61+
}
62+
63+
@Benchmark
64+
public int ahc_no_space() throws Exception {
65+
return encodeOnce(AHC_VALUE);
66+
}
67+
68+
@Benchmark
69+
public int static_table_with_space() throws Exception {
70+
return encodeOnce(STATIC_VALUE);
71+
}
72+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright (c) 2024 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+
package org.asynchttpclient.bench;
11+
12+
import org.openjdk.jmh.annotations.Benchmark;
13+
import org.openjdk.jmh.annotations.BenchmarkMode;
14+
import org.openjdk.jmh.annotations.Level;
15+
import org.openjdk.jmh.annotations.Mode;
16+
import org.openjdk.jmh.annotations.OutputTimeUnit;
17+
import org.openjdk.jmh.annotations.Scope;
18+
import org.openjdk.jmh.annotations.Setup;
19+
import org.openjdk.jmh.annotations.State;
20+
import org.openjdk.jmh.infra.Blackhole;
21+
22+
import java.util.concurrent.ConcurrentLinkedDeque;
23+
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
24+
import java.util.concurrent.TimeUnit;
25+
26+
/**
27+
* Models the per-checkout allocation of {@code DefaultChannelPool}: each
28+
* {@code offer()} wraps the channel in a freshly allocated {@code IdleChannel}
29+
* holder that is pushed onto a {@code ConcurrentLinkedDeque} (which itself
30+
* allocates a linked node per insert). On {@code poll()} the holder is
31+
* discarded. Under keep-alive churn this is one IdleChannel + one CLD node per
32+
* request.
33+
*
34+
* This bench compares the current "allocate a holder per offer" pattern against
35+
* an alternative that stores the bare channel reference + a parallel timestamp,
36+
* avoiding the holder allocation. It is a standalone model (no Netty Channel
37+
* needed) so it can run on the bare JMH classpath; the shapes mirror
38+
* DefaultChannelPool.IdleChannel exactly (one Object ref + one long + one
39+
* volatile int) and CLD node churn is identical for both arms because both push
40+
* one element.
41+
*/
42+
@State(Scope.Thread)
43+
@BenchmarkMode(Mode.AverageTime)
44+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
45+
public class ChannelPoolCheckoutBenchmark {
46+
47+
// Mirror of DefaultChannelPool.IdleChannel: Object ref + long start + volatile int owned.
48+
static final class IdleChannel {
49+
static final AtomicIntegerFieldUpdater<IdleChannel> OWNED =
50+
AtomicIntegerFieldUpdater.newUpdater(IdleChannel.class, "owned");
51+
final Object channel;
52+
final long start;
53+
@SuppressWarnings("unused")
54+
private volatile int owned;
55+
56+
IdleChannel(Object channel, long start) {
57+
this.channel = channel;
58+
this.start = start;
59+
}
60+
61+
boolean takeOwnership() {
62+
return OWNED.getAndSet(this, 1) == 0;
63+
}
64+
}
65+
66+
private ConcurrentLinkedDeque<IdleChannel> currentDeque;
67+
private ConcurrentLinkedDeque<Object> bareDeque;
68+
private Object channel;
69+
70+
@Setup(Level.Trial)
71+
public void setup() {
72+
currentDeque = new ConcurrentLinkedDeque<>();
73+
bareDeque = new ConcurrentLinkedDeque<>();
74+
channel = new Object();
75+
}
76+
77+
/** Current behavior: allocate an IdleChannel holder on every offer. */
78+
@Benchmark
79+
public void currentOfferPoll(Blackhole bh) {
80+
currentDeque.offerFirst(new IdleChannel(channel, 123L));
81+
IdleChannel c = currentDeque.pollFirst();
82+
if (c != null && c.takeOwnership()) {
83+
bh.consume(c.channel);
84+
}
85+
}
86+
87+
/**
88+
* Alternative: push the bare channel ref. Models pushing the Channel itself
89+
* and reading the timestamp/owned flag from a Netty channel attribute
90+
* instead of a per-checkout holder. Only the CLD node is allocated.
91+
*/
92+
@Benchmark
93+
public void bareOfferPoll(Blackhole bh) {
94+
bareDeque.offerFirst(channel);
95+
Object c = bareDeque.pollFirst();
96+
bh.consume(c);
97+
}
98+
}
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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright (c) 2024 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+
package org.asynchttpclient.bench;
11+
12+
import io.netty.handler.codec.http.cookie.Cookie;
13+
import io.netty.handler.codec.http.cookie.DefaultCookie;
14+
import org.asynchttpclient.cookie.ThreadSafeCookieStore;
15+
import org.asynchttpclient.uri.Uri;
16+
import org.openjdk.jmh.annotations.Benchmark;
17+
import org.openjdk.jmh.annotations.BenchmarkMode;
18+
import org.openjdk.jmh.annotations.Level;
19+
import org.openjdk.jmh.annotations.Mode;
20+
import org.openjdk.jmh.annotations.OutputTimeUnit;
21+
import org.openjdk.jmh.annotations.Param;
22+
import org.openjdk.jmh.annotations.Scope;
23+
import org.openjdk.jmh.annotations.Setup;
24+
import org.openjdk.jmh.annotations.State;
25+
26+
import java.util.List;
27+
import java.util.concurrent.TimeUnit;
28+
29+
/**
30+
* Measures allocations of {@link ThreadSafeCookieStore#get(Uri)} which is on the
31+
* request path: every outgoing request for which a cookie store is configured
32+
* calls it to collect applicable cookies. The current implementation walks
33+
* sub-domains and, for each, runs a Stream pipeline
34+
* ({@code entrySet().stream().filter(lambda).map(lambda).collect(toList())}).
35+
*
36+
* This bench pins the per-get byte cost so a proposal can quantify replacing
37+
* the Stream pipeline + per-subdomain list copies with an imperative scan.
38+
*/
39+
@State(Scope.Thread)
40+
@BenchmarkMode(Mode.AverageTime)
41+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
42+
public class CookieStoreGetBenchmark {
43+
44+
private ThreadSafeCookieStore store;
45+
private Uri requestUri;
46+
47+
@Param({"1", "5"})
48+
public int cookiesPerDomain;
49+
50+
@Setup(Level.Trial)
51+
public void setup() {
52+
store = new ThreadSafeCookieStore();
53+
Uri uri = Uri.create("https://www.example.com/some/path");
54+
for (int i = 0; i < cookiesPerDomain; i++) {
55+
DefaultCookie c = new DefaultCookie("cookie" + i, "value" + i);
56+
c.setDomain("www.example.com");
57+
c.setPath("/some");
58+
store.add(uri, c);
59+
}
60+
// a couple of parent-domain cookies to force the sub-domain walk to find matches
61+
DefaultCookie root = new DefaultCookie("root", "v");
62+
root.setDomain("example.com");
63+
root.setPath("/");
64+
store.add(uri, root);
65+
66+
requestUri = Uri.create("https://www.example.com/some/path/leaf");
67+
}
68+
69+
@Benchmark
70+
public List<Cookie> get() {
71+
return store.get(requestUri);
72+
}
73+
}

0 commit comments

Comments
 (0)