Ramasse-miettes en contexte asynchrone

En PHP, le ramasse-miettes fonctionne normalement de maniere synchrone. Lorsque le tampon de racines possibles est plein, gc_collect_cycles() est appele dans le contexte courant. Le GC calcule les references circulaires et appelle les destructeurs d’objets dans une boucle pour les objets marques pour suppression.

Dans un environnement concurrent, ce modele ne fonctionne plus. Le destructeur d’un objet peut appeler await – par exemple, pour fermer correctement une connexion a la base de donnees. Si le GC s’execute dans une coroutine, await suspendra cette coroutine, laissant le GC dans un etat incomplet. Les autres coroutines verront des objets partiellement collectes.

C’est pourquoi TrueAsync a du modifier la logique du ramasse-miettes.

Coroutine GC

Lorsque le tampon gc_possible_root se remplit et que le seuil est atteint, zend_gc_collect_cycles() se lance dans une coroutine separee.

ZEND_API int zend_gc_collect_cycles(void)
{
    if (UNEXPECTED(ZEND_ASYNC_IS_ACTIVE
        && ZEND_ASYNC_CURRENT_COROUTINE != GC_G(gc_coroutine))) {

        if (GC_G(gc_coroutine)) {
            return 0;  // Le GC est deja en cours dans une autre coroutine
        }

        start_gc_in_coroutine();
        return 0;
    }

    // ... collecte effective des dechets
}

La coroutine qui a declenche le GC n’est pas bloquee et continue son travail, tandis que la collecte des dechets s’effectue au prochain tick du Scheduler.

La coroutine GC obtient son propre Scope de niveau superieur (parent = NULL). Cela isole la collecte des dechets du code utilisateur : l’annulation d’un Scope utilisateur n’affectera pas le GC.

Destructeurs dans les coroutines

Le principal probleme survient specifiquement lors de l’appel des destructeurs, car les destructeurs peuvent suspendre une coroutine de maniere inattendue. C’est pourquoi le GC utilise un algorithme d’iterateur concurrent base sur les microtaches. Pour lancer l’iteration, le GC cree une autre coroutine d’iteration. Ceci est fait pour creer l’illusion d’une execution sequentielle, ce qui simplifie considerablement le GC.

static bool gc_call_destructors_in_coroutine(void)
{
    GC_G(dtor_idx) = GC_FIRST_ROOT;
    GC_G(dtor_end) = GC_G(first_unused);

    // Creer une coroutine enfant pour les destructeurs
    zend_coroutine_t *coroutine = gc_spawn_destructors_coroutine();

    // La coroutine GC se suspend sur dtor_scope
    zend_async_resume_when(GC_G(gc_coroutine), &scope->event, ...);
    ZEND_ASYNC_SUSPEND();   // Le GC dort pendant que les destructeurs s'executent

    return true;
}

Le destructeur utilise le mecanisme de Scope non seulement pour controler la duree de vie des coroutines, mais aussi pour attendre leur achevement. A cette fin, un autre Scope enfant est cree pour encapsuler toutes les coroutines de destructeurs :

gc_scope                          <- `GC` de niveau superieur
  \-- Coroutine GC                <- marquage + coordination
       \-- dtor_scope             <- portee enfant
            \-- dtor-coroutine[0] <- appel des destructeurs (HI_PRIORITY)

La coroutine GC s’abonne a l’evenement d’achevement de dtor_scope. Elle ne se reveillera que lorsque tous les destructeurs dans dtor_scope seront termines.

Ramasse-miettes dans une coroutine separee

Que se passe-t-il si un destructeur appelle await ?

Ici, l’algorithme classique d’iterateur concurrent base sur les microtaches est utilise :

L’iterateur verifie s’il est toujours dans la meme coroutine :

static zend_result gc_call_destructors(uint32_t idx, uint32_t end, ...)
{
    zend_coroutine_t *coroutine = ZEND_ASYNC_CURRENT_COROUTINE;

    while (idx != end) {
        obj->handlers->dtor_obj(obj);   // appel du destructeur

        // Si la coroutine a change -- le destructeur a appele await
        if (coroutine != NULL && coroutine != *current_coroutine_ptr) {
            return FAILURE;   // abandonner le parcours
        }
        idx++;
    }
    return SUCCESS;
}

Si ZEND_ASYNC_CURRENT_COROUTINE a change, cela signifie que le destructeur a appele await et que la coroutine courante s’est endormie. Dans ce cas, l’iterateur se termine simplement, et la prochaine etape d’iteration sera lancee dans une nouvelle coroutine.