Understanding Consumer-Side Thread Model Optimization in Dubbo 2.7.5
Background
In January 2020, the Dubbo team released a milestone version with significant performance improvements. One of the key enhancements was the optimization of the consumer-side thread model, which addressed a critical issue affecting applications that consume numerous services under high-concurrency scenarios.
Dubbo Thread Model Overview
Before diving into the optimization, let's understand the thread model architecture in Dubbo.
Client-Side Threading
On the client side, besides user threads, there exists a thread pool named DubboClientHandler-ip:port. The default implementation uses a cached thread pool:
// org.apache.dubbo.config.AbstractInterface
if (!hasMaxThreadSize) {
// When no threadpool is specified, default to cached implementation
setThreadpool(Constants.DEFAULT_THREADPOOL);
}
The setThreadName method configures the thread naming pattern:
// org.apache.dubbo.common.utils.ExecutorUtil#setThreadName
public static void setThreadName(String threadName) {
Thread.currentThread().setName(threadName);
}
The default thread name format follows the pattern DubboClientHandler-ip:port when not explicit configured.
Server-Side Threading
The server-side differs in that it uses a fixed thread pool by default. Configuration allows specifying different pool types:
<dubbo:protocol name="dubbo" threadpool="xxx"/>
Available options include fixed, cached, limited, and eager, with fixed as the default. The threading model also incorporates a dispatcher component that determines how incoming requests are routed to thread pools.
Issues with Pre-2.7.5 Thread Model
The previous thread model suffered from a significant scalability problem. According to GitHub issue #2013 and related discussions, applications consuming large numbers of services under high-concurrency conditions (typical gateway scenarios) would experience excessive thread allocation on the consumer side.
Root Cause Analysis
The fundamental issue stemmed from the cached thread pool implementation on the client side. Unlike fixed thread pools that maintain a bounded number of threads, cached pools can create unlimited threads as needed, limited only by available system resources.
// Pre-2.7.5: WrappedChannelHandler constructor
// Each connection creates its own thread pool
public WrappedChannelHandler(ChannelHandler handler, URL url) {
this.handler = handler;
this.url = url;
// Creates a new executor for each connection
this.executor = newCachedThreadPool(...);
}
When actives=20 was configured (representing maximum concurrent calls per method per consumer), users observed over 10,000 threads with DubboClientHandler prefix in blocked state. This wasn't a bug—it was the expected behavior of the cached pool allowing requests to queue beyond the active limit.
Connection-Level Isolation Problem
As highlighted in isue #4467, Dubbo created a separate thread pool for each connection:
// Dubbo 2.7.4.1 implementation
public AllChannelHandler extends AbstractChannelHandlerDelegate {
public AllChannelHandler(ChannelHandler handler, URL url) {
super(handler, url);
// Each connection gets its own thread pool
this.executorService = getExecutorService();
}
private ExecutorService getExecutorService() {
String serviceKey = url.toServiceString();
return new ThreadPoolExecutor(
0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
new NamedThreadFactory(name, true));
}
}
This design created unnecessary thread overhead. When a client connected to multiple providers, each provider connection maintained its own thread pool, leading to thread explosion.
ThreadlessExecutor: The Solution
Dubbo 2.7.5 introduced ThreadlessExecutor to address these issues. The key distinction from regular executors:
public class ThreadlessExecutor {
private final BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
private final ExecutorService sharedExecutor;
/**
* Execute without spawning new threads.
* Tasks are stored in the queue and only executed
* when the calling thread invokes waitAndDrain().
*/
public void execute(Runnable task) {
queue.add(task);
}
/**
* Wait for and execute queued tasks in the current thread.
*/
public void waitAndDrain() throws InterruptedException {
Runnable task = queue.poll(3000, TimeUnit.MILLISECONDS);
if (task != null) {
task.run();
}
}
}
This executor doesn't manage any threads itself. Tasks submitted via execute() are stored in a blocking queue and executed by whichever thread calls waitAndDrain().
Thread Model Comparison
Old Thread Model (Pre-2.7.5)
1. Business thread sends request, obtains Future instance
2. Business thread calls future.get() and blocks
3. Independent Consumer thread pool deserializes response
4. Consumer thread calls future.set() with result
5. Business thread returns with result
The old model required a dedicated consumer-side thread pool for each connection, consuming significant resources under high load.
New Thread Model (2.7.5+)
1. Business thread sends request, obtains Future instance
2. Before calling future.get(), invoke ThreadlessExecutor.wait()
3. When response arrives, create Runnable Task and add to ThreadlessExecutor queue
4. Business thread retrieves Task from queue and executes deserialization
5. Business thread returns with result
The new model eliminates the per-connection thread pool overhead entirely.
Code-Level Changes
Request Submission
Version 2.7.4.1:
// ReferencingClusterInvoker
public Result doInvoke(Invocation invocation) {
RPCUtils.getInvocationBody(invocation);
// Creates default future without executor context
return currentClient.request(invocation, timeout);
}
Version 2.7.5:
// ReferencingClusterInvoker
public Result doInvoke(Invocation invocation) {
RPCUtils.getInvocationBody(invocation);
// Passes ThreadlessExecutor to request method
return currentClient.request(invocation, timeout, executor);
}
Future Creation
Version 2.7.4.1:
public static DefaultFuture newFuture(Channel channel, Request request, int timeout) {
return new DefaultFuture(
new ChannelSupporter(channel, request),
channel.getUrl(),
timeout);
}
Version 2.7.5:
public static DefaultFuture newFuture(Channel channel, Request request,
int timeout, ExecutorService executor) {
return new DefaultFuture(
new ChannelSupporter(channel, request),
channel.getUrl(),
timeout,
executor);
}
Executor Selection Logic
The key method getPreferredExecutorService() implements the new selection strategy:
// AllChannelHandler - Version 2.7.5
public ExecutorService getPreferredExecutorService() {
if (CollectionUtils.isNotEmptyCollection(getChannels())) {
return getSharedExecutor();
}
// Use ThreadlessExecutor for callback execution
return getOrCreateThreadlessExecutor();
}
private ExecutorService getOrCreateThreadlessExecutor() {
if (threadlessExecutor == null) {
synchronized (this) {
if (threadlessExecutor == null) {
threadlessExecutor = new ThreadlessExecutor(sharedExecutor);
}
}
}
return threadlessExecutor;
}
The comment clarifies the intent:
/**
* Currently, this method is mainly customized to facilitate the thread model on consumer side.
* 1. Use ThreadlessExecutor - delegate callback directly to the thread initiating the call.
* 2. Use shared executor to execute the callback.
*/
Callback Execution
Version 2.7.4.1:
public void received(Channel channel, Object message) {
ExecutorService executor = getExecutorService();
executor.execute(() -> {
try {
// Process message in separate thread
handler.received(channel, message);
} catch (Exception e) {
sendFeedback(channel, message, e);
}
});
}
Version 2.7.5:
public void received(Channel channel, Object message) {
ExecutorService executor = getPreferredExecutorService();
executor.execute(() -> {
try {
handler.received(channel, message);
} catch (Exception e) {
sendFeedback(channel, message, e);
}
});
}
The actual message handling logic remains similar, but the executor selection has been fundamentally redesigned to avoid per-connection thread pools.
Version Considerations
The Dubbo community currently maintains two major versions:
| Version | Focus | Stability |
|---|---|---|
| 2.6.x | Bug fixes, minor enhancements | High |
| 2.7.x | New features, optimizations | Evolving |
Version 2.7.5 introduced HTTP/2 protocol support and native Protobuf integration, marking Dubbo's direction toward cloud-native architecture and multi-language interoperability. While the community notes this version may not yet be production-ready for large-scale deployments, it represents significant architectural progress.