Skip to main content

Threading Rules

Paper runs the world on one thread. Blocking that thread stalls every player, and touching the world from any other thread corrupts it. StoneLib does not hide either rule. This page lists, module by module, where each call is allowed to run.

The two rules

  1. Never block the main thread on I/O. Database calls, and file saves of any size, belong off the main thread.
  2. Never touch the world off the main thread. Entities, blocks, inventories, and most of the Bukkit API are main-thread only. Sending packets to a player, such as particles and sounds, is the usual exception.

The shape that satisfies both is "load off-thread, apply on the main thread", which is exactly what SchedulerService.supplyAsync does:

scheduler.supplyAsync(
() -> repository.reload(uuid), // runs async: may block
profile -> applyToPlayer(profile)); // runs on the main thread: may touch the world

Calls that block

These wait on a database or the filesystem. Call them off the main thread, except during startup and shutdown where a short block is acceptable.

CallWaits on
new ConnectionPool(...)MySQL. HikariCP opens a first connection while constructing, and throws if it cannot.
Every ConnectionPool method: connection, update, query, queryOne, transactionMySQL
SchemaMigrator.migrate, currentVersion, hasRunMySQL
MySqlRepository.createTable, load, reload, save, saveNowMySQL
SqliteRepository.load, saveThe SQLite file
YamlRepository.load, saveThe YAML file
ConfigManager, MultiConfigManager, and MessageService constructors and reloadConfig files. Small and fast, normally fine on the main thread.

MySqlRepository.get, put, remove, all, and invalidate only touch the in-memory cache and never block.

Calls that run off the main thread for you

CallRuns onAllowed inside
SchedulerService.runAsync, runAsyncLater, runAsyncTimerAn async scheduler threadBlocking I/O. No world access.
The supplier passed to SchedulerService.supplyAsyncAn async scheduler threadBlocking I/O. No world access.
Renderable.renderThe render loop's async taskPackets to the given viewers, and reading the RenderContext. No world access.
PermissionService.grant, grantTemporary, revoke futuresLuckPerms' own executorCompleting the future. Hop back to the main thread before touching the world.

Calls that must be on the main thread

CallWhy
HologramService.create, update, remove, hasSpawns, edits, and searches entities.
MenuHolder.openCreates and opens an inventory.
FormDialog.Builder.showShows a dialog to a player.
PermissionService.resolveCalls Player.hasPermission.
LootTable.rollBuilds ItemStacks. Safe to compute off-thread, but hand the items to the world on the main thread.
MessageBus.register, unregister, publishRegisters channels and sends through a player's connection.
RenderLoop.startSchedules the snapshot task.

Callbacks and which thread they arrive on

CallbackThread
supplyAsync main-thread callbackMain thread
MenuHolder.ClickHandlerMain thread (it is an inventory event)
FormDialog submit and cancel handlersWhichever thread Paper delivers dialog clicks on. StoneLib does not reschedule them, so if a handler touches the world, check Bukkit.isPrimaryThread() or hop with runSync.
MessageBus.MessageHandlerThe thread the plugin message arrives on. Treat it as unknown: do blocking work via runAsync and world work via runSync.
Renderable.cleanUpAlways the main thread. The loop reschedules it there if it was triggered off-thread.

Thread-safe structures

These are safe to call from any thread:

  • CooldownService, which is backed by concurrent maps.
  • PermissionService.has, cached, invalidate, and invalidateAll. Note that has falls back to a live hasPermission check on a cache miss, which should stay on the main thread.
  • MySqlRepository's cache operations.
  • RenderLoop.add, remove, get, and size.
  • MiniMessages, UntrustedText, Durations, LocationCodec.serialize, and Message encoding, which hold no state.

YamlRepository and SqliteRepository are not thread-safe. Their caches are plain LinkedHashMaps, so do not call put from one thread while save runs on another. Either keep all access on the main thread and save a snapshot, or guard the repository yourself.