Skip to content

Zend async Scheduler ABI - #22561

Open
EdmondDantes wants to merge 81 commits into
php:masterfrom
true-async:async-core
Open

EdmondDantes wants to merge 81 commits into
php:masterfrom
true-async:async-core

Conversation

@EdmondDantes

@EdmondDantes EdmondDantes commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

A lightweight asynchronous core without complex logic.

The idea is:
https://github.com/true-async/php-async-core-rfc/blob/main/scheduler_rfc.md

A detailed explanation of the core integration can be found here:
https://github.com/true-async/php-async-core-rfc/blob/main/core-integration.md

At the moment, both the code and the RFC are still under development. I'd be happy to hear your ideas and feedback.

@EdmondDantes
EdmondDantes marked this pull request as draft July 2, 2026 17:48
@EdmondDantes
EdmondDantes force-pushed the async-core branch 4 times, most recently from 615d050 to ed9fe03 Compare July 2, 2026 18:11
@EdmondDantes
EdmondDantes force-pushed the async-core branch 10 times, most recently from f3b272d to 6e1a5ce Compare July 3, 2026 09:58
Comment thread Zend/zend_async_API.c Outdated
Comment thread Zend/zend_scheduler_hook.stub.php Outdated
Comment thread Zend/zend_gc.c Outdated
Comment thread Zend/zend_scheduler_hook.stub.php Outdated
A provider registers once per process but wants async per request: it sets
READY with ZEND_ASYNC_INITIALIZE in its RINIT, or when it registers at run
time. Registration no longer sets READY. A request left OFF runs without a
scheduler, so a run-time provider such as the ext-scheduler-hook bridge is not
asked to launch before its PHP scheduler exists. TrueAsync gates its launch the
same way.
…h ZEND_ASYNC_CANCEL_EX

The comment said delivery is deferred; in TrueAsync a started coroutine
cancelled safely is not interrupted at all: it becomes a zombie and runs to
its end. ZEND_ASYNC_CANCEL_EX is the fork's macro that passes the flag.
…outine_count slot

Nothing kept ZEND_ASYNC_ACTIVE_COROUTINE_COUNT up to date, so it always read 0.
The scheduler knows its coroutines; the new slot asks it, and the macro gives 0
when no scheduler provides it. test_scheduler implements it and exposes it as
TestScheduler\coroutineCount() for the test.
A coroutine without an object was allowed by the comments but had no use, and
ZEND_COROUTINE_ADD_REF(), which new_coroutine tells callers to take, asserted
on it. The fiber code now takes and drops its reference with
ZEND_COROUTINE_ADD_REF()/ZEND_COROUTINE_RELEASE() instead of skipping a
missing object.
The CLI evaluated that code without the launch and the run after main that
php_execute_script_ex() gives a file, so the scheduler never started under
php -r. The RFC launches it before the script's first line in every SAPI.
-r code runs as a file does; -B, every -R line and -E share one main
coroutine, and the scheduler runs once, after -E.
Under the scheduler, every coroutine that found the root buffer full
awaited the same GC coroutine run and then called gc_adjust_threshold()
with its count. N waiters raised the threshold by N steps and grew the
buffer to match: 12000 coroutines left a threshold of 90020001 and a
buffer with room for 90 million roots after one run.

As in the TrueAsync core, a full buffer now records that a step is owed
(adjust_threshold, replacing run_deferred) and the GC coroutine takes it
once after its run, skipping a run that found the buffer drained. The
record is set before the call, since our callers await the run. A run
whose waiter was cancelled, or that was started where switching is
blocked, now adjusts the threshold too; before, neither ever did. When
the GC coroutine cannot be created, the caller takes the step at once,
as TrueAsync does. A bailout that cuts the run short drops the record
with the coroutine.
zend_gc_collect_cycles() removed the caller's live TMPVARs from the root
buffer before creating the GC coroutine. When creation failed it
returned 0 without re-rooting them, and they left GC tracking.
PHP prints the path with backslashes on Windows, so str_replace() did
not find the one built with a forward slash and the test failed there.
…r the front

A coroutine that fills the root buffer parks in the middle of an opcode until
the GC coroutine's run ends, and so does every coroutine that finds the buffer
full before the run starts. A scheduler that queues the run behind them keeps
one parked fiber stack per such coroutine: 100000 coroutines exceed
vm.max_map_count. The slot carries the engine's request in TrueAsync's
zend_coroutine_priority: HI_PRIORITY for the collector's run, NORMAL for the
destructor iterators, so a scheduler can put the run first without reordering
the destructor passes. TrueAsync gives the priority the other way round, to
its destructor coroutines, because its callers never wait for the run.
ext/test_scheduler ignores the request. API version 3.
zend_gc_collect_cycles() in a coroutine returned GC_G(gc_collected), the
count of the last run to finish. A scheduler that runs the GC coroutine
before the coroutines already queued wakes the waiter behind them, and one
of them can start and finish a second run first: the waiter then returned
the second run's count. A run cancelled before it started returned the
previous run's count too. The run stores its count in its coroutine's
result, and the waiter holds a reference to the run to read it, released
under gc_active: on a full buffer the root the release adds would start the
next run and wait for it, and that run's release would start another.

The await slot's caller holds the reference across the call and the slot
takes none, so the provider's return releases nothing on the waiter;
ext/test_scheduler's ts_await() no longer takes its own.
OPcache validates a cached script by its file time in whole seconds. The
test rewrote one .inc file for each case, and when a case wrote it within
the same second as an earlier case, its child ran the earlier case's
cached script: on WINDOWS_X64_ZTS, where the children share the parent's
OPcache memory, the exits case ran the throws case's code.
opcache.file_cache keys by path too, so a reused path can go stale on
other platforms as well.
zend_hash_update() runs the old value's destructor before it writes the new
one: a destructor that grows the table leaves the write in the freed bucket
array, one that unsets the key frees the value twice. Both the userland
store's string keys and the internal context replaced values this way; the
object keys already wrote the new value first and released the old one
after. The new value is also copied before the lookup, which may grow the
table a C caller's value points into.

zend_async_context_destroy() released the store while the coroutine still
pointed at it, so a value's destructor reached the store being freed.
…t ran

The scheduler finishes such a coroutine without the fiber's entry point,
and the entry point is what wakes the coroutine waiting in start(): it
waited forever, the fiber stayed in INIT, and a second start() adopted
another coroutine while the first kept a pointer to the fiber, written
after the Fiber was freed. A finish handler added at start() ends the
fiber and wakes the waiter with the coroutine's error, as from a body
that threw at once; the entry point removes it once the body runs.
TrueAsync calls the entry point with the error thrown instead.
After a fatal out-of-memory error in the script, the call after the
destructors made a loop coroutine and a new main coroutine. The object
store reuses no handle after the destructors, so a full store had to
grow with no memory left, and the error was printed a second time
"in Unknown on line 0". That call now finishes main, drains only when
there is work, and returns async to READY with no current coroutine,
as TrueAsync does after main: what runs before async is turned off is
main.c's print of an uncaught exception, on the synchronous paths.
… can run it

Output handlers run after the core turned async off. A fiber run as a
coroutine whose coroutine a bailout left parked is still suspended
there, and resume() or throw() enqueued it and waited as the current
coroutine: there is none, which asserts in zend_fiber_await() (an
Error from the scheduler without assertions). Both now throw a
FiberError before the enqueue once the scheduler has stopped, and the
FiberError of a refused switch when the current coroutine is missing
or finished, which is what zend_fiber_await() needs. A plain fiber is
not affected. TrueAsync has no such check.
The destructor of a fiber run as a coroutine cancels the coroutine and
detaches it, and left the fiber SUSPENDED. A destructor that kept the
object (a cycle the GC collects, whose __destruct stores the fiber)
then reached the legacy path of resume() and throw(), which switched
to a context the fiber never had: an assertion in a debug build, a
crash without one. Like a legacy fiber the destructor closes, the
fiber is now DEAD, and resume() throws "Cannot resume a fiber that is
not suspended".
A coroutine fiber stayed SUSPENDED until its body was scheduled, the
legacy caller pointer is never set for it, and the yield cleared the
waiting caller before that caller had taken the value. A resume() from
another coroutine in either window passed the check, overwrote the
transfer value and the waiting caller, and the first caller waited for
good. resume() and throw() now mark the fiber RUNNING, as a legacy
switch does. The yield moves the waiting caller to handoff_coroutine,
where it stays until it has taken the value; a body its cancellation
runs meanwhile yields to nobody and leaves that value alone. resume(),
throw(), isSuspended() and isRunning() count both as the legacy path
counts its caller.

The transfer slot releases a value nobody takes (a waiter cancelled
after the yield, a resume value the cancellation of the body overtook,
a failed enqueue), detached first and released after the state change,
since its destructor may park or re-enter. A starter cancelled before
the body first runs leaves the body no frame to link its backtrace to.
A coroutine fiber stays INIT until its body runs. When the starter was
cancelled while the body waited in the queue, a second start() adopted
the fiber again: two coroutines ran one body and one of them leaked.
start() refuses a fiber that already has a coroutine, and isStarted()
says so.
…t ends

A body parked outside Fiber::suspend() (an await, a delay) saved
EG(active_fiber) with its context and got it back on the next switch
in. When the last holder, its starter, was cancelled meanwhile, the
destructor freed the fiber and the body's Fiber::getCurrent() or
Fiber::suspend() read freed memory. The destructor now keeps the object
until the body ends, through a finish handler, as a legacy fiber is
held by its resumer while it runs. The body is cancelled, unless it is
the running coroutine outside the scheduler's tick, and its
Fiber::suspend() refuses as in a force-closed fiber.
…h handler, a defer and the launch

The core's refusal paths had no test: the test scheduler could fail only
the enqueue of a CREATED coroutine. New INI countdowns, each failing the
Nth call once: fail_wake (the enqueue of a coroutine that is not
CREATED), fail_cancel, fail_finish_handler (returns 0), fail_defer and
fail_launch (returns NULL). A refused enqueue, cancel or defer throws an
Error naming its seam; a transferred error is released.

Two fixes the tests need. An exception a finish handler leaves at a
coroutine's end becomes that coroutine's, as the body's would, instead
of staying pending for the next coroutine to meet at its suspend point;
one the body ended with comes first. ts_enqueue() stores the error for
the suspension point only after its last refusal, so a refused wake
leaves no error behind for a later cancel to take as already in flight.
…body's end without losing an error

Fiber::suspend() parked the body before waking the waiting caller: a
refused wake left its error pending, and the next coroutine to run met
it. The wake now comes first; refused, it throws in the body, which
goes on. start() added its finish handler after the enqueue and stored
the id unchecked: a refused handler left a body cancelled before it ran
with nobody to wake the starter. The handler now comes first, and a
refused one fails start() with a FiberError. A body's end handed its
error to the caller with ownership, so a refused wake released it; the
error now stays the coroutine's, under the refusal, and an exit stays an
exit.

The enqueue contract says what the core relies on: every refusal
returns false with an Error thrown and a transferred error released.
…nd, not by a finish handler

The destructor of a fiber whose body runs kept the object through a
finish handler. A scheduler that refused the handler left the body
running on a freed fiber, a heap use after free in Fiber::getCurrent();
a handler that threw or was skipped kept the fiber to the request's
end. The destructor now sets ZEND_FIBER_FLAG_HELD_BY_BODY, and the
coroutine entry releases the object after it has handed the body's
outcome back, on the bailout path too. The hand-back moves into
zend_fiber_coroutine_hand_back().
…in charge

gc_spawn_destructors_coroutine() set dtor_coroutine and counted the new
iterator before the scheduler accepted it, and undid both on refusal.
The parked iterator, still running, then saw dtor_coroutine NULL and
never disarmed the microtask, which leaked. Both are set only after the
handler and the enqueue succeed.
…opped a reference

The error of a refused wake of the GC coroutine, a refused defer of the
hand-off microtask, or a refused run where switching is blocked (a tick
function, the scheduler's own work) stayed pending and surfaced in
unrelated code. Each is cleared, as a refused iterator's already was:
it belongs to no caller. One that wraps an exception the caller had
stays, as clearing it would take that exception with it. A GC coroutine
whose wake is refused stays parked inside its run: the collector is off
for the rest of the request, and whoever waits for the run stays parked
until the scheduler finds nothing else to run and reports a deadlock.
…ow position across the park

A reference an unwind drops can fill the root buffer, and the automatic
run parks that frame until the GC coroutine ends. The pending exception
went into the switch: the next coroutine met it, or, held by the
scheduler, it came back with EG(opline_before_exception) overwritten by
code that threw and caught meanwhile, so the frame's catch was missed.
gc_possible_root_when_full() now holds the exception and its throw
position as zend_objects_destroy_object() does around a destructor,
without moving the frame back to the throwing opline: the GC's TMPVAR
walks would take live ranges the unwind already freed as live. A
cancellation delivered meanwhile is thrown over the held exception; an
exit wins either way. A refused GC coroutine's error is cleared under
the hold, so the exception in flight reaches its catch unchanged.

The suspend contract says the caller parks with no exception pending,
and the test scheduler asserts it.
…e TMPVARs

zend_gc_collect_cycles() shields the caller's live TMPVARs from the run
and re-roots them after the await by the frame's opline. A cancellation
delivered at the await moves the frame to the exception op, the walk
found no live range, and the shielded values fell out of the root
buffer: a cycle only they kept was collected at the request's end. The
re-root walks at the opline the shield used; nothing between the two
frees a TMPVAR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants