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
34 changes: 31 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,15 @@ The mechanism was selected by experiment, and the negative result is the load-be

## Fork, layers and locks

- **Sharing is fork-only.** Shared objects are valid because children inherit an identical address
layout. There is no attach-by-key across unrelated processes, and adding one would mean giving up
addresses as values — that is, giving up the Never-Serialize Rule.
- **Every participant must see the arena at the same virtual address, and must agree on the engine
pointers baked into shared structs** — class entries, `std_object_handlers`. Under the
Never-Serialize Rule an address *is* the value, so a shared object only means the same thing in
another process when both hold. This is the requirement; it is not a statement about how you get
there. The implementation gets both for free by forking — a child inherits its parent's mappings
and its loaded classes — but `shm_open` plus `mmap(MAP_FIXED)`, `userfaultfd` and other mechanisms
can put the same region at the same address in a process that was never forked from this one. A
design that establishes the same two guarantees another way is legitimate. What may not be given
up is the guarantees.
- **Prefork ordering is load-bearing**: the arena and the shared roots are created **before** the
fork; fibers are created **after** it. A fiber that exists across the fork barrier is a stack the
child now owns a copy of, and a shared root created after it is not shared at all.
Expand Down Expand Up @@ -349,3 +355,25 @@ Scopes in use: `scheduler`, `channels`, `select`, `sync`, `context`, `poller`, `
`parallel`, `contracts`, `tests`, `tools`, `spikes`, `ci`, `docs`.

Code style is PER-CS2.0, applied by php-cs-fixer. Run `composer cs:fix` rather than hand-formatting.

## Breaking changes are allowed — prefer the correct shape

This package and `lisachenko/php-shared-data-extension` ride development lines and have **no
external consumers**. Backwards compatibility is therefore not a constraint on either of them:
rename a method, narrow an interface, change a record layout, resize an id — whatever makes the
design right. Do not carry a deprecation cycle, do not keep a wrong method alive because something
might implement it, and do not invent an adapter to avoid touching a published shape. `LAYOUT_VERSION`
already exists to hard-fail a mismatched reader, which is the only compatibility mechanism this
family needs while it is being built.

Two things this does **not** license.

**`lisachenko/z-engine` is different.** It has consumers of its own, so a change there gets the
ordinary care — and, as ever, the fix for a missing capability is a named public method upstream
rather than a reach-through from here.

**None of this applies to the invariants.** The Never-Serialize Rule, the same-address requirement, the prefork
ordering, the `arData` law, the publication order, lock discipline, `EOWNERDEAD` handling and the
preemption obligations are not API contracts — they are the conditions under which this code is
correct at all. "Breaking changes are allowed" means the *shape* is negotiable. The rules above it in
this file are not.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,10 @@ z-engine requires it, and z-engine is a hard dependency of this package.
`finally` blocks do not run — exactly as a goroutine's deferred calls do not run when `main`
returns.
- **An uncaught throwable is a panic**: it ends the run and comes back out of `Runtime::run()`.
- **Sharing is fork-only.** Shared objects are valid because children inherit an identical address
layout; there is no attach-by-key across unrelated processes.
- **Every participant must see the arena at the same address**, and must agree on the engine pointers
inside shared structs (class entries, object handlers) — an address is the value, so both have to
hold for a shared object to mean the same thing twice. Forked workers get both for free, which is
how the runtime works today; it is a requirement, not a restriction to forking forever.
- **Plain arrays are not shareable.** Use `SharedArray`; a plain array grows into the private heap of
whichever process filled it. Closures are shareable only by **pre-fork registration**
(`registerSharedClosure()`); work created after the fork travels as a `Task`. Anything else throws
Expand Down
3 changes: 2 additions & 1 deletion src/Runtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
*
* # The order everything is created in is the design
*
* Fork is what makes sharing sound, and fork only copies what already exists:
* Workers must see the arena at the same address as the parent, and a fork only copies what already
* exists — so anything shared has to be built before it:
*
* 1. **the arena, the wake registry and the result slots** — in this constructor, so they exist
* before anything else and every worker inherits them at the same address;
Expand Down
6 changes: 4 additions & 2 deletions src/RuntimeInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ interface RuntimeInterface
/**
* Declare a named shared root, to be created **before** the workers fork.
*
* Fork is what makes sharing sound: children inherit an identical address layout, so a root
* created before the fork is valid at the same address in every process. Declaring a root after
* A root is addressed, not named, once it is in the arena, so it is only usable by a process
* that sees it at the same virtual address. Forking is how this runtime arranges that today: a
* child inherits the parent's mappings, so a root created before the fork is at the same address
* everywhere, and one created afterwards exists in a single process. Declaring a root after
* {@see self::run()} has forked is therefore an error, not a late binding.
*
* @param class-string $class Shared type to instantiate, e.g. SharedArray or SharedChannel.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ use Lisachenko\NativePhpCoroutines\RuntimeInterface;

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

const ITERATIONS = 1_500_000;
// Roughly half a 10 ms slice on the machines this has been measured on, so the preemption
// counter is re-read often enough to stop promptly without the loop itself being interrupted
// by the check. The cap bounds a build where preemption never fires: the test must fail, not spin.
const CHUNK = 250_000;
const MAX_ITERATIONS = 100_000_000;

$state = new stdClass();
$state->parked = false;
Expand All @@ -31,19 +35,32 @@ $runtime = new Runtime(preemptive: true);
$runtime->run(static function (RuntimeInterface $self) use ($state): void {
$silence = new Channel($self->scheduler());

Coroutine::spawn(static function () use ($state, $silence): void {
$sum = 0;
// Kept so the assertion below can look at the channel itself once the run is over. Whether
// the coroutine really parked is only observable from the channel's own wait queue: a flag
// set by the coroutine can only prove it reached the line *before* parking.
$state->channel = $silence;

for ($index = 0; $index < ITERATIONS; $index++) {
$sum += $index % 7;
Coroutine::spawn(static function () use ($state, $silence, $self): void {
$sum = 0;
$index = 0;

// Burn CPU until the scheduler has actually taken a slice back, rather than for a fixed
// number of iterations. How long a fixed count takes is a property of the machine: 1.5M
// iterations finished inside the first 10 ms slice on a CI runner, so the coroutine was
// never preempted and the test failed on a build where preemption worked perfectly.
while (($self->preemptor()?->preemptions() ?? 0) < 1 && $index < MAX_ITERATIONS) {
for ($chunk = 0; $chunk < CHUNK; $chunk++) {
$sum += $index % 7;
$index++;
}
}

$state->parked = true;

// Nobody ever sends here. The drain resumes this coroutine out of the preemption
// callback, it parks itself on the channel, and from there it is ordinary debris that
// main returning is entitled to drop.
$silence->receive();
$silence->recv();

$state->finished = true;
});
Expand All @@ -53,7 +70,8 @@ $runtime->run(static function (RuntimeInterface $self) use ($state): void {

echo 'the loop was preempted: ',
($runtime->preemptor()?->preemptions() ?? 0) >= 1 ? 'yes' : 'no', PHP_EOL;
echo 'it was drained far enough to park itself: ', $state->parked ? 'yes' : 'no', PHP_EOL;
echo 'it was drained far enough to park itself: ',
$state->parked && $state->channel->pendingReceivers() === 1 ? 'yes' : 'no', PHP_EOL;
echo 'it was then discarded rather than resumed: ', $state->finished ? 'no' : 'yes', PHP_EOL;
?>
--EXPECT--
Expand Down