Skip to content

True async api stable #19142

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 177 commits into
base: master
Choose a base branch
from
Open

Conversation

EdmondDantes
Copy link

TrueAsync engine API

The TrueAsync engine API defines a pluggable interface that lets extensions register different async backends while the core supplies standardized primitives.

Key Components

  • Events: Low‑level representation of sockets, timers and other readiness sources
  • Coroutines: Stackful tasks
  • Scopes: Hierarchical lifetime management enabling grouped cancellation
  • Wakers: Event‑completion handlers that resume suspended coroutines

PR for https://wiki.php.net/rfc/true_async_engine_api

EdmondDantes and others added 30 commits May 24, 2025 13:49
Initial version of the asynchronous API for PHP. Includes only the API itself and the necessary core changes required for the API to function.
…ized into a method for retrieving any ClassEntry.

  Now, using this API function, you can obtain the required class entry by a type descriptor type.
Added API functions for coroutine context management:
- zend_async_context_set_t: set value by string or object key
- zend_async_context_get_t: get value by string or object key
- zend_async_context_has_t: check if key exists
- zend_async_context_delete_t: delete key-value pair

Includes convenience macros for easier usage:
ZEND_ASYNC_CONTEXT_SET_STR/OBJ, GET_STR/OBJ, HAS_STR/OBJ, DELETE_STR/OBJ
Added complete Context API implementation:
- Updated zend_async_scheduler_register signature to accept context functions
- Added context function pointers and registration in zend_async_API.c
- Context API supports both string and object keys
- Includes convenience macros for easy usage

This completes the core API infrastructure for coroutine context management.
- Remove separate context_set, context_get, context_has, context_delete functions
- Add single zend_async_new_context_fn function to create context instances
- Move context implementation to ext/async module using zend_async_context_t structure
- Update scheduler registration to include new_context_fn parameter
- Add context field to zend_async_globals_t and zend_coroutine_s
- Simplify Context API macros to ZEND_ASYNC_NEW_CONTEXT and ZEND_ASYNC_CURRENT_CONTEXT
% removal of the current Scope from the global structure
… always needs to be explicitly known. Macros like ZEND_ASYNC_CURRENT_SCOPE were updated.

  A new macro ZEND_ASYNC_MAIN_SCOPE was added for the main Scope.
…main coroutine can correctly return control.
* add macro START_REACTOR_OR_RETURN for reactor autostart
… is now passed to the main coroutine’s finalize method instead of being displayed on screen.

* Fixed an issue with correctly passing the exception into the coroutine.
… captures the exception, marking it as handled in another coroutine.
Comment on lines +312 to +326
ZEND_API bool zend_async_reactor_register(char *module, bool allow_override,
zend_async_reactor_startup_t reactor_startup_fn,
zend_async_reactor_shutdown_t reactor_shutdown_fn,
zend_async_reactor_execute_t reactor_execute_fn,
zend_async_reactor_loop_alive_t reactor_loop_alive_fn,
zend_async_new_socket_event_t new_socket_event_fn,
zend_async_new_poll_event_t new_poll_event_fn,
zend_async_new_timer_event_t new_timer_event_fn,
zend_async_new_signal_event_t new_signal_event_fn,
zend_async_new_process_event_t new_process_event_fn,
zend_async_new_thread_event_t new_thread_event_fn,
zend_async_new_filesystem_event_t new_filesystem_event_fn,
zend_async_getnameinfo_t getnameinfo_fn, zend_async_getaddrinfo_t getaddrinfo_fn,
zend_async_freeaddrinfo_t freeaddrinfo_fn, zend_async_new_exec_event_t new_exec_event_fn,
zend_async_exec_t exec_fn, zend_async_new_trigger_event_t new_trigger_event_fn)

Choose a reason for hiding this comment

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

Might make sense to put this into a struct so it can be extended without necessarily breaking the api.

Copy link
Member

@arnaud-lb arnaud-lb left a comment

Choose a reason for hiding this comment

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

I did a partial review so far. I have at least one important comment, but otherwise this looks great. I hope that True Async eventually passes.

However I'm not convinced that this should go into core yet, for the following reasons:

  • The C API is large and reflects the same concepts and behaviors as the userland API, while ext/async is the backend. So further discussions on the userland APIs will necessitate changes in the C API as well.
  • If we iterate on the API in the 8.5 branch, this will create incompatibilities between different versions of the extension and different PHP releases. People will have a hard time testing the extension, which is the main goal of merging the API into core early. If we don't iterate in the 8.5 branch, then we don't need to merge the C API into 8.5.
  • There are no tests, so this code will eventually break during maintenance of 8.5 and master.
  • There are actually very few changes in core, so it seems that we could add a few extension points in core, and move most of this code to the extension. Then it will be easier to iterate, and for people to test. These extension points could take the form of function pointers. For example we could make shutdown_destructors a function pointer.
  • I understand that we don't expect other extensions to try to integrate with the API. Therefore the API can be in ext/async, and we don't need this amount of decoupling and indirection.

Comment on lines +166 to +167
static bool php_output_main_coroutine_start_handler(
zend_coroutine_t *coroutine, bool is_enter, bool is_finishing)
Copy link
Member

Choose a reason for hiding this comment

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

From this example alone I don't fully understand the use-case. The code is called exactly once in the lifetime of the coroutine, seemingly for one specific coroutine. It feels like it could be executed at the spawn site.

Comment on lines +151 to +161
typedef struct {
zend_async_timer_event_t event;
uv_timer_t uv_handle;
} async_timer_event_t;
// Initialize callbacks for the event
event->event.base.add_callback = libuv_add_callback;
event->event.base.del_callback = libuv_remove_callback;
event->event.base.start = libuv_timer_start;
event->event.base.stop = libuv_timer_stop;
event->event.base.dispose = libuv_timer_dispose;
Copy link
Member

Choose a reason for hiding this comment

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

I was confused at first, thinking these were libuv functions. Prefixing global names with zend_, php_, or something else, make the origin of the symbol less ambiguous, and also avoid clashes. As these are defined in ext/async, I would suggest php_async_: php_async_libuv_add_callback. We usually prefix local (static) symbols too, sometimes with a shorter prefix (e.g. phpa_ for php async).

Every extended event defines its own ``start``, ``stop`` and ``dispose`` functions. The dispose
handler must release all resources associated with the event and is called when the reference count
reaches ``1``. It is common to stop the event first and then close the underlying libuv handle so
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
reaches ``1``. It is common to stop the event first and then close the underlying libuv handle so
is ``1``. It is common to stop the event first and then close the underlying libuv handle so

or

Suggested change
reaches ``1``. It is common to stop the event first and then close the underlying libuv handle so
reaches ``0``. It is common to stop the event first and then close the underlying libuv handle so

@@ -1929,6 +1930,8 @@ void php_request_shutdown(void *dummy)
zend_call_destructors();
} zend_end_try();

ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN();
Copy link
Member

Choose a reason for hiding this comment

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

This is worth a comment to explain that this is called to handle any task created by shutdown functions or destructors, and to handle destructors that didn't return yet.

Possibly this should be executed between php_call_shutdown_functions() and zend_call_destructors() as well, so that destructors are not called before tasks created by php_call_shutdown_functions() are executed.

Comment on lines +1058 to +1062
uint32_t zend_async_internal_context_key_alloc(const char *key_name)
{
#ifdef ZTS
tsrm_mutex_lock(zend_async_context_mutex);
#endif
Copy link
Member

Choose a reason for hiding this comment

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

It would make sense to allow usage of zend_async_internal_context_key_alloc() only during the startup phase, so that no mutex is needed.

Comment on lines +614 to +624
if (callback->ref_count > 1) {
// If the callback is still referenced, we cannot dispose it yet
callback->ref_count--;
return;
} else if (UNEXPECTED(callback->ref_count == 0)) {
// Circular free from destructor
return;
} else {
ZEND_ASSERT(callback->ref_count > 0
&& "Callback ref_count must be greater than 0. Memory corruption detected.");
}
Copy link
Member

Choose a reason for hiding this comment

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

This seems inconsistent with zend_async_event_t.dispose and could lead to confusion:

  • In zend_async_event_t.dispose is supposed to be called only when the event is not referenced anymore. It frees the event unconditionally. (According to docs.)
  • In zend_async_event_callback_t.dispose (or at least this implementation) manages the refcount and frees the callback only when it reaches zero.


if (event != NULL && waker != NULL) {
// Find the trigger for this event
zval *trigger_zval = zend_hash_index_find(&waker->events, (zend_ulong) event);
Copy link
Member

Choose a reason for hiding this comment

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

When using pointers as hash key, consider using rotr3() (Because zend_hash doesn't hash numeric indices, it only masks them. This leads to collisions as the lower 3 bits are usually zero on pointers.)


if (event != NULL && waker != NULL) {
// Find the trigger for this event
zval *trigger_zval = zend_hash_index_find(&waker->events, (zend_ulong) event);
Copy link
Member

Choose a reason for hiding this comment

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

Nit:

Suggested change
zval *trigger_zval = zend_hash_index_find(&waker->events, (zend_ulong) event);
zend_async_waker_trigger_t *trigger = zend_hash_index_find_ptr(&waker->events, (zend_ulong) event);

@@ -274,6 +305,11 @@ typedef struct _zend_gc_globals {
uint32_t dtor_end;
zend_fiber *dtor_fiber;
bool dtor_fiber_running;
gc_async_context_t async_context; /* async context for gc */
gc_stack *gc_stack; /* local mark/scan stack */
zend_coroutine_t *dtor_coroutine;
Copy link
Member

Choose a reason for hiding this comment

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

I suggest renaming to gc_coroutine if the entire GC runs in it, not only destructors

Comment on lines 2118 to +2126
ZEND_API int zend_gc_collect_cycles(void)
{
int total_count = 0;
bool should_rerun_gc = 0;
bool did_rerun_gc = 0;
if (UNEXPECTED(ZEND_ASYNC_IS_ACTIVE && ZEND_ASYNC_CURRENT_COROUTINE != GC_G(dtor_coroutine))) {

if (GC_G(dtor_coroutine)) {
return 0;
}

start_gc_in_coroutine();
Copy link
Member

@arnaud-lb arnaud-lb Aug 1, 2025

Choose a reason for hiding this comment

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

The new behavior seems complex. Let me know if I understand it correctly:

  • zend_gc_collect_cycles() is now asynchronous when ZEND_ASYNC_IS_ACTIVE. It creates a coroutine (GC_G(dtor_coroutine)) and returns. (This implies that the userland function gc_collect_cycles() is async as well and always return 0.)
  • The coroutine re-enters zend_gc_collect_cycles(), but this time it doesn't create a coroutine as it detects that it's running in GC_G(dtor_coroutine) already
  • The coroutine creates a microtask that re-creates the coroutine in case the first one suspends. This re-enters zend_gc_collect_cycles() again.
  • Some of zend_gc_collect_cycles local variables are moved to GC_G() because they are shared between multiple invocations

I understand the intent is that if a destructor suspends, we continue in an other coroutine.

I'm not entirely sure of the priority that is given to the GC: Are coroutines added to the front or back of the queue? (I don't see where it is enqueued.) Does the microtask execute immediately after the coroutine is suspended?

I think it's not necessary that the entire GC runs in a coroutine: the scanning part is inherently synchronous, non-reentrant, must not be interrupted, and does not call any userland code; so it's not necessary and it also does not improve concurrency. It is enough that just the "Actually call destructors" part runs in a coroutine, like we do for fibers.

Can this be simplfied like this?

  • Run zend_gc_collect_cycles() synchronously, outside of a coroutine, as usual
  • Invoke destructors from a coroutine if necessary, and call the rest of the destructors in another one in case a destructor suspends
  • This process should not allow zend_gc_collect_cycles() to return before all destructors have been invoked, so it's not necessary to move its local variables to GC_G(). (Returning before all destructors have returned is fine.)

@bukka
Copy link
Member

bukka commented Aug 1, 2025

However I'm not convinced that this should go into core yet,

It cannot go into 8.5 in any case as the RFC vote has not started yet and the deadline for the start has already passed.

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.

8 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy