Threads and parking

Threads and parking

A collection needs every thread stopped at a known point. A thread blocked inside a syscall cannot reach one, so it must declare itself parked first.

The rule

Every blocking call made from an attached thread must be bracketed.

pgc_begin_blocking();
result = recv(fd, buffer, len, 0);
pgc_end_blocking();

Or with the macro:

PGC_BLOCKING(result = recv(fd, buffer, len, 0));

That covers every mutex acquisition, condition wait, socket read or write, accept, sleep, and process wait.

pgc_begin_blocking says "I hold no live managed pointers I will touch while blocked, so scan my last safepoint and carry on." pgc_end_blocking waits out any collection in progress before the thread resumes.

What happens without it

The collector loops waiting for every thread to park. A thread sitting in recv never parks. The entire process hangs on the next allocation that triggers a collection, with no diagnostic. It looks like a deadlock, because it is one.

This is the single most common way to break a working program with a small change, and it is worth checking any time a new blocking call appears in C.

Attaching

A thread that touches managed memory must attach before its first managed access and detach when done, from inside itself, near the top of its entry function:

static void *worker(void *arg) {
    pgc_thread_attach();
    /* ... managed work ... */
    pgc_thread_detach();
    return NULL;
}

Attaching records the thread so the collector can stop it and scan its stack for roots. Detaching matters as much: a thread that never detaches leaves a permanently reserved region that the collector cannot compact past.

If you spawn threads through std::threads, all of this is handled for you.

The ordering rules

Two constraints that are easy to violate and hard to diagnose.

No GC operation while parked. Creating a handle, allocating, or touching managed memory between pgc_begin_blocking and pgc_end_blocking is invalid. Prepare first, then park:

pgc_handle h = pgc_handle_create(item);   /* before parking */

pgc_begin_blocking();
peko_mutex_lock(&lock);
pgc_end_blocking();                       /* unpark while holding the lock */

No GC operation while holding a lock. Another thread may be parked waiting on that lock, so a collection triggered under it cannot complete.

Note the idiom above: park to acquire, then unpark immediately, so the thread runs unparked while it holds the lock and does managed work.

Callbacks from a parked loop

A native event loop is normally parked for its whole run. When it calls back into PekoScript, that callback will allocate, which a parked thread must not do. So the trampoline unparks around the call and re-resolves the closure through its handle:

static void trampoline(const char *req, void *arg) {
    binding *b = (binding *)arg;

    pgc_end_blocking();
    void *ctx = pgc_handle_get(b->ctx);   /* may have moved */
    char *result = b->fn(ctx, req);
    pgc_begin_blocking();
}

That shape is the template for any C library that calls you back from a blocking loop.