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 layer (
frankenphp.go,phpthread.go,threadworker.go,threadregular.go,scaling.go): Thread pool management, request routing, auto-scaling - C layer (
frankenphp.c): PHP SAPI implementation, script execution loop, superglobal management - State machine (
internal/state/): Synchronization between Go goroutines and C threads
The main PHP thread (phpMainThread) initializes the PHP runtime:
- Applies
php.inioverrides - Takes a snapshot of the environment (
main_thread_env) for sandboxing - Starts the PHP SAPI module
- Signals readiness to the Go side
It stays alive for the lifetime of the server. All other threads are started after it signals Ready.
Handle classic one-request-per-invocation PHP scripts. Each request:
- Receives a request via
requestChanor the sharedregularRequestChan - Returns the script filename from
beforeScriptExecution() - The C layer executes the PHP script
afterScriptExecution()closes the request context
Keep a PHP script alive across multiple requests. The PHP script calls frankenphp_handle_request() in a loop:
beforeScriptExecution()returns the worker script filename- The C layer starts executing the PHP script
- The PHP script calls
frankenphp_handle_request(), which callswaitForWorkerRequest()in Go - Go blocks until a request arrives, then sets up the request context
- The PHP callback handles the request
go_frankenphp_finish_worker_request()cleans up the request context- The PHP script loops back to step 3
After 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:
- Atomically succeeds from
ReadyorInactive(under mutex) - Returns
falseimmediately fromShuttingDown,Done, orReserved - Blocks and retries from any other state, waiting for
Ready,Inactive, orShuttingDown
This 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.
- Go → C strings:
C.CString()allocates withmalloc(). The C side is responsible for freeing (e.g.,frankenphp_free_request_context()frees cookie data). - Go string pinning:
thread.Pin()/thread.Unpin()pins Go memory so C can safely reference it during script execution without copying. Unpinned after each script execution. - PHP memory: Managed by Zend's memory manager (
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 single goroutine (startUpscalingThreads) reads from an unbuffered scaleChan:
- A request handler can't find an available thread
- It sends the request context to
scaleChan - The scaling goroutine checks:
- Has the request been stalled long enough? (minimum 5ms)
- Is CPU usage below the threshold? (80%)
- Is the thread limit reached?
- If all checks pass, a new thread is booted and assigned
A separate goroutine (startDownScalingThreads) periodically checks (every 5s) for idle auto-scaled threads. Threads in Ready state idle longer than maxIdleTime (default 5s) are converted to Inactive via convertToInactiveThread() (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:
- At startup, the main thread snapshots
os.Environ()intomain_thread_env(a PHPHashTable) - For each request,
$_SERVERis built from a copy ofmain_thread_envplus request-specific variables (infrankenphp_register_server_vars);$_ENVis populated from the same snapshot through PHP'sphp_import_environment_variableshook frankenphp_putenv()/frankenphp_getenv()operate on a thread-localsandboxed_envinitialized lazily frommain_thread_env, preventing race conditions on the global C environment- After each script execution,
reset_sandboxed_environment()releasessandboxed_env; the next call re-initializes it frommain_thread_env
- HTTP request arrives at Caddy
- FrankenPHP's Caddy module resolves the PHP script path
- A
frankenPHPContextis created with the request and script info - The context is sent to an available regular thread via
requestChan - The thread's
beforeScriptExecution()returns the script filename - The C layer executes the PHP script
- During execution, Go callbacks handle I/O (
go_ub_write,go_read_post, etc.) - After execution,
afterScriptExecution()signals completion - The response is sent to the client
- HTTP request arrives at Caddy
- FrankenPHP's Caddy module resolves the worker for this request
- A
frankenPHPContextis created - The context is sent to the worker's
requestChanor a specific thread'srequestChan - The worker thread's
waitForWorkerRequest()receives it - PHP's
frankenphp_handle_request()callback is invoked - After the callback returns,
go_frankenphp_finish_worker_request()cleans up - The worker loops back to
waitForWorkerRequest()