Skip to content

Async core#22561

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

Async core#22561
EdmondDantes wants to merge 29 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/RFC.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 9 times, most recently from 635afa0 to f3b272d Compare July 3, 2026 09:08
The engine gains the ability to activate a concurrent execution mode.
A scheduler - a C extension or a PHP library - registers a set of
hooks through a single call and takes control of concurrency. The core
contains NO implementation: no scheduler, no queues, no event system;
it is hook routing plus the minimal mechanism only the engine can
perform (context switching, the GC destructor walker, the coroutine
lifecycle status).

Core ABI (Zend/zend_async_API.h):

  - zend_coroutine_t: lifecycle status packed into the flags word,
    modifier flags, object_offset for the single-allocation embed
    pattern (or a stored object pointer via OBJ_REF), an awaiting_info
    diagnostics hook. No embedded wait state.
  - scheduler slots registered once per process via a versioned struct:
    new_coroutine, enqueue_coroutine, suspend(from_main, is_bailout),
    resume, cancel, launch, shutdown, get_class_ce, call_on_main_stack,
    context accessors (userland zval keys + internal numeric keys with
    a core-owned key registry), intercept_fiber, gc_destructors, defer.
  - zend_async_microtask_t: a thin one-shot task (handler, dtor, 32-bit
    ref_count, named flag bits incl. is_cancelled). The queue is
    provider-owned; the defer slot only routes the pointer.
  - per-thread globals: state, current/main coroutine, live coroutine
    count, the in_scheduler_context flag, the PHP bridge hook storage.

Engine integration:

  - the scheduler is not lazy: it launches right before the script code
    runs (main.c, phpdbg) and receives the after-main handover; after
    destructors the API deactivates.
  - fibers: intercept_fiber links a fiber to a coroutine (the hook
    returns the coroutine to bind, created by the scheduler, or NULL
    for the legacy low-level path). There is NO switching API: inside
    scheduler code (in_scheduler_context, set around hook invocations)
    the plain Fiber API on a bound fiber performs the direct context
    switch; in application code the same calls park the value and
    route through the hooks. Deferred starts take an owned deep copy
    of their arguments. Fiber::suspend() is unchanged.
  - GC: the destructor phase is an around-interceptor. The engine keeps
    the walker, the once-per-object guarantee and the phase cursor, and
    re-runs missed destructors after the hook as a safety net; the hook
    brackets the run with provider logic (open a completion group, run,
    await everything the destructors spawned, transitively). Each
    destructor executes as application code (the scheduler flag is
    dropped around it). The executor reaches the hook as a Closure over
    an unregistered internal function - unreachable from application
    code.

PHP bridge (Zend/zend_scheduler_hook.c): final class Async\
SchedulerHook - hook-name constants, register(module, hooks) (throws
when a scheduler is already registered), getModule(), defer()
(forwards to the DEFER hook). Hooks are stored by numeric index in the
per-thread globals; each slot is backed by a C thunk forwarding to the
stored callable.

Tests (Zend/tests/async, 13): registration semantics, constants,
launch-at-registration, managed-fiber lifecycle through a plain-Fiber
scheduler loop (start/suspend/resume/finish, throw at the suspension
point, uncaught exception propagation, after-main drain, direct-
inside/routed-outside context semantics), low-level fibers untouched
by a null intercept, microtask forwarding, and a GC cycle whose
destructors spawn managed fibers awaited by the hook. Full async,
fibers and gc suites pass (194/194, debug build - leak-checked).

RFC and integration reference:
https://github.com/true-async/php-async-core-rfc
Comment thread Zend/zend_async_API.c Outdated
Comment thread Zend/zend_scheduler_hook.stub.php Outdated
* when a scheduler is already registered (by a C extension or by an
* earlier PHP call) throws an Error.
*/
public static function register(string $module, array $hooks): bool {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add @param $module to explain what exactly module is supposed to mean? From my understanding simply a name to be returned with SchedulerHook::getModule()?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stub are not usually use for documenting anything so the docs should be actually completely removed - docs should go to RFC instead.

Comment thread Zend/zend_gc.c
zend_hrtime_t dtor_start_time = zend_hrtime();
if (EXPECTED(!EG(active_fiber))) {
gc_call_destructors(GC_FIRST_ROOT, end, NULL);
GC_G(dtor_phase_active) = true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly does this provide over GC_G(active)?

Also isn't this a regression over the current behaviour, which always allows destructor calls?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also isn't this a regression over the current behaviour, which always allows destructor calls?

100% no. As for the flag, I'll take a look at it a bit later.

Comment thread Zend/zend_scheduler_hook.stub.php Outdated
/**
* Activation point for the concurrent mode.
*
* A scheduler is registered by handing register() a map of hook name

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give a rough example/rationale when it's beneficial to have custom userland hooks for the scheduler, rather than using pure fibers (like e.g. revolt does)?
From my uninformed perspective, it only makes sense for extensions to control this at that layer, not so much for userland?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give a rough example/rationale when it's beneficial to have custom userland hooks for the scheduler, rather than using pure fibers (like e.g. revolt does)? From my uninformed perspective, it only makes sense for extensions to control this at that layer, not so much for userland?

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

This can be viewed as a single API group together with the Poll API.

…urrent() in suspend hook (drop engine auto-tracking)
…ne::setCurrent() in suspend hook (drop engine auto-tracking)"

This reverts commit daa6f28.
…ine::setCurrent() in suspend hook (drop engine auto-tracking)"

This reverts commit 337e17e.
…Scheduler; launch(): ?object records current
…tation, not core API (cancel stays a Scheduler hook)
…urn); only the PHP Async\Coroutine API was removed
Async\Continuation::create(callable) mints a symmetric execution context backed
by the engine's fiber machinery; switchTo() jumps directly into it, carrying a
value in and delivering the body's return value — or its thrown exception or
bailout — back to the switcher. No central pump: control goes A -> B directly.

Built on the exported zend_fiber_switch_context; the entry trampoline follows
zend_fiber_execute (VM-stack setup + zend_call_function) and the switch follows
TrueAsync's fiber_switch_context_ex. A finished context is freed by
zend_fiber_switch_context itself, so free_object only tears down one that never
completed.

Tests: Zend/tests/async/continuation_{switch,value_exception,symmetric_nested}.phpt.
…nqueue/resume/cancel into onEnqueue; remove AbstractScheduler; getContext returns object; contextFind drops includeParent; update tests (15/15)
…Continuation + currentCoroutine closures (switchTo stays a Continuation method); +mandate test (16/16)
…main normalisation

- Continuation::current() wraps EG(current_fiber_context); a captured context
  is borrowed (never owned), free_obj does not destroy it
- onLaunch mandate grows to three closures: createContinuation,
  currentContinuation, currentCoroutine; the bridge hands all three
- create_object initialises fci.function_name (fixes a latent free on garbage
  for objects that never went through create())
- tests: signatures updated, mandate test covers the capture, new
  continuation_current_main.phpt switches from a continuation back into the
  captured main flow and back (17/17)
…ing its mandate

Per review feedback:
- SchedulerHook::register(string $module, callable $factory): the factory
  receives the mandate (createContinuation, currentContinuation,
  currentCoroutine) and returns the Async\Scheduler, so the scheduler is
  created in a valid state; the factory runs synchronously inside register(),
  which is the launch moment for a PHP scheduler
- onLaunch is removed from the interface; the launch slot is no longer
  bridged (C-scheduler concern only)
- the engine now learns the current coroutine in exactly one way: the return
  value of onSuspend()
- Closure parameter types are gone from the interface; register() takes
  callable
- tests updated to the factory form; 17/17
- PHP hooks report failure by exception only: onShutdown/onDefer/contextSet
  return void (new call_void bridge path), register() throws instead of
  returning false; bool stays where false is data (onEnqueue, contextUnset)
- Continuation::switchTo(mixed $value = null, ?Throwable $error = null):
  a non-null error is thrown at the target's suspension point; a first entry
  with an error finishes the continuation without starting the body; both
  arguments at once is a ValueError; switching into a finished continuation
  throws Error (+continuation_switchto_error test)
- fix enqueue/resume rejection without a pending exception: throw FiberError
  at the PHP boundary instead of RETURN_THROWS on empty EG(exception)
- port the C-only coroutine switch-handler API (per-coroutine vector +
  main-coroutine start handlers) from the true-async tree
- port the fork API with default deny: ZEND_ASYNC_BEFORE_FORK() throws while
  the Async state is on unless a fork-hook pair is registered; pcntl_fork()
  guarded, child reinitialised via ZEND_ASYNC_AFTER_FORK_CHILD()
- update tests to the new signatures (18/18 async, fibers green)
- new C slot zend_async_wait_info_t (appended to the API bundle) with a
  quiet no-op stub: wait info is diagnostics, not an error without a
  subscriber; ZEND_ASYNC_WAIT_INFO() macro for C callers
- the PHP bridge binds Scheduler::onWaitInfo(object, string): void and
  forwards through the void-hook path
- onWaitInfo added to the Async\Scheduler interface stub and tests
- api_shutdown: also reset gc_destructors/defer/wait_info slots
- onEnqueue doc: state what true means (queued and will run)
…he hooks

- zend_coroutine_t gains a lazily allocated internal_context (numeric keys);
  engine-owned find/set/unset/destroy replace the eight context slots, the
  zend_async_context_t struct and the userland-context C API (deliberate ABI
  break; ZEND_ASYNC_INTERNAL_CONTEXT_* macros now take the coroutine,
  NULL = current)
- ZEND_ASYNC_CURRENT_COROUTINE is now correct for bound fibers:
  zend_fiber_scheduler_switch() sets fiber->coroutine as current while the
  fiber body runs and restores it after
- the bridge keeps one registry (coroutine object handle -> zend_coroutine_t):
  fiber-adopted handles are minted at adoption and released by the fiber
  teardown, continuation handles on first record-current and released at
  unregistration; dispose destroys the internal context and drops the entry
- the Scheduler interface shrinks to the six event hooks: getContext/
  getInternalContext/contextFind/contextSet/contextUnset removed from the
  stub and tests
Minting a coroutine handle installs a C destructor on the coroutine object:
the object's handlers are swapped for a per-class copy whose free_obj
destroys the handle (and the internal context) before the class's own
free_obj runs. The registry holds no reference anymore. Fiber-adopted
handles keep the stronger lifetime: the fiber machinery holds a raw pointer
to the handle, so it pins the object and the fiber teardown drives the
release through extended_dispose.
…ABI slot

A naive FIFO scheduler filling the whole zend_async_scheduler_api_t from a
separate Zend extension (built only with --enable-test-scheduler, activated
by test_scheduler.enable=1), plus a TestScheduler\ userland API so .phpt
tests can drive it. 24 tests green under debug+ZTS+ASan; the first runtime
validation of the async-core engine paths.

Findings folded into the design: a deliberate-wake flag (a dequeued flow
stops scheduling and returns; finish/park hand control to the main loop),
park-to-main on a dry queue instead of returning, and an end-of-main unwind
of still-parked coroutines via zend_create_unwind_exit().
Adapted from ext/async's gc/edge_cases suites: a coroutine reference cycle
through the result (get_gc traversal), suspend() inside a destructor, GC-phase
destructors through the gc_destructors hook, resurrection from a suspending
destructor, spawn and resume from destructors, fiber-before-spawn ordering.
Implements RFC 0.4: engine-owned per-coroutine PHP-visible storage with
string/object keys. Async\Context (find/has/set/unset, set() chains,
unset() returns whether the key existed) and Async\get_context(?object):
null resolves to the current flow, main included, whether or not a
scheduler is registered; the explicit-object form resolves through a new
coroutine-object registry (providers bind/unbind) and is how a scheduler
implements its inheritance policy from outside the coroutine.

The main flow always resolves to one pre-adoption store, so values set
before concurrency starts stay visible after adoption. A fresh coroutine
starts empty: no engine-imposed inheritance. C extensions reach the same
store through zend_async_context_find/set/unset (ZEND_ASYNC_CONTEXT_*);
destroy() joins the internal context in the dispose path.

test_scheduler binds its coroutine objects, disposes contexts, and gains
ctx_* helpers so tests cover the C side; the bridge does the same for its
handles. Tests: engine-level (no scheduler, per-coroutine isolation via
the bridge, unknown-object ValueError) and test_scheduler (isolation,
explicit handle, C<->PHP visibility, lifetime and object-key ownership).
The first onSuspend call that finds no current coroutine is the main
flow's normalisation (the only flow that can yield without a coroutine),
so the coroutine it reports back is main: the bridge flags it, records
ZEND_ASYNC_MAIN_COROUTINE and fires the main-coroutine start handlers,
which the PHP-scheduler path never did. Main's userland context now
survives adoption under a PHP scheduler too; handlers_reset clears the
main record with the rest.
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