Skip to content

Silent fiber loss: Random task assignment to a blocked scheduler thread causes probabilistic deadlocks #62

Description

@mzfhhhh

Issue Title: Silent fiber loss: Random task assignment to a blocked scheduler thread causes probabilistic deadlocks

Environment

  • OS: Windows
  • D Compiler: DMD v2.111.0
  • Photon Version: ~>0.18.11, ~>0.19.0

Background (Original Problem)

My original issue involved using std.process.pipeProcess inside a goroutine to spawn a subprocess and read its stdout via byLineCopy. Since pipeProcess performs blocking OS-level I/O, it blocks the underlying OS thread.
Through deep investigation, I've identified the root cause of the subsequent scheduler stall: When one worker thread is blocked by a CPU-bound infinite loop or blocking I/O, the scheduler still randomly assigns new Fibers to that blocked thread's queue. Once assigned, these Fibers are silently lost and never execute.

Reproduction Steps

Run the following minimized code. It simulates a blocked OS thread using while(true), and provides three test scenarios to demonstrate the issue.

module bugfix;
import photon;
import std.stdio;
void spawn_process()
{
    go(() {
        // Simulates a blocking OS thread (like pipeProcess would cause)
        while (true) 
        {
        }
    });
}
// Phenomenon 1: Channel + delay -> Global Deadlock
void test1()
{
    shared Channel!int testBus = channel!int(100);
    shared Channel!int testBus2 = channel!int(100);
    go({
        testBus.put(0);
        foreach (v; testBus)
        {
            delay(100.msecs);
            testBus2.put(v + 1);
        }
    });
    foreach (ctx; testBus2)
    {
        go({ writeln("hello...................", ctx); testBus.put(ctx); });
    }
}
// Phenomenon 2: Pure delay -> Survives (Misleading)
void test2()
{
    go({
        int c;
        while (true)
        {
            writeln("hello world:", c++);
            delay(100.msecs);
        }
    });
}
// The Definitive Proof: Silent Fiber Loss
void test3()
{
    foreach (i; 1..20)
    {
        go({ writeln("fiber", i); });
        delay(100.msecs);
    }
}
void main()
{
    initPhoton();
    
    spawn_process();
    // go({ test1(); }); // Try this: Deadlocks after a few prints
    // go({ test2(); }); // Try this: Survives indefinitely
    go({ test3(); });   // Try this: Missing outputs (lost fibers)
    runScheduler();
}

Root Cause Analysis

Photon uses a cooperative multi-threaded fiber scheduler. Each scheduler thread runs fibers from its own queue. When a fiber never yields (no I/O, no delay(), no yield()), it pins the scheduler thread in an infinite loop.
The problem manifests as follows:

  1. spawn_process() creates a fiber with while(true){} that is assigned to scheduler thread N.
  2. Thread N is now permanently blocked — it will never pick up another fiber.
  3. When new fibers are created via go(), they are distributed across available scheduler threads.
  4. Any fiber assigned to thread N is silently lost — it is enqueued but will never execute.
  5. In test3, approximately 1 out of 19 fibers is lost because one thread is occupied by the busy-loop. (On a 4-core CPU, each go() has a ~25% chance of hitting the blocked thread. The probability of N fibers surviving is (3/4)^N, making fiber loss a mathematical certainty over time).
    This root cause also explains the confusing behavior in test1 vs test2:
  • test1 (Deadlock): The Channel relies on producers and consumers. If either is randomly assigned to the blocked thread, the Channel handshake breaks, causing the remaining Fibers on unblocked threads to hang waiting for a message that will never come.
  • test2 (Survives): The delay loop runs entirely within a single Fiber. If this Fiber lands on an unblocked thread, it continues forever.
    The number of lost fibers scales with the number of non-yielding fibers: each non-yielding fiber monopolizes one scheduler thread.

Suggested Improvements

Since true preemptive scheduling is not feasible in a pure library implementation (unlike Go's compiler-injected stackguard), the following improvements could mitigate this:

  1. Work-stealing: Allow idle scheduler threads to steal fibers from busy threads' queues. This would prevent fiber loss even when one thread is monopolized by a non-yielding fiber. This is the most practical fix at the library level.
  2. Fiber queue isolation from monopolized threads: When creating a fiber via go(), avoid assigning it to threads that are already running a long-lived non-yielding fiber. A simple heuristic (e.g., tracking per-thread fiber completion rate) could detect stalled threads and route new fibers elsewhere.
  3. Watchdog / detection: A background monitor thread could track how long each fiber has been running without yielding. If a fiber exceeds a threshold (e.g., 5 seconds), log a prominent warning to help developers identify CPU-bound fibers during development.
  4. Documentation note: Clearly document that fibers performing CPU-bound work must call yield() periodically, or use offload() to run on a separate thread pool.

Workaround

Currently, using offload({ ... }) correctly offloads the blocking operation (like my original pipeProcess) to a separate thread pool,
preventing the Photon worker thread from being starved.

This issue was very difficult to track down. The symptoms are intermittent and seemingly random — fibers silently disappear without any error message or stack trace. I spent a significant amount of time narrowing this down to a minimal reproducible test case. A fix (or at least a mitigation such as work-stealing) would be greatly appreciated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions