Skip to content

First executions of delayed and repeating tasks on FoliaAsyncScheduler run in a single timer thread, making tasks block each other #12038

@metabrixkt

Description

@metabrixkt

Expected behavior

  1. Bukkit.getAsyncScheduler().runDelayed(...) and Bukkit.getAsyncScheduler().runAtFixedRate(...) schedule the task using FoliaAsyncScheduler#timerThread: it doesn't matter if it's a delayed or a repeating task
  2. FoliaAsyncScheduler.AsyncScheduledTask#run() gets run for the task
  3. All runs of the task are executed on FoliaAsyncScheduler#executors

Observed/Actual behavior

  1. Bukkit.getAsyncScheduler().runDelayed(...) and Bukkit.getAsyncScheduler().runAtFixedRate(...) schedule the task using FoliaAsyncScheduler#timerThread: it doesn't matter if it's a delayed or a repeating task
  2. FoliaAsyncScheduler.AsyncScheduledTask#run() gets run for the task
  3. Each run is meant to be dispatched into the pool (FoliaAsyncScheduler#executors) but the first one isn't. The work is dispatched into the pool only when AsyncScheduledTask#state is STATE_ON_TIMER, which is instead always STATE_SCHEDULED_EXECUTOR for first runs of all task:
    1. AsyncScheduledTask gets initialized in FoliaAsyncScheduler#scheduleTimerTask(...) by its constructor: the delay argument is always null, so this.state = delay == null ? STATE_SCHEDULED_EXECUTOR : STATE_ON_TIMER becomes this.state = STATE_SCHEDULED_EXECUTOR
      final AsyncScheduledTask ret = new AsyncScheduledTask(
      plugin, period <= 0 ? period : unit.toNanos(period), task, null,
      System.nanoTime() + unit.toNanos(initialDelay)
      );
      public AsyncScheduledTask(final Plugin plugin, final long repeatDelay, final Consumer<ScheduledTask> run,
      final ScheduledFuture<?> delay, final long firstTarget) {
      this.plugin = plugin;
      this.repeatDelay = repeatDelay;
      this.run = run;
      this.delay = delay;
      this.state = delay == null ? STATE_SCHEDULED_EXECUTOR : STATE_ON_TIMER;
      this.scheduleTarget = firstTarget;
      }
    2. Then, AsyncScheduledTask#setDelay(ScheduledFuture<?>) gets called in the same #scheduleTimerTask(...) method, once again explicitly setting the state: this.state = STATE_SCHEDULED_EXECUTOR
      synchronized (ret) {
      // even though ret is not published, we need to synchronise while scheduling to avoid a race condition
      // for when a scheduled task immediately executes before we update the delay field and state field
      ret.setDelay(this.timerThread.schedule(ret, initialDelay, unit));
      this.tasks.add(ret);
      private void setDelay(final ScheduledFuture<?> delay) {
      this.delay = delay;
      this.state = STATE_SCHEDULED_EXECUTOR;
      }
    3. When the AsyncScheduledTask#run() is called, the local timer variable responsible for dispatching the task into the pool or running it in-place is false, because of the state being STATE_SCHEDULED_EXECUTOR
      if (this.state == STATE_ON_TIMER) {
      timer = true;
      this.delay = null;
      this.state = STATE_SCHEDULED_EXECUTOR;
      } else if (this.state != STATE_SCHEDULED_EXECUTOR) {
      // cancelled
      if (this.state != STATE_CANCELLED) {
      throw new IllegalStateException("Wrong state: " + this.state);
      }
      return;
      } else {
      timer = false;
      this.state = STATE_EXECUTING;
      }
    4. Since timer is false, the pool part is skipped and the task is run in-place
      if (timer) {
      // the scheduled executor is single thread, and unfortunately not expandable with threads
      // so we just schedule onto the executor
      FoliaAsyncScheduler.this.executors.execute(this);
      return;
      }
      try {
      this.run.accept(this);
    5. If the task is a single-run delayed task, its state becomes STATE_FINISHED, but if it's a repeating task its state finally becomes STATE_ON_TIMER, so going back to the 3rd point: during subsequent runs timer is true and the task is properly dispatched into the thread pool

Steps/models to reproduce

public void onEnable() {
    Bukkit.getAsyncScheduler().runDelayed(this, task -> {
        System.out.println("Starting task 1");
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            System.out.println("Task 1 interrupted");
            throw new RuntimeException(e);
        }
        System.out.println("Finishing task 1");
    }, 5, TimeUnit.SECONDS);
    Bukkit.getAsyncScheduler().runDelayed(this, task -> {
        System.out.println("Starting task 2");
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            System.out.println("Task 2 interrupted");
            throw new RuntimeException(e);
        }
        System.out.println("Finishing task 2");
    }, 7, TimeUnit.SECONDS);
}

Expected output:

...
[00:00:00] Done (...s)! For help, type "help"
[00:00:05] [TestPaperPlugin] [STDOUT] Starting task 1
[00:00:07] [TestPaperPlugin] [STDOUT] Starting task 2
[00:00:15] [TestPaperPlugin] [STDOUT] Finishing task 1
[00:00:17] [TestPaperPlugin] [STDOUT] Finishing task 2
...

Observed output:

...
[00:00:00] Done (...s)! For help, type "help"
[00:00:05] [TestPaperPlugin] [STDOUT] Starting task 1
[00:00:15] [TestPaperPlugin] [STDOUT] Finishing task 1
[00:00:15] [TestPaperPlugin] [STDOUT] Starting task 2
[00:00:25] [TestPaperPlugin] [STDOUT] Finishing task 2
...

Plugin and Datapack List

Only the test plugin

Paper version

> ver
[16:12:37 INFO]: Checking version, please wait...
[16:12:37 INFO]: This server is running Paper version 1.21.4-130-main@a392d47 (2025-01-28T00:39:56Z) (Implementing API version 1.21.4-R0.1-SNAPSHOT)
You are running the latest version

Other

sorry for way too many letters, hopefully that helps 🎄

Metadata

Metadata

Assignees

No one assigned

    Labels

    scope: apistatus: acceptedDisputed bug is accepted as valid or Feature accepted as desired to be added.version: 1.21.4Game version 1.21.4

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions