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:
spawn_process() creates a fiber with while(true){} that is assigned to scheduler thread N.
- Thread N is now permanently blocked — it will never pick up another fiber.
- When new fibers are created via
go(), they are distributed across available scheduler threads.
- Any fiber assigned to thread N is silently lost — it is enqueued but will never execute.
- 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:
- 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.
- 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.
- 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.
- 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.
Issue Title: Silent fiber loss: Random task assignment to a blocked scheduler thread causes probabilistic deadlocks
Environment
~>0.18.11,~>0.19.0Background (Original Problem)
My original issue involved using
std.process.pipeProcessinside a goroutine to spawn a subprocess and read its stdout viabyLineCopy. SincepipeProcessperforms 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.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(), noyield()), it pins the scheduler thread in an infinite loop.The problem manifests as follows:
spawn_process()creates a fiber withwhile(true){}that is assigned to scheduler thread N.go(), they are distributed across available scheduler threads.test3, approximately 1 out of 19 fibers is lost because one thread is occupied by the busy-loop. (On a 4-core CPU, eachgo()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
test1vstest2: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): Thedelayloop 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:
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.yield()periodically, or useoffload()to run on a separate thread pool.Workaround
Currently, using
offload({ ... })correctly offloads the blocking operation (like my originalpipeProcess) 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.