From 24be64dd519ad5867062fe32b55bd994df1eeca4 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts Date: Tue, 30 Jun 2026 09:30:34 +0200 Subject: [PATCH 1/2] Avoid per-connect lock on shared Bootstrap options map (#2218) AHC reuses a single Bootstrap for every outbound connection. Netty's AbstractBootstrap.newOptionsArray() copies the shared options map under "synchronized (options)" on every connect, serializing all connection attempts on one monitor. Under high connection-establishment rates this becomes a lock convoy. Resolve the configured ChannelOptions once at construction into a fixed array and apply them to each channel from the existing channel initializer via Channel.config().setOption(...), leaving the Bootstrap options map empty. This removes the global lock from the connect path and avoids re-reading config per connection, while preserving identical option values, ordering, and timing (options are applied during channel registration, before connect). --- .../netty/channel/ChannelManager.java | 72 ++++++++++++++----- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index e0db5a9c4..d3b42ac14 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -18,6 +18,7 @@ import io.netty.bootstrap.Bootstrap; import io.netty.buffer.ByteBufAllocator; import io.netty.channel.Channel; +import io.netty.channel.ChannelConfig; import io.netty.channel.ChannelFactory; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; @@ -93,8 +94,8 @@ import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.util.LinkedHashMap; import java.util.Map; -import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; @@ -129,6 +130,9 @@ public class ChannelManager { private final boolean allowReleaseEventLoopGroup; private final Bootstrap httpBootstrap; private final Bootstrap wsBootstrap; + // Channel options, resolved from config once at construction, applied to each channel from the channel + // initializer instead of via Bootstrap#option to avoid Netty's synchronized per-connect options map (issue #2218). + private final Map.Entry, Object>[] channelOptions; private final long handshakeTimeout; private final @Nullable AddressResolverGroup addressResolverGroup; @@ -200,8 +204,9 @@ public ChannelManager(final AsyncHttpClientConfig config, Timer nettyTimer) { } } - httpBootstrap = newBootstrap(transportFactory, eventLoopGroup, config); - wsBootstrap = newBootstrap(transportFactory, eventLoopGroup, config); + channelOptions = buildChannelOptions(config); + httpBootstrap = newBootstrap(transportFactory, eventLoopGroup); + wsBootstrap = newBootstrap(transportFactory, eventLoopGroup); // Use the address resolver group from config if provided; otherwise null (legacy per-request resolution) addressResolverGroup = config.getAddressResolverGroup(); @@ -243,37 +248,64 @@ public static boolean isSslHandlerConfigured(ChannelPipeline pipeline) { return pipeline.get(SSL_HANDLER) != null; } - private static Bootstrap newBootstrap(ChannelFactory channelFactory, EventLoopGroup eventLoopGroup, AsyncHttpClientConfig config) { - Bootstrap bootstrap = new Bootstrap().channelFactory(channelFactory).group(eventLoopGroup) - .option(ChannelOption.ALLOCATOR, config.getAllocator() != null ? config.getAllocator() : ByteBufAllocator.DEFAULT) - .option(ChannelOption.TCP_NODELAY, config.isTcpNoDelay()) - .option(ChannelOption.SO_REUSEADDR, config.isSoReuseAddress()) - .option(ChannelOption.SO_KEEPALIVE, config.isSoKeepAlive()) - .option(ChannelOption.AUTO_CLOSE, false); + private static Bootstrap newBootstrap(ChannelFactory channelFactory, EventLoopGroup eventLoopGroup) { + // Channel options are intentionally NOT set on the Bootstrap. Netty's AbstractBootstrap applies them + // per-connect by copying the shared options map under "synchronized (options)" in newOptionsArray(), + // which serializes every outbound connection on a single monitor (issue #2218). Instead, we apply the + // pre-resolved options to each Channel from the channel initializer via Channel.config(), keeping the + // Bootstrap options map empty and removing that global lock from the connect path. + return new Bootstrap().channelFactory(channelFactory).group(eventLoopGroup); + } + + /** + * Resolves the configured {@link ChannelOption}s from the client config exactly once. Values and conditional + * options (connect timeout, SO_LINGER, buffer sizes) are computed here so the per-connection path only iterates + * a fixed array and never re-reads the config. + */ + @SuppressWarnings("unchecked") + private static Map.Entry, Object>[] buildChannelOptions(AsyncHttpClientConfig config) { + Map, Object> options = new LinkedHashMap<>(); + options.put(ChannelOption.ALLOCATOR, config.getAllocator() != null ? config.getAllocator() : ByteBufAllocator.DEFAULT); + options.put(ChannelOption.TCP_NODELAY, config.isTcpNoDelay()); + options.put(ChannelOption.SO_REUSEADDR, config.isSoReuseAddress()); + options.put(ChannelOption.SO_KEEPALIVE, config.isSoKeepAlive()); + options.put(ChannelOption.AUTO_CLOSE, false); long connectTimeout = config.getConnectTimeout().toMillis(); if (connectTimeout > 0) { connectTimeout = Math.min(connectTimeout, Integer.MAX_VALUE); - bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int) connectTimeout); + options.put(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int) connectTimeout); } if (config.getSoLinger() >= 0) { - bootstrap.option(ChannelOption.SO_LINGER, config.getSoLinger()); + options.put(ChannelOption.SO_LINGER, config.getSoLinger()); } if (config.getSoSndBuf() >= 0) { - bootstrap.option(ChannelOption.SO_SNDBUF, config.getSoSndBuf()); + options.put(ChannelOption.SO_SNDBUF, config.getSoSndBuf()); } if (config.getSoRcvBuf() >= 0) { - bootstrap.option(ChannelOption.SO_RCVBUF, config.getSoRcvBuf()); + options.put(ChannelOption.SO_RCVBUF, config.getSoRcvBuf()); } - for (Entry, Object> entry : config.getChannelOptions().entrySet()) { - bootstrap.option(entry.getKey(), entry.getValue()); - } + // User-supplied options last so they can override the defaults above, matching the previous Bootstrap order. + options.putAll(config.getChannelOptions()); + + return options.entrySet().toArray(new Map.Entry[0]); + } - return bootstrap; + /** + * Applies the pre-resolved channel options to a freshly created channel. Invoked from the channel initializer + * (once per connection, on the channel's event loop, before the channel is connected), mirroring what + * {@link Bootstrap#option} would otherwise do but without the shared, synchronized options map. + */ + @SuppressWarnings("unchecked") + private void applyChannelOptions(Channel channel) { + ChannelConfig channelConfig = channel.config(); + for (Map.Entry, Object> option : channelOptions) { + channelConfig.setOption((ChannelOption) option.getKey(), option.getValue()); + } } public void configureBootstraps(NettyRequestSender requestSender) { @@ -284,6 +316,8 @@ public void configureBootstraps(NettyRequestSender requestSender) { httpBootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(Channel ch) { + applyChannelOptions(ch); + ChannelPipeline pipeline = ch.pipeline() .addLast(HTTP_CLIENT_CODEC, newHttpClientCodec()); @@ -309,6 +343,8 @@ protected void initChannel(Channel ch) { wsBootstrap.handler(new ChannelInitializer() { @Override protected void initChannel(Channel ch) { + applyChannelOptions(ch); + ChannelPipeline pipeline = ch.pipeline() .addLast(HTTP_CLIENT_CODEC, newHttpClientCodec()) .addLast(AHC_WS_HANDLER, wsHandler); From fab425c874f7a579ea8dd33cde9ed4a62521b7e6 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts Date: Tue, 30 Jun 2026 09:42:32 +0200 Subject: [PATCH 2/2] Avoid per-connect lock on shared Bootstrap options map (#2218) AHC reuses a single Bootstrap for every outbound connection. Netty's AbstractBootstrap.newOptionsArray() copies the shared options map under "synchronized (options)" on every connect, serializing all connection attempts on one monitor. Under high connection-establishment rates this becomes a lock convoy. Resolve the configured ChannelOptions once at construction into a fixed array and apply them to each channel from the existing channel initializer via Channel.config().setOption(...), leaving the Bootstrap options map empty. This removes the global lock from the connect path and avoids re-reading config per connection, while preserving identical option values, ordering, and timing (options are applied during channel registration, before connect). --- .../java/org/asynchttpclient/netty/channel/ChannelManager.java | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index d3b42ac14..e71f3b5f6 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -96,6 +96,7 @@ import java.net.InetSocketAddress; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit;