This document explains FrankenPHP’s internal architecture, focusing on thread management, the state machine, and the CGO boundary between Go and C/PHP.
FrankenPHP embeds the PHP interpreter directly into Go via CGO. Each PHP execution runs on a real POSIX thread (not a goroutine) because PHP’s ZTS (Zend Thread Safety) model requires it. Go orchestrates these threads through a state machine, while C handles the PHP SAPI lifecycle.
The main layers are:
*.go files such as frankenphp.go, phpthread.go, thread*.go, scaling.go): Thread pool management, request routing, auto-scalingfrankenphp.c, frankenphp.h): PHP SAPI implementation, script execution loop, superglobal managementinternal/state/): Synchronization between Go goroutines and C threadsphpmainthread.go)The main PHP thread (phpMainThread) initializes the PHP runtime:
php.ini overridesmain_thread_env) for sandboxingIt stays alive for the lifetime of the server. All other threads are started after it signals Ready.
threadregular.go)Handle classic one-request-per-invocation PHP scripts. Each request:
requestChan or the shared regularRequestChanbeforeScriptExecution()afterScriptExecution() closes the request contextthreadworker.go)Keep a PHP script alive across multiple requests. The PHP script calls frankenphp_handle_request() in a loop:
beforeScriptExecution() returns the worker script filenamefrankenphp_handle_request(), which calls waitForWorkerRequest() in Gogo_frankenphp_finish_worker_request() cleans up the request contextAfter the script exits, the worker is restarted immediately if it had reached frankenphp_handle_request() at least once (whether the exit was clean or the result of a fatal error). Exponential backoff is only applied to consecutive startup failures, where the script exits before ever reaching frankenphp_handle_request().
Each thread has a ThreadState (defined in internal/state/state.go) that governs its lifecycle. The state machine uses a sync.RWMutex for all state transitions and a channel-based subscriber pattern for blocking waits.
Lifecycle: Reserved → BootRequested → Booting → Inactive → Ready ⇄ (processing)
↓
Shutdown: ShuttingDown → Done → Reserved
↑
Restart (admin/watcher): Restarting → Yielding → Ready
↑
ZTS reboot (max_requests): Rebooting → RebootReady → Ready
↑
Handler transition: TransitionRequested → TransitionInProgress → TransitionComplete
The full set of states is defined in internal/state/state.go:
| State | Description |
|---|---|
Reserved | Thread slot allocated but not booted. Can be booted on demand. |
BootRequested | Boot has been queued (e.g., by the main thread) but the POSIX thread hasn’t started. |
Booting | Underlying POSIX thread is starting up. |
Inactive | Thread is alive but has no handler assigned. Minimal memory footprint. |
Ready | Thread has a handler and is ready to accept work. |
ShuttingDown | Thread is shutting down. |
Done | Thread has completely shut down. Transitions back to Reserved for potential reuse. |
Restarting | Worker thread is being restarted (e.g., via admin API or file watcher). |
Yielding | Worker thread has yielded control and is waiting to be re-activated. |
Rebooting | Worker thread is exiting the C loop for a full ZTS restart (e.g., max_requests). |
RebootReady | The C thread has exited and ZTS state is cleaned up, ready to spawn a new C thread. |
TransitionRequested | A handler change has been requested from the Go side. |
TransitionInProgress | The C thread has acknowledged the transition request. |
TransitionComplete | The Go side has installed the new handler. |
RequestSafeStateChange(nextState): The primary way external goroutines request state changes. It:
Ready or Inactive (under mutex)false immediately from ShuttingDown, Done, or ReservedReady, Inactive, or ShuttingDownThis guarantees mutual exclusion: only one of shutdown(), setHandler(), or drainWorkerThreads() can succeed at a time on a given thread.
WaitFor(states...): Blocks until the thread reaches one of the specified states. Uses a channel-based subscriber pattern so waiters are efficiently notified.
Set(nextState): Unconditional state change. Used by the thread itself (from C callbacks) to signal state transitions.
CompareAndSwap(compareTo, swapTo): Atomic compare-and-swap. Used for boot initialization.
When a thread needs to change its handler (e.g., from inactive to worker):
Go side (setHandler) C side (PHP thread)
───────────────── ─────────────────
RequestSafeStateChange(
TransitionRequested)
close(drainChan)
detects drain
Set(TransitionInProgress)
WaitFor(TransitionInProgress)
→ unblocked WaitFor(TransitionComplete)
handler = newHandler
drainChan = make(chan struct{})
Set(TransitionComplete)
→ unblocked
newHandler.beforeScriptExecution()
This protocol ensures the handler pointer is never read and written concurrently.
When workers are restarted (e.g., via admin API):
Go side (RestartWorkers) C side (worker thread)
───────────────── ─────────────────
RequestSafeStateChange(
Restarting)
close(drainChan)
detects drain in waitForWorkerRequest()
returns false → PHP script exits
beforeScriptExecution():
state is Restarting →
Set(Yielding)
WaitFor(Yielding)
→ unblocked WaitFor(Ready, ShuttingDown)
drainChan = make(chan struct{})
Set(Ready)
→ unblocked
beforeScriptExecution() recurse:
state is Ready → normal execution
C code calls Go functions via CGO exports. The main callbacks are:
| Function | Called when |
|---|---|
go_frankenphp_before_script_execution | C loop needs the next script to execute |
go_frankenphp_after_script_execution | PHP script has finished executing |
go_frankenphp_worker_handle_request_start | Worker’s frankenphp_handle_request() is called |
go_frankenphp_finish_worker_request | Worker request handler has returned |
go_ub_write | PHP produces output (echo, etc.) |
go_read_post | PHP reads POST body (php://input) |
go_read_cookies | PHP reads cookies |
go_write_headers | PHP sends response headers |
go_sapi_flush | PHP flushes output |
go_log_attrs | PHP logs a structured message |
All these functions receive a threadIndex parameter identifying the calling thread. This is a thread-local variable in C (__thread uintptr_t thread_index) set during thread initialization.
Each PHP thread runs php_thread() in frankenphp.c:
while ((scriptName = go_frankenphp_before_script_execution(thread_index))) {
php_request_startup();
php_execute_script(&file_handle);
php_request_shutdown();
go_frankenphp_after_script_execution(thread_index, exit_status);
}
Bailouts (fatal PHP errors) are caught by zend_catch, which marks the thread as unhealthy and forces cleanup.
C.CString() allocates with malloc(). The C side is responsible for freeing (e.g., frankenphp_free_request_context() frees cookie data).phpThread (in phpthread.go) embeds Go’s runtime.Pinner. thread.Pin() / thread.Unpin() keep Go memory referenced from C alive without copying it. The thread is unpinned after each script execution.emalloc/efree). Automatically freed at request shutdown.FrankenPHP can automatically scale the number of PHP threads based on demand (scaling.go).
num_threads: Initial number of threads started at bootmax_threads: Maximum number of threads allowed (includes auto-scaled)A dedicated goroutine reads from an unbuffered scaleChan:
scaleChanA separate goroutine periodically checks (every 5s) for idle auto-scaled threads. Threads in Ready state idle longer than maxIdleTime (default 5s) are converted to Inactive (up to 10 per cycle). They are not fully stopped: a code path exists for that, but it is currently disabled because some PECL extensions leak memory and prevent threads from cleanly shutting down.
FrankenPHP sandboxes environment variables per-thread:
os.Environ() into main_thread_env (a PHP HashTable).$_SERVER is built from a copy of main_thread_env plus request-specific variables (in frankenphp_register_server_vars). It is rebuilt for every request, including each iteration of a worker script.$_ENV is populated from the same snapshot through PHP’s php_import_environment_variables hook. In regular mode this happens once per script execution; in worker mode it happens once when the worker script starts and is not rebuilt between worker requests, which is why writes to $_ENV leak across requests (see Worker Mode).frankenphp_putenv() / frankenphp_getenv() operate on a thread-local sandboxed_env initialized lazily from main_thread_env, preventing race conditions on the global C environment.reset_sandboxed_environment() releases sandboxed_env after each PHP script execution. In regular mode that’s per request; in worker mode it only runs when the worker script itself exits, so putenv() writes are visible to subsequent worker requests on the same thread until the script restarts.frankenPHPContext is created with the request and script inforequestChanbeforeScriptExecution() returns the script filenamego_ub_write, go_read_post, etc.)afterScriptExecution() signals completionfrankenPHPContext is createdrequestChan or a specific thread’s requestChanwaitForWorkerRequest() receives itfrankenphp_handle_request() callback is invokedgo_frankenphp_finish_worker_request() cleans upwaitForWorkerRequest()