Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Threads

Verified against Minecraft 26.2 · Reference · Hand-kept from net/minecraft/util/thread and every Thread the game starts, beside Anatomy’s four — looked up, not watched.

Every thread the game creates, who creates it, what runs on it, and what is allowed to run on it. The last column is the rule the rest of the documentation leans on: game state belongs to exactly one thread, and anything else submits a task to that thread’s event loop.

The picture

Two threads own game state — the Render thread owns the client’s, the Server thread owns the world’s — and everything else is a way of getting work to them or from them. Work crosses a thread boundary in exactly three ways, and the figure labels each edge with which: a posted task (a Runnable on the owner’s BlockableEventLoop), a completed future (a worker’s result, completed onto the owner’s executor), or a hopped handler (a packet decoded on Netty and re-posted to its owner by PacketUtils.ensureRunningOnSameThread).

flowchart LR
    subgraph ClientSide["the client process"]
        RT["Render thread<br/>Minecraft.runTick: a frame, then 0 to 10 client ticks"]
        SND["Sound engine<br/>SoundEngineExecutor: the OpenAL calls"]
    end
    subgraph Shared["shared by both halves"]
        NET["Netty IO<br/>Connection: split, decode, encode, plus the handshake and login handlers"]
        WK["Worker-Main-n<br/>Util.backgroundExecutor: generation, lighting, meshing"]
    end
    subgraph ServerSide["the server"]
        ST["Server thread<br/>MinecraftServer.runServer: a tick every 50 ms"]
        IO["IO-Worker-n<br/>Util.ioPool: region file reads and writes"]
        WD["Server Watchdog<br/>dedicated only"]
        LST["Console, RCON, query, management<br/>dedicated only"]
    end
    RT -- "serverbound packets, written on the caller's thread" --> NET
    NET -- "clientbound play packets: hopped handler" --> RT
    NET -- "serverbound play packets: hopped handler" --> ST
    ST -- "clientbound packets" --> NET
    ST -- "chunk generation and lighting: posted task" --> WK
    WK -- "a generated chunk: completed future" --> ST
    RT -- "section meshing: posted task" --> WK
    WK -- "a built mesh: completed future" --> RT
    ST -- "region reads and writes: posted task" --> IO
    IO -- "a loaded chunk's data: completed future" --> ST
    RT -- "play, stop, move a source: posted task" --> SND
    LST -- "a command line: posted task" --> ST
    WD -. "reads tick state unsynchronised, kills the JVM past max-tick-time" .-> ST

The table is the figure’s rows. Netty is drawn once and shared because it is: in singleplayer the client’s Connection and the integrated server’s run on the same Netty Local IO threads, and the packets between them are real.

The threads a lecture leans on

threadmade byrunsmay touch
Render thread (client)the JVM main thread, renamed in client/main/MainMinecraft.runMinecraft.runTick once per frame; Minecraft.tick 0–10 times inside itEverything client-side: ClientLevel, LocalPlayer, the GPU (RenderSystem.assertOnRenderThread), screens, options. It is also the client’s event loop (Minecraft is a ReentrantBlockableEventLoop), so packet handlers run here after PacketUtils.ensureRunningOnSameThread.
Server threadMinecraftServer.spinMinecraftServer.runServerMinecraftServer.processPacketsAndTick every TickRateManager.nanosecondsPerTick (50 ms by default); MinecraftServer.waitUntilNextTick drains the task queue in the slackEvery ServerLevel, every chunk, entity and block entity, the PlayerList. Serverbound play packet handlers run here, not on Netty. One per server; singleplayer has exactly one.
Netty IO (Netty NIO IO #n, Netty Epoll IO #n, Netty Kqueue IO #n, Netty Local IO #n)EventLoopGroupHolderthe Connection pipeline: split, decrypt, decompress, decode; encode, compress, encrypt — and the handshake and login handlersBytes and Packet objects — plus, in handshake and login, the handlers themselves: ServerHandshakePacketListenerImpl and ServerLoginPacketListenerImpl never hop. The login state machine is not all theirs, though: ServerLoginPacketListenerImpl is a TickablePacketListener, so the Server thread advances it once a tick through MinecraftServer.tickConnection. A play handler that needs game state re-posts to the owning thread. Local is the in-process channel of singleplayer.
Worker-Main-nUtil.backgroundExecutor — a ForkJoinPool sized to the JDK’s available-processor count minus one, clamped by Util.maxAllowedExecutorThreads and capped by the max.bg.threads property (Util.getMaxThreads)chunk generation and lighting via ChunkTaskDispatcher; section meshing via SectionRenderDispatcher; resource-reload prepare phases; chunk serialisationIts own inputs. Results return to the owning thread as a CompletableFuture completed onto that thread’s executor. Never Level state directly.
IO-Worker-nUtil.ioPoolregion file reads and writes through IOWorker, one PriorityConsecutiveExecutor per storage so writes to one file stay orderedFiles. Util.nonCriticalIoPool (Download-n) is the same shape for downloads, telemetry and sound decoding.
Sound engineSoundEngineExecutora BlockableEventLoop that owns the per-source OpenAL callsThe SoundEngine’s channels; see the sound engine. Device open/close and buffer deletion stay on the Render thread.
Server WatchdogDedicatedServer.initServer (dedicated only, positive limit only)ServerWatchdogWrites nothing, but reads game state unsynchronised while the Server thread is mid-tick — the game rules and every level’s ServerLevel.getWatchdogStats, for the crash report. It kills the JVM past DedicatedServerProperties.maxTickTime, and that kill does not save the world.
Server console handlerDedicatedServer.initServerreads stdinQueues each line to the server thread as a command; runs nothing itself.
RCON Listener #n / RCON ClientRconThread from DedicatedServer.initServer, when enable-rcon and rcon.password is setaccepts RCON sockets; one RconClient thread per connectionSockets. Each command is queued to the server thread. Dedicated only, and non-daemonDedicatedServer.onServerExit must stop it or the JVM will not exit.
Query Listener #nQueryThreadGs4 from DedicatedServer.initServer, when enable-querythe GS4 query protocolIts own cached status. Dedicated only, and non-daemon, like RCON.
Management server IO #nManagementServer, built by JsonRpc in server/Maina second, independent Netty event-loop group: the JSON-RPC/WebSocket management API and its heartbeatIts own pipeline; management calls reach the game through the server’s task queue. Dedicated only.
Timer hack threadUtil.startTimerHackThreadsleeps foreverNothing. Keeps the JVM’s timer resolution high by existing.

The nine client handlers that never hop

Connection.channelRead0 calls a packet’s handler on the Netty thread, and a handler’s first line is normally PacketUtils.ensureRunningOnSameThread, which re-posts it to the owning thread and aborts (the connection). Nine handlers on the client’s play listener omit it, so they run to completion on Netty and must touch nothing the Render thread owns. Seven are declared in ClientPacketListener itself; the last two are inherited from ClientCommonPacketListenerImpl and are the two that matter most, because a keep-alive is answered and a disconnect is acted on without the game thread being involved at all.

handlerwhat it does on the Netty thread
ClientPacketListener.handlePlayerCombatEnternothing — the body is empty
ClientPacketListener.handlePlayerCombatEndnothing — the body is empty
ClientPacketListener.handleChunkBatchStartstarts the ChunkBatchSizeCalculator’s clock
ClientPacketListener.handleChunkBatchFinishedstops it and sends ServerboundChunkBatchReceivedPacket with the chunks-per-tick it now wants — so the loop in what the client is told times packet decode, not mesh building
ClientPacketListener.handleDebugSamplehands the sample to DebugScreenOverlay.logRemoteSample
ClientPacketListener.handlePongResponserecords the round trip in PingDebugMonitor
ClientPacketListener.handleLowDiskSpaceWarningcalls Minecraft.sendLowDiskSpaceWarning, which posts the toast to the Render thread itself — the one that crosses after all, by Minecraft.execute rather than by the hop
ClientCommonPacketListenerImpl.handleKeepAlivereplies with ServerboundKeepAlivePacket through ClientCommonPacketListenerImpl.sendWhen, deferred while the window is frozen at RenderSystem.isFrozenAtPollEvents — so the answer that keeps a connection alive never waits for a frame
ClientCommonPacketListenerImpl.handleDisconnectcalls Connection.disconnect straight from the event loop

Situational threads

Real, but nothing in the corpus hangs on them: User Authenticator (one per login, for the session-server call), Chat-Filter-Worker, Server Pinger and Server Connector (the multiplayer screen), Telemetry-Sender, LanServerPinger and its detector, World Upgrader, Datafixer Bootstrap (priority 1, so it yields to everything), the client and server shutdown hooks with ClientShutdownWatchdog behind them, Swing’s event dispatch thread when a dedicated server is started without –nogui and runs its MinecraftServerGui, the Friends List fetcher behind the social screen, and ChaseServer’s two threads and ChaseClient’s one, which exist only behind SharedConstants.DEBUG_CHASE_COMMAND and the /chase command it registers. Realms starts nine more, and is out of scope with the rest of com/mojang/realmsclient.

The rules that follow

  • Two owners, one wire. Client state is the Render thread’s; server state is the Server thread’s; in singleplayer they share a JVM and still only talk about the world through packets over the local channel. (Settings — pause, view distance, publishing — do cross by direct call; see Anatomy.)
  • Handlers hop. A play packet is decoded on Netty and handled on the owning game thread; PacketUtils.ensureRunningOnSameThread is the hop. Handshake and login are the exception: their handlers run to completion on Netty. The login state machine is still advanced from the Server thread, which ticks the listener once a tick.
  • Workers compute, owners commit. Chunk generation, lighting and meshing produce results on the worker pool; only the owning thread installs them.
  • Waiting drains. An owning thread never blocks idle: BlockableEventLoop.managedBlock keeps running its own queue while it waits on a future, which is why a server tick that waits for a chunk does not deadlock the chunk that needs the server tick. ServerChunkCache.MainThreadExecutor is the extra event loop that makes that work.