Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ The mechanism was selected by experiment, and the negative result is the load-be
- **The scheduler must hold a strong reference to every preempted fiber and drain them before
shutdown.** Dropping one is fatal, and uninstalling the hook first does not help.
- **Each forked worker re-arms its own timer** — `setitimer` intervals are cleared in the child.
- **The clock is stopped for the idle poll, and the re-arm is a `finally`.** A free-running 10 ms
timer wakes an idle preemptive process ~100 times a second to preempt nobody, so
`Scheduler::pollIdle()` brackets the one blocking call with `Preemptor::pauseSlicing()` and
`resumeSlicing()`. Two conditions make that safe and neither is negotiable: **idle means the
poller is about to block**, never "the run queue looks empty" — a coroutine about to run must
still be sliceable — and the re-arm lives in a `finally` spanning the whole `poll()`, so no way
out of it (a readiness, a timeout, the EINTR retry the poller does internally, a throw) can leave
the process silently cooperative. Pausing is not disarming: the hook stays installed, SIGALRM
stays ours, and `isArmed()` goes on saying yes. A missed re-arm reports nothing and fails nowhere,
which is why it is structural rather than careful.
- **10 ms is a target, not a guarantee.** A single internal opcode is not interruptible: `sort()`
over 4M ints delayed preemption by 1.6–2.0 s. Never document or assume a bounded slice; document
the caveat with it.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,10 @@ z-engine requires it, and z-engine is a hard dependency of this package.
- **Preemption is opt-in** (`new Runtime(preemptive: true)`) and, once armed, makes coroutine
lifetimes the scheduler's business: a preempted coroutine is suspended inside an engine callback,
so it is drained rather than discarded when a run ends.
- **An idle preemptive runtime is as quiet as a cooperative one.** The slice clock is stopped for
exactly the time the process spends blocked in the poller — there is no coroutine to take the CPU
away from — and started again on every way out of it, so a server waiting for work does not pay a
hundred wakeups a second. Every forked worker gets the same treatment for its own inbox wait.
- **`workers: 0` maps no arena at all.** The shared surface is then refused with a message naming the
remedy rather than half-composed — a cooperative runtime stays exactly as cheap as it was.

Expand Down
96 changes: 92 additions & 4 deletions src/Preemption/Preemptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@
* worker. Leaving the outermost section re-raises the interrupt so the deferred preemption is
* taken immediately rather than up to a slice later.
*
* # The clock stops while the process is idle
*
* A slice measures the CPU a coroutine is holding, so there is nothing to measure while no
* coroutine is running. {@see self::pauseSlicing()} and {@see self::resumeSlicing()} let the
* scheduler stop the clock for exactly the time it spends blocked in the poller, which is what
* keeps an idle preemptive runtime from waking a hundred times a second to preempt nobody. Pausing
* is not disarming: the hook stays installed, the signal stays ours, and this class keeps reporting
* itself armed.
*
* # What preemption does not promise
*
* A time slice is a target, not a bound. The interrupt check sits between opcodes, so a single
Expand Down Expand Up @@ -82,6 +91,15 @@ final class Preemptor

private int $criticalDepth = 0;

/**
* The clock is stopped because the process is blocked in the poller with nothing running.
*
* Distinct from {@see self::$armed}, and the distinction is the whole point: a paused preemptor
* is still armed — the hook is installed, the signal is ours, `shouldPreempt()` still answers —
* it simply has no clock ticking while there is nothing on the CPU to take away.
*/
private bool $slicingPaused = false;

private int $preemptions = 0;

private bool $shutdownDrainRegistered = false;
Expand Down Expand Up @@ -162,19 +180,23 @@ public function arm(): void
* Stop preempting, and leave nothing suspended inside the engine callback behind.
*
* The drain happens *first*, while the clock is still running: a coroutine resumed here may be
* in a loop that never yields, and only a live timer guarantees that resuming it returns.
* in a loop that never yields, and only a live timer guarantees that resuming it returns. That
* is also why the idle pause is lifted before the drain rather than left to the ordinary path —
* a teardown reached from inside a blocking poll would otherwise drain against a stopped clock.
*/
public function disarm(): void
{
if (!$this->armed) {
return;
}

$this->resumeSlicing();
$this->scheduler->drainPreempted();

$this->clock->disarm();
$this->armed = false;
$this->requested = false;
$this->armed = false;
$this->requested = false;
$this->slicingPaused = false;

if (extension_loaded('pcntl')) {
$previous = $this->previousSignalHandler;
Expand All @@ -200,14 +222,77 @@ public function rearmAfterFork(): void
return;
}

$this->requested = false;
$this->requested = false;
$this->slicingPaused = false;

pcntl_async_signals(true);
pcntl_signal(SIGALRM, $this->onTick(...));

$this->clock->rearmAfterFork();
}

/**
* Stop the clock on the way into a blocking poll, without giving up preemption.
*
* A free-running interval timer raises SIGALRM about a hundred times a second whether or not
* there is anything to preempt, and every one of those signals cuts the poller's
* `stream_select()` short. Correctness survives it — the poller retries with what is *left* of
* the timeout — but an idle preemptive server pays a hundred wakeups a second to suspend
* nobody.
*
* Nothing is given up by stopping the clock there, because "there" means no coroutine is
* running: the run queue is empty, every due timer has fired, and the process is about to block
* in the kernel. What is emphatically **not** the condition for this is "the run queue looks
* empty" — a coroutine that is about to run must still be sliceable, which is why the pause
* brackets the blocking call itself and is undone before anything is dequeued.
*
* Only the clock stops. The interrupt hook stays installed, SIGALRM stays ours, a preemption
* that was requested and not yet taken stays pending, and {@see self::isArmed()} goes on saying
* yes — the runtime is still preemptive, it is merely not counting.
*/
public function pauseSlicing(): void
{
if (!$this->armed || $this->slicingPaused) {
return;
}

$this->slicingPaused = true;

$this->clock->disarm();
}

/**
* Start the clock again on the way out of the poll — on *every* way out.
*
* The caller owes this a `finally`. A readiness, a timeout, a signal that cut the wait short
* and sent it round the EINTR retry, and a poller that throws must all end with the clock
* running again, because the next thing the scheduler does is resume a coroutine. A missed
* re-arm fails nowhere and reports nothing: it leaves the process cooperative for the rest of
* its life behind a preemptor that still calls itself armed.
*
* The interval restarts from now rather than resuming the grid the pause interrupted, so the
* coroutine that runs next gets a whole slice instead of whatever was left of one.
*/
public function resumeSlicing(): void
{
if (!$this->slicingPaused) {
return;
}

$this->slicingPaused = false;

// Checked rather than assumed: the shutdown drain disarms, and it can run from anywhere.
if ($this->armed) {
$this->clock->arm();
}
}

/** Whether the clock is stopped for an idle poll; still armed, just not ticking. */
public function isSlicingPaused(): bool
{
return $this->slicingPaused;
}

/**
* Open a section that must run to its end without being preempted.
*
Expand Down Expand Up @@ -324,6 +409,9 @@ private function registerShutdownDrain(): void
$this->shutdownDrainRegistered = true;

register_shutdown_function(function (): void {
// Shutdown can be reached from anywhere, an idle poll included, and the drain below
// needs a running clock to be sure a resumed coroutine ever comes back.
$this->resumeSlicing();
$this->scheduler->drainPreempted();
$this->clock->disarm();
$this->armed = false;
Expand Down
41 changes: 40 additions & 1 deletion src/Scheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
* A coroutine parked as *externally wakeable* is excluded from that conclusion: its wakeup was
* never the scheduler's to produce. That exclusion is what keeps an idle server from reporting
* itself deadlocked every time it has nothing to do.
*
* The blocking poll of step 2 is also the only moment a preemptive process has nothing on the CPU,
* so it is where the slice clock is stopped and started again — see {@see self::pollIdle()}.
*/
final class Scheduler implements SchedulerInterface
{
Expand Down Expand Up @@ -335,13 +338,49 @@ private function drive(?CoroutineInterface $until): void
return;
}

$this->poller->poll($timeout);
$this->pollIdle($timeout);
}
} finally {
$this->current = null;
}
}

/**
* Block in the poller, with the slice clock stopped for exactly as long as that lasts.
*
* This is the one place in the process where nothing is running: the run queue is empty, every
* due timer has fired, and the next thing that happens is a syscall. A preemption timer left
* free-running over it raises SIGALRM ~100 times a second, cuts each `stream_select()` short
* and has nothing to suspend when it does — the correctness is fine (the poller retries with
* the remaining timeout) and the wakeups are pure waste.
*
* Two things make this safe, and both are structural rather than careful:
*
* - **Idle is "the poller is about to block", not "the queue looks empty".** The pause opens
* after the queue has drained and the timers have fired, and closes before anything is
* dequeued, so a coroutine that is about to run is never the one that lost its slice.
* - **The re-arm is a `finally` over the whole call.** Every way out of `poll()` — a readiness,
* a timeout, the EINTR retry that a signal sends it round, a `RuntimeException` from a broken
* descriptor — leaves the clock running again. A missed re-arm would not throw anywhere: it
* would quietly leave the process cooperative behind a preemptor still reporting itself
* armed.
*
* The preemptor is read once into a local, so the pause and the re-arm are always the same
* object's, whatever a callback fired from inside the poll does with the attachment.
*/
private function pollIdle(?float $timeout): void
{
$preemptor = $this->preemptor;

$preemptor?->pauseSlicing();

try {
$this->poller->poll($timeout);
} finally {
$preemptor?->resumeSlicing();
}
}

private function runNext(): void
{
$coroutine = $this->runQueue->dequeue();
Expand Down
29 changes: 29 additions & 0 deletions src/StreamPoller.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ final class StreamPoller implements PollerInterface
/** @var array<int, array{stream: resource, onReadable: \Closure(resource): void}> */
private array $watches = [];

/**
* How many times a blocking wait in this poller has ended, for any reason.
*
* A diagnostic, and the only honest way to ask "is this process actually idling?". A signal
* that cuts a `stream_select()` short and is retried counts here as its own wakeup, because
* from the kernel's point of view that is exactly what it was — the process woke up, did
* nothing, and went back to sleep. Counting only the returns of {@see self::poll()} would
* report an idle preemptive runtime waking once a second when it is really waking a hundred
* times.
*/
private int $wakeups = 0;

public function __construct(private readonly SchedulerInterface $scheduler) {}

public function awaitReadable($stream): void
Expand Down Expand Up @@ -152,6 +164,19 @@ public function hasWatches(): bool
return $this->readWaiters !== [] || $this->writeWaiters !== [] || $this->watches !== [];
}

/**
* How many times this poller has come back from a blocking wait.
*
* One per `stream_select()` return — a readiness, a timeout, or a signal that cut it short and
* sent it round the retry — plus one per descriptor-less idle sleep. An idle process should
* report roughly one wakeup per thing it is actually waiting for, and a count that scales with
* elapsed time instead is the signature of something waking it for nothing.
*/
public function wakeups(): int
{
return $this->wakeups;
}

/**
* Drop every registration without waking anybody.
*
Expand Down Expand Up @@ -283,6 +308,8 @@ private function select(array $read, array $write, ?float $timeout): array
error_clear_last();
$count = @stream_select($readSet, $writeSet, $exceptSet, $seconds, $microseconds);

++$this->wakeups;

if ($count !== false) {
return ['read' => self::readyIds($readSet), 'write' => self::readyIds($writeSet)];
}
Expand Down Expand Up @@ -352,6 +379,8 @@ private function idle(float $seconds): void

if ($microseconds > 0) {
usleep($microseconds);

++$this->wakeups;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
--TEST--
A call-free loop that starts after an idle stretch is preempted exactly as one that never idled
--INI--
ffi.enable=1
opcache.jit=off
error_reporting=E_ALL & ~E_DEPRECATED
--FILE--
<?php

declare(strict_types=1);

use Lisachenko\NativePhpCoroutines\Coroutine;
use Lisachenko\NativePhpCoroutines\Runtime;

include __DIR__ . '/../../vendor/autoload.php';

// The clock is stopped while the process blocks in the poller, so every idle stretch is a chance to
// come back cooperative by accident. This is the ordinary way out of that poll — no signal, no
// error, just a timer deadline arriving — and it has to end with slicing running again, because
// the coroutines that were waiting on that deadline are the ones about to get the CPU.
const IDLE_SECONDS = 0.1;
const ITERATIONS = 4_000_000;

$state = new stdClass();
$state->ticks = 0;
$state->ticksSeenByTheLoop = -1;

$runtime = new Runtime(preemptive: true);

$runtime->run(static function () use ($state): void {
// Nothing else is runnable and nothing is registered with the poller, so the scheduler really
// does go idle here rather than taking a turn round the run queue.
Coroutine::sleep(IDLE_SECONDS);

Coroutine::spawn(static function () use ($state): void {
$sum = 0;

for ($index = 0; $index < ITERATIONS; $index++) {
$sum += $index % 7;
}

$state->ticksSeenByTheLoop = $state->ticks;
});

Coroutine::spawn(static function () use ($state): void {
for ($round = 0; $round < 10_000; $round++) {
$state->ticks++;
Coroutine::yield();
}
});

for ($round = 0; $round < 3; $round++) {
Coroutine::yield();
}
});

echo 'the ticker ran while the loop was still running: ',
$state->ticksSeenByTheLoop >= 1 ? 'yes' : 'no', PHP_EOL;
echo 'the loop was preempted at least once: ',
($runtime->preemptor()?->preemptions() ?? 0) >= 1 ? 'yes' : 'no', PHP_EOL;
?>
--EXPECT--
the ticker ran while the loop was still running: yes
the loop was preempted at least once: yes
Loading