Skip to content

Zend async Scheduler ABI - #22561

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

EdmondDantes wants to merge 56 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
The error stayed in EG(exception) across the switch and reached the coroutine entered: a destructor's await saw it and the coroutine it awaited finished unrun. The driving coroutine now finishes the pass itself when it resumes, as when no iterator could be created.
…uld not create

The enqueue error stayed in the GC coroutine, which then ended with an
exception no caller handles (a provider may end the request on it), and
the run gave up its rerun. Clear it, as a missing iterator leaves none:
the run is redone with the destructors called.
A fatal error, exit() or an uncaught exception in a destructor during
shutdown bails out; shutdown_destructors() caught it, marked every
object destructed and went on as if the pass had ended. The scheduler
then ran the coroutines queued before it, with every object marked
destructed, and in the iterator coroutine the pass went on to resume
the main parked in another destructor.

shutdown_destructors() now re-raises the bailout after its cleanup. In
the iterator coroutine it ends the coroutine as a bailout, so the
scheduler stops the queue as for a fatal error in a coroutine. On the
main path zend_call_destructors() catches it and returns true, and
php_request_shutdown() passes that to the scheduler's last hand-over.

The catch also drops the pass owner, so the iterator ended by the
bailout no longer warns at its release that it did not finish.

061 loses the deadlock and that warning: the main parked in
ParksForever was left waiting only because the iterator swallowed the
fatal error. 083
loses the second DeadlockError the final pass printed after the
destructor's fatal error: that pass no longer runs, and the first fatal
error already names the deadlock.
…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.
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