Skip to content

PlayerLoopRunner.RunCore() does not preserve FIFO processing order due to tail-swap compaction #701

Description

@anyldursun

PlayerLoopRunner.RunCore() uses a single-pass compaction algorithm that processes
IPlayerLoopItems in a non-deterministic order. When a null slot is encountered during
iteration, the algorithm pulls an item from the tail of the array to fill the gap
and calls MoveNext() on it immediately.

This causes tail items to be processed before lower-index items, breaking the
expected FIFO (first-in, first-out) processing order based on registration time.

Example

Given an array state of [A, null, B, C] with tail=4:

i=0: A.MoveNext() called — correct
i=1: null found → pull C from tail → C.MoveNext() called — C processed BEFORE B!
i=2: B.MoveNext() called — too late

Processing order: A, C, B (not registration order A, B, C)

The null slot pattern depends on which items completed in previous frames, which
varies between different execution contexts. This makes the processing order
effectively non-deterministic.

Real-World Impact

This is problematic for any system that relies on deterministic async execution
ordering — replay systems, lockstep simulations, or any scenario where multiple
UniTask.Delay() / UniTask.NextFrame() calls complete in the same frame and their
continuation order matters.

Question

I understand that this tail-swap approach is intentional for performance optimization
(O(1) removal, no array shifting), as confirmed in #453 where there is explicitly no
guarantee of callback sequence. Was there a reason to use the tail swap method other than performance?

However, I believe FIFO ordering can be preserved without meaningful performance
cost.

Proposed Solution

A stable, in-place forward-compaction algorithm that preserves strict FIFO ordering.
Still single-pass, still O(n), same number of MoveNext() calls — just shifts items
forward instead of swapping from tail:

  [System.Diagnostics.DebuggerHidden]
  void RunCore()
  {
      lock (runningAndQueueLock)
      {
          running = true;
      }

      lock (arrayLock)
      {
          var firstNullIndex = -1;

          for (int i = 0; i < tail; i++)
          {
              var action = loopItems[i];
              if (action != null)
              {
                  try
                  {
                      if (!action.MoveNext())
                      {
                          loopItems[i] = null;
                      }
                      else
                      {
                          if (firstNullIndex != -1)
                          {
                              loopItems[firstNullIndex] = action;
                              loopItems[i] = null;
                              firstNullIndex++;
                          }
                          continue;
                      }
                  }
                  catch (Exception ex)
                  {
                      loopItems[i] = null;
                      try
                      {
                          unhandledExceptionCallback(ex);
                      }
                      catch { }
                  }
              }

              if (firstNullIndex == -1) firstNullIndex = i;
          }

          tail = firstNullIndex == -1 ? tail : firstNullIndex;

          lock (runningAndQueueLock)
          {
              running = false;
              while (waitQueue.Count != 0)
              {
                  if (loopItems.Length == tail)
                  {
                      Array.Resize(ref loopItems, checked(tail * 2));
                  }
                  loopItems[tail++] = waitQueue.Dequeue();
              }
          }
      }
  }

How it works:

Array: [A, null, B, C] (tail=4)
i=0: A.MoveNext() → alive, no gap yet → [A, null, B, C]
i=1: null → firstNull=1
i=2: B.MoveNext() → alive, shift to [1], firstNull=2 → [A, B, null, C]
i=3: C.MoveNext() → alive, shift to [2], firstNull=3 → [A, B, C, null]
Processing order: A, B, C ✓ (registration order preserved)

Performance:

Same single-pass structure. Same number of MoveNext() calls. The only difference is
forward-shift instead of tail-swap for compaction — both are O(n) overall. For
typical item counts, the overhead is negligible.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions