perf: run game packet handlers off the Netty I/O loop + bound A* pathfinding (P2)

Root cause from the CPU audit: every incoming packet handler ran on the Netty
I/O event loop (MULTI_THREADED_PACKET_HANDLING is false by default), so any
blocking handler — login DB + loadHabbo, friends/polls/catalog/guild-forum
JDBC (~48 handlers), synchronous A* per walk — stalled socket I/O for every
other client sharing that I/O thread.

- WebSocketChannelInitializer: register GameMessageHandler on a dedicated
  DefaultEventExecutorGroup (max(16, 2x cores), daemon). Netty pins each channel
  to one executor in the group, so a client's packets stay strictly ordered (no
  new intra-client races) while blocking work moves off the I/O loop. The
  cross-client concurrency degree matches the already-multi-threaded I/O group,
  and this is strictly safer than the existing (order-losing) shared-pool
  MULTI_THREADED_PACKET_HANDLING mode the codebase already supported.
- GameMessageHandler: always run the handler inline (now on the group thread);
  drop the shared-pool branch (which would break per-channel ordering and also
  removes the rejectable-pool ByteBuf-drop path).
- PathfinderImpl: default the A* execution-time guard ON (25ms) so a pathological
  search returns an empty path instead of running unbounded on its thread.

Note: this changes the server's packet-threading model — verified to compile,
unit-test, and assemble the shaded jar, but should be load-tested before prod.
Group size is currently derived from CPU count; can be made a config key if
tuning is needed.
This commit is contained in:
simoleo89
2026-06-09 11:36:34 +00:00
parent 45d01876c1
commit 4eb1484daf
3 changed files with 26 additions and 10 deletions
@@ -24,8 +24,11 @@ public class PathfinderImpl implements Pathfinder {
private static final int CACHED_TIMEOUT_MS = Emulator.getConfig()
.getInt(CONFIG_EXECUTION_TIME, 25);
// Default ON: bound A* to CACHED_TIMEOUT_MS (25ms) so a pathological search
// can't run unbounded and stall the thread. On timeout findPath returns an
// empty path (the unit simply doesn't move there) — graceful degradation.
private static final boolean CACHED_TIMEOUT_ENABLED = Emulator.getConfig()
.getBoolean(CONFIG_TIMEOUT_ENABLED, false);
.getBoolean(CONFIG_TIMEOUT_ENABLED, true);
private static final long CACHED_TIMEOUT_NANOS = CACHED_TIMEOUT_MS * 1_000_000L;
private final Room room;
@@ -26,12 +26,26 @@ import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;
import io.netty.util.concurrent.DefaultEventExecutorGroup;
import io.netty.util.concurrent.DefaultThreadFactory;
import io.netty.util.concurrent.EventExecutorGroup;
import javax.net.ssl.SSLEngine;
public class WebSocketChannelInitializer extends ChannelInitializer<SocketChannel> {
private static final int MAX_FRAME_SIZE = 500000;
// Runs the game packet handler OFF the Netty I/O event loop, so a blocking
// handler (login/friends/catalog/guild JDBC, A* pathfinding, etc.) can no
// longer stall socket I/O for every other client sharing that I/O thread.
// A DefaultEventExecutorGroup pins each channel to one executor, so a single
// client's packets stay strictly ordered (no new intra-client races); the
// cross-client concurrency degree is the same the multi-threaded I/O group
// already had. Daemon threads so they don't block JVM shutdown.
private static final EventExecutorGroup PACKET_HANDLER_GROUP = new DefaultEventExecutorGroup(
Math.max(16, Runtime.getRuntime().availableProcessors() * 2),
new DefaultThreadFactory("GamePacketHandler", true));
private final SslContext sslContext;
private final boolean sslEnabled;
private final WebSocketServerProtocolConfig wsConfig;
@@ -82,7 +96,7 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
ch.pipeline().addLast("idleEventHandler", new IdleTimeoutHandler(30, 60));
ch.pipeline().addLast(new GameMessageRateLimit());
ch.pipeline().addLast(new GameMessageHandler());
ch.pipeline().addLast(PACKET_HANDLER_GROUP, "gameMessageHandler", new GameMessageHandler());
ch.pipeline().addLast("messageEncoder", new GameServerMessageEncoder());
if (PacketManager.DEBUG_SHOW_PACKETS) {
@@ -56,14 +56,13 @@ public class GameMessageHandler extends ChannelInboundHandlerAdapter {
ClientMessage message = (ClientMessage) msg;
try {
ChannelReadHandler handler = new ChannelReadHandler(ctx, message);
if (PacketManager.MULTI_THREADED_PACKET_HANDLING) {
Emulator.getThreading().run(handler);
return;
}
handler.run();
// This handler is registered on a dedicated EventExecutorGroup
// (see WebSocketChannelInitializer), so channelRead already runs OFF
// the Netty I/O event loop, serialized per channel. Running the
// handler inline here keeps that per-channel ordering — submitting to
// the shared game pool instead would break ordering, so we no longer
// branch on MULTI_THREADED_PACKET_HANDLING.
new ChannelReadHandler(ctx, message).run();
} catch (Exception e) {
LOGGER.error("Caught exception", e);
}