Repository navigation
Zend async Scheduler ABI - #22561
Open
EdmondDantes wants to merge 64 commits into
Open
Zend async Scheduler ABI#22561EdmondDantes wants to merge 64 commits into
EdmondDantes wants to merge 64 commits into
Conversation
EdmondDantes
marked this pull request as draft
July 2, 2026 17:48
EdmondDantes
force-pushed
the
async-core
branch
4 times, most recently
from
July 2, 2026 18:11
615d050 to
ed9fe03
Compare
EdmondDantes
force-pushed
the
async-core
branch
10 times, most recently
from
July 3, 2026 09:58
f3b272d to
6e1a5ce
Compare
bwoebi
reviewed
Jul 3, 2026
bwoebi
reviewed
Jul 3, 2026
bwoebi
reviewed
Jul 3, 2026
bwoebi
reviewed
Jul 3, 2026
EdmondDantes
force-pushed
the
async-core
branch
5 times, most recently
from
July 16, 2026 08:21
3286ac0 to
9e95fa1
Compare
…scheduler honours
…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
… bailout leaves set
…an their upstream originals
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.