1 /*
2 * Copyright 2025 The Netty Project
3 *
4 * The Netty Project licenses this file to you under the Apache License,
5 * version 2.0 (the "License"); you may not use this file except in compliance
6 * with the License. You may obtain a copy of the License at:
7 *
8 * https://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, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations
14 * under the License.
15 */
16 package io.netty.util.concurrent;
17
18 import io.netty.util.internal.ObjectUtil;
19
20 import java.util.ArrayList;
21 import java.util.Collections;
22 import java.util.List;
23 import java.util.concurrent.TimeUnit;
24 import java.util.concurrent.atomic.AtomicLong;
25 import java.util.concurrent.atomic.AtomicReference;
26
27 /**
28 * A factory that creates auto-scaling {@link EventExecutorChooser} instances.
29 * This chooser implements a dynamic, utilization-based auto-scaling strategy.
30 * <p>
31 * It enables the {@link io.netty.channel.EventLoopGroup} to automatically scale the number of active
32 * {@link io.netty.channel.EventLoop} threads between a minimum and maximum threshold.
33 * The scaling decision is based on the average utilization of the active threads, measured over a
34 * configurable time window.
35 * <p>
36 * An {@code EventLoop} can be suspended if its utilization is consistently below the
37 * {@code scaleDownThreshold}. Conversely, if the group's average utilization is consistently
38 * above the {@code scaleUpThreshold}, a suspended thread will be automatically resumed to handle
39 * the increased load.
40 * <p>
41 * To control the aggressiveness of scaling actions, the {@code maxRampUpStep} and {@code maxRampDownStep}
42 * parameters limit the maximum number of threads that can be activated or suspended in a single scaling cycle.
43 * Furthermore, to ensure decisions are based on sustained trends rather than transient spikes, the
44 * {@code scalingPatienceCycles} defines how many consecutive monitoring windows a condition must be met
45 * before a scaling action is triggered.
46 */
47 public final class AutoScalingEventExecutorChooserFactory implements EventExecutorChooserFactory {
48
49 /**
50 * A container for the utilization metric of a single EventExecutor.
51 * This object is intended to be created once and have its {@code utilization}
52 * field updated periodically.
53 */
54 public static final class AutoScalingUtilizationMetric {
55 private final EventExecutor executor;
56 private final AtomicLong utilizationBits = new AtomicLong();
57
58 AutoScalingUtilizationMetric(EventExecutor executor) {
59 this.executor = executor;
60 }
61
62 /**
63 * Returns the most recently calculated utilization for the associated executor.
64 * @return a value from 0.0 to 1.0.
65 */
66 public double utilization() {
67 return Double.longBitsToDouble(utilizationBits.get());
68 }
69
70 /**
71 * Returns the {@link EventExecutor} this metric belongs too.
72 * @return the executor.
73 */
74 public EventExecutor executor() {
75 return executor;
76 }
77
78 void setUtilization(double utilization) {
79 long bits = Double.doubleToRawLongBits(utilization);
80 utilizationBits.lazySet(bits);
81 }
82 }
83
84 private static final Runnable NO_OOP_TASK = () -> { };
85 private final int minChildren;
86 private final int maxChildren;
87 private final long utilizationCheckPeriodNanos;
88 private final double scaleDownThreshold;
89 private final double scaleUpThreshold;
90 private final int maxRampUpStep;
91 private final int maxRampDownStep;
92 private final int scalingPatienceCycles;
93
94 /**
95 * Creates a new factory for a scaling-enabled {@link EventExecutorChooser}.
96 *
97 * @param minThreads the minimum number of threads to keep active.
98 * @param maxThreads the maximum number of threads to scale up to.
99 * @param utilizationWindow the period at which to check group utilization.
100 * @param windowUnit the unit for {@code utilizationWindow}.
101 * @param scaleDownThreshold the average utilization below which a thread may be suspended.
102 * @param scaleUpThreshold the average utilization above which a thread may be resumed.
103 * @param maxRampUpStep the maximum number of threads to add in one cycle.
104 * @param maxRampDownStep the maximum number of threads to remove in one cycle.
105 * @param scalingPatienceCycles the number of consecutive cycles a condition must be met before scaling.
106 */
107 public AutoScalingEventExecutorChooserFactory(int minThreads, int maxThreads, long utilizationWindow,
108 TimeUnit windowUnit, double scaleDownThreshold,
109 double scaleUpThreshold, int maxRampUpStep, int maxRampDownStep,
110 int scalingPatienceCycles) {
111 minChildren = ObjectUtil.checkPositiveOrZero(minThreads, "minThreads");
112 maxChildren = ObjectUtil.checkPositive(maxThreads, "maxThreads");
113 if (minThreads > maxThreads) {
114 throw new IllegalArgumentException(String.format(
115 "minThreads: %d must not be greater than maxThreads: %d", minThreads, maxThreads));
116 }
117 utilizationCheckPeriodNanos = ObjectUtil.checkNotNull(windowUnit, "windowUnit")
118 .toNanos(ObjectUtil.checkPositive(utilizationWindow,
119 "utilizationWindow"));
120 this.scaleDownThreshold = ObjectUtil.checkInRange(scaleDownThreshold, 0.0, 1.0, "scaleDownThreshold");
121 this.scaleUpThreshold = ObjectUtil.checkInRange(scaleUpThreshold, 0.0, 1.0, "scaleUpThreshold");
122 if (scaleDownThreshold >= scaleUpThreshold) {
123 throw new IllegalArgumentException(
124 "scaleDownThreshold must be less than scaleUpThreshold: " +
125 scaleDownThreshold + " >= " + scaleUpThreshold);
126 }
127 this.maxRampUpStep = ObjectUtil.checkPositive(maxRampUpStep, "maxRampUpStep");
128 this.maxRampDownStep = ObjectUtil.checkPositive(maxRampDownStep, "maxRampDownStep");
129 this.scalingPatienceCycles = ObjectUtil.checkPositiveOrZero(scalingPatienceCycles, "scalingPatienceCycles");
130 }
131
132 @Override
133 public EventExecutorChooser newChooser(EventExecutor[] executors) {
134 return new AutoScalingEventExecutorChooser(executors);
135 }
136
137 /**
138 * An immutable snapshot of the chooser's state. All state transitions
139 * are managed by atomically swapping this object.
140 */
141 private static final class AutoScalingState {
142 final int activeChildrenCount;
143 final long nextWakeUpIndex;
144 final EventExecutor[] activeExecutors;
145 final EventExecutorChooser activeExecutorsChooser;
146
147 AutoScalingState(int activeChildrenCount, long nextWakeUpIndex, EventExecutor[] activeExecutors) {
148 this.activeChildrenCount = activeChildrenCount;
149 this.nextWakeUpIndex = nextWakeUpIndex;
150 this.activeExecutors = activeExecutors;
151 activeExecutorsChooser = DefaultEventExecutorChooserFactory.INSTANCE.newChooser(activeExecutors);
152 }
153 }
154
155 private final class AutoScalingEventExecutorChooser implements ObservableEventExecutorChooser {
156 private final EventExecutor[] executors;
157 private final EventExecutorChooser allExecutorsChooser;
158 private final AtomicReference<AutoScalingState> state;
159 private final List<AutoScalingUtilizationMetric> utilizationMetrics;
160
161 AutoScalingEventExecutorChooser(EventExecutor[] executors) {
162 this.executors = executors;
163 List<AutoScalingUtilizationMetric> metrics = new ArrayList<>(executors.length);
164 for (EventExecutor executor : executors) {
165 metrics.add(new AutoScalingUtilizationMetric(executor));
166 }
167 utilizationMetrics = Collections.unmodifiableList(metrics);
168 allExecutorsChooser = DefaultEventExecutorChooserFactory.INSTANCE.newChooser(executors);
169
170 AutoScalingState initialState = new AutoScalingState(maxChildren, 0L, executors);
171 state = new AtomicReference<>(initialState);
172
173 ScheduledFuture<?> utilizationMonitoringTask = GlobalEventExecutor.INSTANCE.scheduleAtFixedRate(
174 new UtilizationMonitor(), utilizationCheckPeriodNanos, utilizationCheckPeriodNanos,
175 TimeUnit.NANOSECONDS);
176
177 if (executors.length > 0) {
178 executors[0].terminationFuture().addListener(future -> utilizationMonitoringTask.cancel(false));
179 }
180 }
181
182 /**
183 * This method is only responsible for picking from the active executors list.
184 * The monitor handles all scaling decisions.
185 */
186 @Override
187 public EventExecutor next() {
188 // Get a snapshot of the current state.
189 AutoScalingState currentState = this.state.get();
190
191 if (currentState.activeExecutors.length == 0) {
192 // This is only reachable if minChildren is 0 and the monitor has just suspended the last active thread.
193 // To prevent an error and ensure the group can recover, we wake one up and use the
194 // chooser that contains all executors as a safe temporary choice.
195 tryScaleUpBy(1);
196 return allExecutorsChooser.next();
197 }
198 return currentState.activeExecutorsChooser.next();
199 }
200
201 /**
202 * Tries to increase the active thread count by waking up suspended executors.
203 * This method is thread-safe and updates the state atomically.
204 *
205 * @param amount The desired number of threads to add to the active count.
206 */
207 private void tryScaleUpBy(int amount) {
208 if (amount <= 0) {
209 return;
210 }
211
212 for (;;) {
213 AutoScalingState oldState = state.get();
214 if (oldState.activeChildrenCount >= maxChildren) {
215 return;
216 }
217
218 int canAdd = Math.min(amount, maxChildren - oldState.activeChildrenCount);
219 List<EventExecutor> wokenUp = new ArrayList<>(canAdd);
220 final long startIndex = oldState.nextWakeUpIndex;
221
222 for (int i = 0; i < executors.length; i++) {
223 EventExecutor child = executors[(int) Math.abs((startIndex + i) % executors.length)];
224
225 if (wokenUp.size() >= canAdd) {
226 break; // We have woken up all the threads we reserved.
227 }
228 if (child instanceof SingleThreadEventExecutor) {
229 SingleThreadEventExecutor stee = (SingleThreadEventExecutor) child;
230 if (stee.isSuspended()) {
231 stee.execute(NO_OOP_TASK);
232 wokenUp.add(stee);
233 }
234 }
235 }
236
237 if (wokenUp.isEmpty()) {
238 return;
239 }
240
241 // Create the new state.
242 List<EventExecutor> newActiveList = new ArrayList<>(oldState.activeExecutors.length + wokenUp.size());
243 Collections.addAll(newActiveList, oldState.activeExecutors);
244 newActiveList.addAll(wokenUp);
245
246 AutoScalingState newState = new AutoScalingState(
247 oldState.activeChildrenCount + wokenUp.size(),
248 startIndex + wokenUp.size(),
249 newActiveList.toArray(new EventExecutor[0]));
250
251 if (state.compareAndSet(oldState, newState)) {
252 return;
253 }
254 // CAS failed, another thread changed the state. Loop again to retry.
255 }
256 }
257
258 @Override
259 public int activeExecutorCount() {
260 return state.get().activeChildrenCount;
261 }
262
263 @Override
264 public List<AutoScalingUtilizationMetric> executorUtilizations() {
265 return utilizationMetrics;
266 }
267
268 private final class UtilizationMonitor implements Runnable {
269 private final List<SingleThreadEventExecutor> consistentlyIdleChildren = new ArrayList<>(maxChildren);
270 private final List<SingleThreadEventExecutor> consistentlyBusyChildren = new ArrayList<>(maxChildren);
271 private long lastCheckTimeNanos;
272
273 @Override
274 public void run() {
275 if (executors.length == 0 || executors[0].isShuttingDown()) {
276 // The group is shutting down, so no scaling decisions should be made.
277 // The lifecycle listener on the terminationFuture will handle the final cancellation.
278 return;
279 }
280
281 // Calculate the actual elapsed time since the last run.
282 final long now = executors[0].ticker().nanoTime();
283 long totalTime;
284
285 if (lastCheckTimeNanos == 0) {
286 // On the first run, use the configured period as a baseline to avoid skipping the cycle.
287 totalTime = utilizationCheckPeriodNanos;
288 } else {
289 // On subsequent runs, calculate the actual elapsed time.
290 totalTime = now - lastCheckTimeNanos;
291 }
292
293 // Always update the timestamp for the next cycle.
294 lastCheckTimeNanos = now;
295
296 if (totalTime <= 0) {
297 // Skip this cycle if the clock has issues or the interval is invalid.
298 return;
299 }
300
301 consistentlyIdleChildren.clear();
302 consistentlyBusyChildren.clear();
303
304 final AutoScalingState currentState = state.get();
305
306 for (int i = 0; i < executors.length; i++) {
307 EventExecutor child = executors[i];
308 if (!(child instanceof SingleThreadEventExecutor)) {
309 continue;
310 }
311
312 SingleThreadEventExecutor eventExecutor = (SingleThreadEventExecutor) child;
313
314 double utilization = 0.0;
315 if (!eventExecutor.isSuspended()) {
316 long activeTime = eventExecutor.getAndResetAccumulatedActiveTimeNanos();
317
318 if (activeTime == 0) {
319 long lastActivity = eventExecutor.getLastActivityTimeNanos();
320 long idleTime = now - lastActivity;
321
322 // If the event loop has been idle for less time than our utilization window,
323 // it means it was active for the remainder of that window.
324 if (idleTime < totalTime) {
325 activeTime = totalTime - idleTime;
326 }
327 // If idleTime >= totalTime, it was idle for the whole window, so activeTime remains 0.
328 }
329
330 utilization = Math.min(1.0, (double) activeTime / totalTime);
331
332 if (utilization < scaleDownThreshold) {
333 // Utilization is low, increment idle counter and reset busy counter.
334 int idleCycles = eventExecutor.getAndIncrementIdleCycles();
335 eventExecutor.resetBusyCycles();
336 if (idleCycles >= scalingPatienceCycles &&
337 eventExecutor.getNumOfRegisteredChannels() <= 0) {
338 consistentlyIdleChildren.add(eventExecutor);
339 }
340 } else if (utilization > scaleUpThreshold) {
341 // Utilization is high, increment busy counter and reset idle counter.
342 int busyCycles = eventExecutor.getAndIncrementBusyCycles();
343 eventExecutor.resetIdleCycles();
344 if (busyCycles >= scalingPatienceCycles) {
345 consistentlyBusyChildren.add(eventExecutor);
346 }
347 } else {
348 // Utilization is in the normal range, reset counters.
349 eventExecutor.resetIdleCycles();
350 eventExecutor.resetBusyCycles();
351 }
352 }
353
354 utilizationMetrics.get(i).setUtilization(utilization);
355 }
356
357 int currentActive = currentState.activeChildrenCount;
358
359 // Make scaling decisions based on stable states.
360 if (!consistentlyBusyChildren.isEmpty() && currentActive < maxChildren) {
361 // Scale Up, we have children that have been busy for multiple cycles.
362 int threadsToAdd = Math.min(consistentlyBusyChildren.size(), maxRampUpStep);
363 threadsToAdd = Math.min(threadsToAdd, maxChildren - currentActive);
364 if (threadsToAdd > 0) {
365 // Reset the counters of the children that justified this decision so that they need
366 // another full patience period of sustained load before triggering a further scale-up.
367 for (SingleThreadEventExecutor busyChild : consistentlyBusyChildren) {
368 busyChild.resetBusyCycles();
369 busyChild.resetIdleCycles();
370 }
371 tryScaleUpBy(threadsToAdd);
372 // State change is handled by tryScaleUpBy, no need for rebuild here.
373 return; // Exit to avoid conflicting scale down logic in the same cycle.
374 }
375 }
376
377 boolean changed = false; // Flag to track if we need to rebuild the active executors list.
378 if (!consistentlyIdleChildren.isEmpty() && currentActive > minChildren) {
379 // Scale down, we have children that have been idle for multiple cycles.
380
381 int threadsToRemove = Math.min(consistentlyIdleChildren.size(), maxRampDownStep);
382 threadsToRemove = Math.min(threadsToRemove, currentActive - minChildren);
383
384 for (int i = 0; i < threadsToRemove; i++) {
385 SingleThreadEventExecutor childToSuspend = consistentlyIdleChildren.get(i);
386 if (childToSuspend.trySuspend()) {
387 // Reset cycles upon suspension so it doesn't get immediately re-suspended on wake-up.
388 childToSuspend.resetBusyCycles();
389 childToSuspend.resetIdleCycles();
390 changed = true;
391 }
392 }
393 }
394
395 // If a scale-down occurred, or if the actual state differs from our view, rebuild.
396 if (changed || currentActive != currentState.activeExecutors.length) {
397 rebuildActiveExecutors();
398 }
399 }
400
401 /**
402 * Atomically updates the state by creating a new snapshot with the current set of active executors.
403 */
404 private void rebuildActiveExecutors() {
405 for (;;) {
406 AutoScalingState oldState = state.get();
407 List<EventExecutor> active = new ArrayList<>(oldState.activeChildrenCount);
408 for (EventExecutor executor : executors) {
409 if (!executor.isSuspended()) {
410 active.add(executor);
411 }
412 }
413 EventExecutor[] newActiveExecutors = active.toArray(new EventExecutor[0]);
414
415 // If the number of active executors in our scan differs from the count in the state,
416 // another thread likely changed it. We use the count from our fresh scan.
417 // The nextWakeUpIndex is preserved from the old state as this rebuild is not a scale-up action.
418 AutoScalingState newState = new AutoScalingState(
419 newActiveExecutors.length, oldState.nextWakeUpIndex, newActiveExecutors);
420
421 if (state.compareAndSet(oldState, newState)) {
422 break;
423 }
424 }
425 }
426 }
427 }
428 }