Skip to content

Scheduler and Threading

Petrus Pradella edited this page Aug 8, 2026 · 2 revisions

Scheduler and Threading

EverNifeCore gives you one platform-agnostic entry point - FCScheduler - for both off-thread work and hops onto the game's main thread. Off-thread work runs on a virtual-thread executor when the JVM supports it (Java 21+), and falls back to a bounded pool on older runtimes. The main-thread bridge is platform-specific: McFCScheduler on Bukkit, HyFCScheduler on Hytale.

FCScheduler (platform-agnostic)

br.com.finalcraft.evernifecore.scheduler.FCScheduler is a static utility. Its core is a single shared VirtualThreadedScheduledExecutor (from the EveryLibs executors package) named "fcscheduler".

import br.com.finalcraft.evernifecore.scheduler.FCScheduler;

// Run now, off the main thread.
FCScheduler.runAsync(() -> {
    // safe for blocking I/O: DB call, HTTP, file read, ...
});

// Run once after a delay (milliseconds), off the main thread.
FCScheduler.scheduleAsync(() -> reloadCaches(), 5_000);

// The underlying scheduled executor, for fixed-rate / fixed-delay scheduling.
FCScheduler.getScheduler().scheduleAtFixedRate(task, 0, 500, TimeUnit.MILLISECONDS);

Both runAsync and scheduleAsync wrap your Runnable so an uncaught Throwable is printed rather than killing the worker - a failing task never takes the pool down with it.

Virtual threads, with a fallback

The shared executor uses virtual threads on Java 21 or newer, which makes it ideal for tasks that block (Thread.sleep, socket/JDBC I/O): a blocked virtual thread parks cheaply instead of pinning an OS thread. On a JVM older than 21 it degrades gracefully to a fixed thread pool bounded to the number of CPU cores - the detection is done through reflection, so the same JAR runs everywhere. On a Bukkit 1.7.10 server (Java 8) you get the bounded pool; on a modern Java 21 host you get virtual threads, with no code change.

For an ad-hoc executor with the same policy, EveryLibs exposes FCExecutorsUtil.createVirtualExecutorIfPossible(name) (br.com.finalcraft.everylibs.executors.util), which returns an ExecutorService backed by virtual threads when possible and a plain pool otherwise. The legacy-import path uses it to fan work out across a temporary pool.

Getting back onto the main thread (Bukkit)

Most Bukkit API is only safe on the server's main thread. McFCScheduler (FCScheduler.getMinecraftScheduler(), or the static McFCScheduler.INSTANCE) is the bridge back.

McFCScheduler mc = FCScheduler.getMinecraftScheduler();

mc.runSync(() -> player.sendMessage("done"));       // next tick, on the main thread
mc.scheduleSyncInTicks(() -> spawnReward(), 20);     // 20 ticks (~1s) later, main thread
mc.scheduleSync(() -> flush(), 5_000);               // 5s later (ms delay), then hop to main
Method Behavior
runSync(Runnable) Runs the task on the main thread on the next tick (BukkitRunnable#runTask).
scheduleSyncInTicks(Runnable, long ticks) Runs on the main thread after N server ticks.
scheduleSync(Runnable, long delayMillis) Waits the delay on the async scheduler, then hops to the main thread.

Waiting for a main-thread result

When an async task needs a value that can only be computed on the main thread, use the SynchronizedAction bridge (mc.getSynchronizedAction()):

McFCScheduler.SynchronizedAction sync = FCScheduler.getMinecraftScheduler().getSynchronizedAction();

// Compute on the main thread and block the caller until it returns.
ItemStack held = sync.runAndGet(() -> player.getInventory().getItemInMainHand());

runAndGet(Callable<T>) runs inline when the caller is already on the main thread; otherwise it posts a FutureTask to the main thread and blocks until it completes. run(Runnable) is the void form. scheduleAndGet(Callable, delayTicks) is the delayed variant and, by contract, must not be called from the main thread (it would deadlock waiting on a tick that cannot advance).

The Hytale side mirrors this through FCScheduler.getHytaleScheduler() (HyFCScheduler). Writing against FCScheduler keeps scheduling code portable across both platforms - see Platform Abstraction.

Moving an item: what the framework refuses off the main thread

StoredInventory (see GUI Framework) is the one type in the core that enforces the main thread rather than trusting you: an inventory written from two threads is a duplicated item, and a duplicated item found weeks later is unattributable.

Three doors are guarded, and each throws from anywhere else, naming the thread that tried:

setItem(UpdateCause, int, ItemStack)
setItemSilently(int, ItemStack)
setCapacity(int)

Three are not, and that is deliberate:

setMaxStackSize(int slot, int max)
onPreUpdate(Consumer<StoredInventoryItemPreUpdateEvent>)
onPostUpdate(Consumer<StoredInventoryItemPostUpdateEvent>)

Describing an inventory is not moving anything. None of those three can put an item anywhere, and a plugin that lays its inventories out while loading is not on the main thread when it does it. Do not read "changes are main-thread only" as covering all six - it covers the three that move an item.

Reading is allowed from any thread and answers a snapshot: a slot is written and copied under the same lock, so a reader gets the whole stack before a change or the whole stack after it, never a stack being edited. What a reader cannot have is a promise that the snapshot is still true by the time it is used - nothing off the main thread can have that, and holding a lock across a decision would only move the race somewhere harder to see.

Nothing is refused before the server is up, because the question is put to the server and there is none yet to answer it.

// Storage answers on a worker thread. Reading the store there is fine; writing is not.
VirtualChestStorage.of(player.getUniqueId()).thenAccept(chest ->
        McFCScheduler.INSTANCE.runSync(() ->
                chest.contents.setItem(UpdateCause.PLUGIN, slot, reward)));

Rebuilding one from storage is the exception, and it has its own door. A decoder runs on a worker thread and would be refused, handing back an empty inventory that the next save then writes over the real data. StoredInventory.restoring(int) fills one that is nobody's yet, and build() closes that door the moment it becomes somebody's. The registered codec already goes through it; you only write it when you decode a StoredInventory yourself.

Async configuration saves

There is no dedicated periodic save thread on this branch. Configuration files persist through EveryConfig's own asynchronous back-store: call config.saveAsync() to hand the write off and return immediately, or config.save() to block until it lands. See Configuration for the save model. Player data is flushed on its own schedule by the storage layer - see PlayerData and PDSections.

Choosing the right thread

  • Blocking work (database, HTTP, disk): FCScheduler.runAsync / scheduleAsync.
  • Recurring background work: FCScheduler.getScheduler().scheduleAtFixedRate(...).
  • Anything touching the Bukkit world/entities/inventories: McFCScheduler.runSync / scheduleSyncInTicks, or a SynchronizedAction when you need the return value.
  • Moving an item in a StoredInventory: the main thread, and it says so if you do not.

See also

Clone this wiki locally