비동기 컨텍스트에서의 가비지 컬렉션

PHP에서 가비지 컬렉터는 일반적으로 동기적으로 동작합니다. 가능한 루트 버퍼가 가득 차면 현재 컨텍스트에서 gc_collect_cycles()가 호출됩니다. GC는 순환 참조를 계산하고 삭제 대상으로 표시된 객체에 대해 루프에서 객체 소멸자를 호출합니다.

동시성 환경에서는 이 모델이 무너집니다. 객체의 소멸자가 await를 호출할 수 있습니다 – 예를 들어, 데이터베이스 연결을 올바르게 닫기 위해서입니다. GC가 코루틴 내부에서 실행 중일 때 await는 해당 코루틴을 일시 중단시키고, GC를 불완전한 상태로 남깁니다. 다른 코루틴들은 부분적으로 수집된 객체를 보게 됩니다.

이러한 이유로 TrueAsync는 가비지 컬렉션 로직을 수정해야 했습니다.

GC 코루틴

gc_possible_root 버퍼가 가득 차고 임계값이 트리거되면, zend_gc_collect_cycles()는 별도의 코루틴에서 자체적으로 실행됩니다.

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;  // GC가 이미 다른 코루틴에서 실행 중
        }

        start_gc_in_coroutine();
        return 0;
    }

    // ... 실제 가비지 컬렉션
}

GC를 트리거한 코루틴은 차단되지 않고 작업을 계속하며, 가비지 컬렉션은 다음 Scheduler 틱에서 수행됩니다.

GC 코루틴은 자체적인 최상위 Scope(parent = NULL)를 갖습니다. 이는 가비지 컬렉션을 사용자 코드로부터 격리합니다: 사용자 Scope를 취소해도 GC에는 영향을 미치지 않습니다.

코루틴에서의 소멸자

주요 문제는 소멸자 호출 시 발생합니다. 소멸자가 예기치 않게 코루틴을 일시 중단시킬 수 있기 때문입니다. 따라서 GC는 마이크로태스크 기반의 동시 반복자 알고리즘을 사용합니다. 반복을 시작하기 위해 GC는 별도의 반복자 코루틴을 생성합니다. 이는 순차적 실행의 환상을 만들어 GC를 상당히 단순화합니다.

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

    // 소멸자를 위한 자식 코루틴 생성
    zend_coroutine_t *coroutine = gc_spawn_destructors_coroutine();

    // GC 코루틴이 dtor_scope에서 일시 중단
    zend_async_resume_when(GC_G(gc_coroutine), &scope->event, ...);
    ZEND_ASYNC_SUSPEND();   // 소멸자가 실행되는 동안 GC는 대기

    return true;
}

소멸자는 Scope 메커니즘을 사용하여 코루틴의 수명을 제어할 뿐만 아니라 완료를 기다리기도 합니다. 이를 위해 모든 소멸자 코루틴을 캡슐화하는 또 다른 자식 Scope가 생성됩니다:

gc_scope                          <- 최상위 `GC`
  \-- GC 코루틴                    <- 마킹 + 조정
       \-- dtor_scope             <- 자식 스코프
            \-- dtor-coroutine[0] <- 소멸자 호출 (HI_PRIORITY)

GC 코루틴은 dtor_scope의 완료 이벤트를 구독합니다. dtor_scope 내의 모든 소멸자가 완료된 경우에만 깨어납니다.

별도 코루틴에서의 가비지 컬렉션

소멸자가 await를 호출하면?

여기서는 마이크로태스크 기반의 고전적인 동시 반복자 알고리즘이 사용됩니다:

반복자는 여전히 같은 코루틴에 있는지 확인합니다:

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);   // 소멸자 호출

        // 코루틴이 변경되었으면 -- 소멸자가 await를 호출한 것
        if (coroutine != NULL && coroutine != *current_coroutine_ptr) {
            return FAILURE;   // 순회 중단
        }
        idx++;
    }
    return SUCCESS;
}

ZEND_ASYNC_CURRENT_COROUTINE가 변경되었다면, 소멸자가 await를 호출하여 현재 코루틴이 대기 상태에 들어갔다는 것을 의미합니다. 이 경우 반복자는 단순히 종료되고, 다음 반복 단계는 새로운 코루틴에서 시작됩니다.