Skip to content

Zend async Scheduler ABI - #22561

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

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

Conversation

@EdmondDantes

@EdmondDantes EdmondDantes commented Jul 2, 2026 •

Copy link
Copy Markdown

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
…me from new_coroutine

ZEND_ASYNC_NEW_COROUTINE() now serves those callers and keeps the NULL check of the
removed fallback: new_coroutine is optional at registration.
…the run-time caller of zend_async_scheduler_unregister
…ts_store *)

Reverts a3834e2: the signature is core API an extension may call, so it
keeps taking the store, and the iterator entry forwards to it again.
Reverts the alias removal of 0301846: ZEND_ASYNC_CLASS_NO,
ZEND_ASYNC_IS_OFF, ZEND_ASYNC_IS_READY and the ZEND_ASYNC_CONTEXT_* and
ZEND_ASYNC_INTERNAL_CONTEXT_* macros are core API an extension may use. The
comment naming the run-time caller of zend_async_scheduler_unregister stays.
Reverts 9edcc5f: object_offset 0 marks a plain C coroutine with no PHP
object again, ZEND_COROUTINE_OBJECT() returns NULL for it, and the fiber
code checks for that before touching the object.
Reverts 653827c: a scheduler may again tell the engine's own GC and
shutdown destructor coroutines apart from the others. ZEND_ASYNC_NEW_COROUTINE()
keeps the NULL check that commit added, since new_coroutine is optional at
registration.
Reverts 08e25de: the cancel slot takes is_safely again, and
ZEND_ASYNC_CANCEL passes false.
Reverts 5e53ee2: new_coroutine takes extra_size again and
ZEND_ASYNC_NEW_COROUTINE_EX() is back. Both macros keep the NULL check, since
new_coroutine is optional at registration.
… zend_async_is_enabled()

Reverts c440e48: the call_on_main_stack and coroutine_from_object slots,
the OBJ_REF object model, ZEND_ASYNC_GET_EXCEPTION_CE,
ZEND_ASYNC_CALL_ON_MAIN_STACK, zend_async_is_enabled(),
zend_async_coroutine_from_object() and the globals destructor come back. The
ext-scheduler-hook bridge calls coroutine_from_object, OBJ_REF and
zend_async_is_enabled().
Reverts 28ef067: active_coroutine_count and
ZEND_ASYNC_ACTIVE_COROUTINE_COUNT are back in the globals for a scheduler to
keep.
Two incompatible changes on one day got the same date; a counter never
repeats. Test 068 simulates a provider built for version 999.
The caller could not find the bytes: the coroutine sits at the head of the
provider's larger struct, and the API has no offset to them. No caller passed
a size and no provider honoured one; TrueAsync's new_coroutine has no such
parameter. API version 2.
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.
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