From 7032cb839141388df4475e80acc3c61b246a897e Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 20 Sep 2026 08:16:38 +0200 Subject: [PATCH 1/2] feat: num_threads counts the threads left for regular requests num_threads is a total today and worker threads are part of it, so sizing a server means subtracting them by hand: give a worker four more threads and four fewer are left for the requests no worker serves. Nothing says so either, the docs describe num_threads as "the number of PHP threads to start" and only an error message mentions the subtraction. It now counts the threads for those requests, and the worker threads come on top of it. max_threads keeps its meaning, the limit on everything the process runs, which is what the memory heuristic of auto bounds, and every worker keeps its own num and max_threads. Nothing is added to the configuration and the "num_threads must be greater than the number of worker threads" error goes away with the arithmetic that caused it. Defaults are unchanged: without num_threads a server still starts what is left of 2x the CPUs once the workers have their threads, one at the very least. A configuration that sets num_threads with workers declared starts more threads than before, up to max_threads where it is set; the startup line reports total_threads and worker_threads so the totals are visible. --- caddy/admin_test.go | 9 ++++++--- caddy/app.go | 4 ++-- docs/config.md | 4 ++-- docs/performance.md | 7 +++++-- frankenphp.go | 19 ++++++++++--------- options.go | 5 ++++- phpmainthread_test.go | 14 ++++++++------ scaling_test.go | 3 ++- 8 files changed, 39 insertions(+), 26 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index b5fe37daae..3e55f9b3ec 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -61,7 +61,8 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { http_port `+testPort+` frankenphp { - num_threads 3 + # one thread for regular requests, one per worker + num_threads 1 max_threads 6 worker ../testdata/worker-with-counter.php 1 worker ../testdata/index.php 1 @@ -140,7 +141,8 @@ func TestAutoScaleWorkerThreads(t *testing.T) { frankenphp { max_threads 10 - num_threads 2 + # one thread for regular requests, one for the worker + num_threads 1 worker ../testdata/sleep.php { num 1 max_threads 3 @@ -372,7 +374,8 @@ func TestRegisteredModuleWorkerPoolsMustBeCorrect(t *testing.T) { admin localhost:2999 frankenphp { - num_threads 4 + # one thread for regular requests, one per worker + num_threads 1 worker ../testdata/worker-with-env.php 1 } } diff --git a/caddy/app.go b/caddy/app.go index fcee129180..bce55c4822 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -45,9 +45,9 @@ func RegisterWorkers(name, fileName string, num int, wo ...frankenphp.WorkerOpti // } // } type FrankenPHPApp struct { - // NumThreads sets the number of PHP threads to start. Default: 2x the number of available CPUs. + // NumThreads sets the number of PHP threads to start for the requests no worker serves, worker threads coming on top of it. Default: what is left of 2x the number of available CPUs. NumThreads int `json:"num_threads,omitempty"` - // MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads + // MaxThreads limits how many threads may run at once, workers included. Default: the threads started at boot MaxThreads int `json:"max_threads,omitempty"` // Workers configures the worker scripts to start Workers []workerConfig `json:"workers,omitempty"` diff --git a/docs/config.md b/docs/config.md index 281f05dc75..8388622089 100644 --- a/docs/config.md +++ b/docs/config.md @@ -98,8 +98,8 @@ You can also explicitly configure FrankenPHP using the [global option](https://c ```caddyfile { frankenphp { - num_threads # Sets the number of PHP threads to start. Default: 2x the number of available CPUs. - max_threads # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'. + num_threads # Sets the number of PHP threads to start for the requests no worker serves, worker threads coming on top of it. Default: what is left of 2x the number of available CPUs once the workers have their threads. + max_threads # Limits how many PHP threads may run at once, workers included. Default: the threads started at boot. Can be set to 'auto'. max_wait_time # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled. max_idle_time # Sets the maximum time an autoscaled thread may be idle before being deactivated. Default: 5s. max_requests # (experimental) Sets the maximum number of requests a PHP thread will handle before being restarted, useful for mitigating memory leaks. Applies to both regular and worker threads. Default: 0 (unlimited). diff --git a/docs/performance.md b/docs/performance.md index b884d045e5..57c9d1b72f 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -13,21 +13,24 @@ However, it is possible to substantially improve performance using an appropriat By default, FrankenPHP starts 2 times more threads and workers (in worker mode) than the available number of CPU cores. The appropriate values depend heavily on how your application is written, what it does, and your hardware. -We strongly recommend changing these values. For best system stability, it is recommended to have `num_threads` x `memory_limit` < `available_memory`. +We strongly recommend changing these values. For best system stability, it is recommended to have `max_threads` x `memory_limit` < `available_memory`, since `max_threads` bounds every thread the process runs. To find the right values, it's best to run load tests simulating real traffic. [k6](https://k6.io) and [Gatling](https://gatling.io) are good tools for this. To configure the number of threads, use the `num_threads` option of the global `frankenphp` directive. + To change the number of workers, use the `num` option of the `worker` section of the `frankenphp` directive. +`num_threads` counts the threads serving the requests no worker serves, and the threads a worker starts come on top of it, so giving a worker more threads never takes capacity away from the rest of the site. `max_threads` limits the whole process, workers included, which is what the memory heuristic of `auto` bounds. The startup log reports what that adds up to, as `total_threads` and `worker_threads`. + ### `max_threads` While it's always better to know exactly what your traffic will look like, real-life applications tend to be more unpredictable. The `max_threads` [configuration](config.md#caddyfile-config) allows FrankenPHP to automatically spawn additional threads at runtime up to the specified limit. `max_threads` can help you figure out how many threads you need to handle your traffic and can make the server more resilient to latency spikes. If set to `auto`, the limit will be estimated based on the `memory_limit` in your `php.ini`. If not able to do so, -`auto` will instead default to 2x `num_threads`. Keep in mind that `auto` might strongly underestimate the number of threads needed. +`auto` will instead default to twice the threads started at boot. Keep in mind that `auto` might strongly underestimate the number of threads needed. `max_threads` is similar to PHP-FPM's [pm.max_children](https://www.php.net/manual/install.fpm.configuration.php#pm.max-children). The main difference is that FrankenPHP uses threads instead of processes and automatically delegates them across different worker scripts and 'classic mode' as needed. diff --git a/frankenphp.go b/frankenphp.go index d8a8c2cf61..9d2e1d4940 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -242,6 +242,13 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { } } + // num_threads counts the threads serving the requests no worker serves: + // the worker threads come on top of it, so raising a worker's num never + // takes capacity away from the rest of the site + if opt.numThreads > 0 { + opt.numThreads += numWorkers + } + numThreadsIsSet := opt.numThreads > 0 maxThreadsIsSet := opt.maxThreads != 0 maxThreadsIsAuto := opt.maxThreads < 0 // maxthreads < 0 signifies auto mode (see phpmaintread.go) @@ -258,9 +265,6 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { if numThreadsIsSet && !maxThreadsIsSet { opt.maxThreads = opt.numThreads - if opt.numThreads <= numWorkers { - return 0, fmt.Errorf("num_threads (%d) must be greater than the number of worker threads (%d)", opt.numThreads, numWorkers) - } return numWorkers, nil } @@ -276,7 +280,8 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { if !numThreadsIsSet { if numWorkers >= maxProcs { - // Start at least as many threads as workers, and keep a free thread to handle requests in non-worker mode + // default: what is left of 2x the CPUs once the workers have their + // threads, and one thread at the very least opt.numThreads = numWorkers + 1 } else { opt.numThreads = maxProcs @@ -287,10 +292,6 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { } // both num_threads and max_threads are set - if opt.numThreads <= numWorkers { - return 0, fmt.Errorf("num_threads (%d) must be greater than the number of worker threads (%d)", opt.numThreads, numWorkers) - } - if !maxThreadsIsAuto && opt.maxThreads < opt.numThreads { return 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d)", opt.maxThreads, opt.numThreads) } @@ -402,7 +403,7 @@ func Init(options ...Option) error { activateServers() if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "FrankenPHP started 🐘", slog.String("php_version", Version().Version), slog.Int("num_threads", mainThread.numThreads), slog.Int("max_threads", mainThread.maxThreads), slog.Int("max_requests", maxRequestsPerThread)) + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "FrankenPHP started 🐘", slog.String("php_version", Version().Version), slog.Int("total_threads", mainThread.numThreads), slog.Int("worker_threads", workerThreadCount), slog.Int("max_threads", mainThread.maxThreads), slog.Int("max_requests", maxRequestsPerThread)) if EmbeddedAppPath != "" { globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "embedded PHP app 📦", slog.String("path", EmbeddedAppPath)) diff --git a/options.go b/options.go index e1eaeb7b55..e1f8336738 100644 --- a/options.go +++ b/options.go @@ -68,7 +68,8 @@ func WithContext(ctx context.Context) Option { } } -// WithNumThreads configures the number of PHP threads to start. +// WithNumThreads configures the number of PHP threads to start for the +// requests no worker serves. Worker threads come on top of it. func WithNumThreads(numThreads int) Option { return func(o *opt) error { o.numThreads = numThreads @@ -77,6 +78,8 @@ func WithNumThreads(numThreads int) Option { } } +// WithMaxThreads limits how many threads may run at once, workers included. +// A negative value derives that limit from the memory available. func WithMaxThreads(maxThreads int) Option { return func(o *opt) error { o.maxThreads = maxThreads diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 3ae65e68b9..8ed98edba6 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -330,14 +330,16 @@ func TestCorrectThreadCalculation(t *testing.T) { testThreadCalculation(t, maxProcs, maxProcs, &opt{}) testThreadCalculation(t, maxProcs, maxProcs, &opt{workers: oneWorkerThread}) - // num_threads is set + // num_threads is set: it counts the threads for regular requests, the + // worker threads coming on top of it testThreadCalculation(t, 1, 1, &opt{numThreads: 1}) - testThreadCalculation(t, 2, 2, &opt{numThreads: 2, workers: oneWorkerThread}) + testThreadCalculation(t, 3, 3, &opt{numThreads: 2, workers: oneWorkerThread}) + testThreadCalculation(t, 2, 2, &opt{numThreads: 1, workers: oneWorkerThread}) // max_threads is set testThreadCalculation(t, 1, 10, &opt{maxThreads: 10}) testThreadCalculation(t, 2, 10, &opt{maxThreads: 10, workers: oneWorkerThread}) - testThreadCalculation(t, 5, 10, &opt{numThreads: 5, maxThreads: 10, workers: oneWorkerThread}) + testThreadCalculation(t, 6, 10, &opt{numThreads: 5, maxThreads: 10, workers: oneWorkerThread}) // automatic max_threads testThreadCalculation(t, 1, -1, &opt{maxThreads: -1}) @@ -347,14 +349,14 @@ func TestCorrectThreadCalculation(t *testing.T) { // max_threads should be thread minimum + sum of worker max_threads testThreadCalculation(t, 2, 6, &opt{workers: []workerOpt{{num: 1, maxThreads: 5}}}) testThreadCalculation(t, 6, 9, &opt{workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 4, maxThreads: 4}}}) - testThreadCalculation(t, 10, 14, &opt{numThreads: 10, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 3, maxThreads: 4}}}) + testThreadCalculation(t, 14, 18, &opt{numThreads: 10, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 3, maxThreads: 4}}}) // max_threads should remain equal to overall max_threads testThreadCalculation(t, 2, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 3}}}) testThreadCalculation(t, 3, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 1, maxThreads: 4}}}) - // not enough num threads - testThreadCalculationError(t, &opt{numThreads: 1, workers: oneWorkerThread}) + // a worker never eats into num_threads, so there is no floor to miss; + // max_threads still has to fit what starts testThreadCalculationError(t, &opt{numThreads: 1, maxThreads: 1, workers: oneWorkerThread}) // not enough max_threads diff --git a/scaling_test.go b/scaling_test.go index d2784a8eeb..9a6d12da6d 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -35,8 +35,9 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { workerName := "worker1" workerPath := filepath.Join(testDataPath, "transition-worker-1.php") + // one thread for regular requests, one for the worker, one to scale into assert.NoError(t, Init( - WithNumThreads(2), + WithNumThreads(1), WithMaxThreads(3), WithWorkers(workerName, workerPath, 1, WithWorkerEnv(map[string]string{}), From ab2944d2fa2889deda3a3bdcefdf52c4096b4356 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 19:01:06 +0200 Subject: [PATCH 2/2] fix: the max_threads error names the num_threads the user wrote and the worker threads on top --- caddy/app.go | 2 +- caddy/caddy_test.go | 2 +- docs/config.md | 2 +- docs/internals.md | 4 ++-- frankenphp.go | 8 ++++++-- options.go | 5 ++--- phpmainthread_test.go | 2 ++ 7 files changed, 15 insertions(+), 10 deletions(-) diff --git a/caddy/app.go b/caddy/app.go index bce55c4822..f06291862c 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -45,7 +45,7 @@ func RegisterWorkers(name, fileName string, num int, wo ...frankenphp.WorkerOpti // } // } type FrankenPHPApp struct { - // NumThreads sets the number of PHP threads to start for the requests no worker serves, worker threads coming on top of it. Default: what is left of 2x the number of available CPUs. + // NumThreads sets the number of PHP threads to start for the requests no worker serves, worker threads coming on top of it. Default: what is left of 2x the number of available CPUs once the workers have their threads, one at the very least. NumThreads int `json:"num_threads,omitempty"` // MaxThreads limits how many threads may run at once, workers included. Default: the threads started at boot MaxThreads int `json:"max_threads,omitempty"` diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index b7d6c231eb..0e37234e0b 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1806,7 +1806,7 @@ func TestOpcacheReset(t *testing.T) { metrics frankenphp { - num_threads 40 + num_threads 20 php_ini { opcache.enable 1 opcache.log_verbosity_level 4 diff --git a/docs/config.md b/docs/config.md index 8388622089..3dc5a9cbbe 100644 --- a/docs/config.md +++ b/docs/config.md @@ -98,7 +98,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c ```caddyfile { frankenphp { - num_threads # Sets the number of PHP threads to start for the requests no worker serves, worker threads coming on top of it. Default: what is left of 2x the number of available CPUs once the workers have their threads. + num_threads # Sets the number of PHP threads to start for the requests no worker serves, worker threads coming on top of it. Default: what is left of 2x the number of available CPUs once the workers have their threads, one at the very least. max_threads # Limits how many PHP threads may run at once, workers included. Default: the threads started at boot. Can be set to 'auto'. max_wait_time # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled. max_idle_time # Sets the maximum time an autoscaled thread may be idle before being deactivated. Default: 5s. diff --git a/docs/internals.md b/docs/internals.md index bc5da58050..dc8059f357 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -202,8 +202,8 @@ FrankenPHP can automatically scale the number of PHP threads based on demand (`s ### Auto-scaling configuration -- `num_threads`: Initial number of threads started at boot -- `max_threads`: Maximum number of threads allowed (includes auto-scaled) +- `num_threads`: Threads started at boot for the requests no worker serves, the worker threads come on top +- `max_threads`: Maximum number of threads allowed, workers and auto-scaled threads included ### Upscaling diff --git a/frankenphp.go b/frankenphp.go index 9d2e1d4940..efbb4e36f6 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -279,9 +279,9 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { } if !numThreadsIsSet { + // default: what is left of 2x the CPUs once the workers have their + // threads, and one thread at the very least if numWorkers >= maxProcs { - // default: what is left of 2x the CPUs once the workers have their - // threads, and one thread at the very least opt.numThreads = numWorkers + 1 } else { opt.numThreads = maxProcs @@ -293,6 +293,10 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { // both num_threads and max_threads are set if !maxThreadsIsAuto && opt.maxThreads < opt.numThreads { + if numWorkers > 0 { + return 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d) plus the worker threads (%d)", opt.maxThreads, opt.numThreads-numWorkers, numWorkers) + } + return 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d)", opt.maxThreads, opt.numThreads) } diff --git a/options.go b/options.go index e1f8336738..5566ed41cf 100644 --- a/options.go +++ b/options.go @@ -127,9 +127,8 @@ func WithWorkers(name, fileName string, num int, options ...WorkerOption) Option // // Workers are designed to run indefinitely and will be gracefully shut down when FrankenPHP shuts down. // -// Extension workers receive the lowest priority when determining thread allocations. If the requested number of threads -// cannot be allocated, then FrankenPHP will panic and provide this information to the user (who will need to allocate -// more total threads). Don't be greedy. +// Extension workers count as worker threads: they come on top of num_threads, and Init() returns an error when +// max_threads has no room for them. Don't be greedy. func WithExtensionWorkers(name, fileName string, numThreads int, options ...WorkerOption) (Workers, Option) { w := &extensionWorkers{ name: name, diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 8ed98edba6..d8a5b6742d 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -335,6 +335,7 @@ func TestCorrectThreadCalculation(t *testing.T) { testThreadCalculation(t, 1, 1, &opt{numThreads: 1}) testThreadCalculation(t, 3, 3, &opt{numThreads: 2, workers: oneWorkerThread}) testThreadCalculation(t, 2, 2, &opt{numThreads: 1, workers: oneWorkerThread}) + testThreadCalculation(t, maxProcs+2, maxProcs+2, &opt{numThreads: 2, workers: []workerOpt{{}}}) // max_threads is set testThreadCalculation(t, 1, 10, &opt{maxThreads: 10}) @@ -345,6 +346,7 @@ func TestCorrectThreadCalculation(t *testing.T) { testThreadCalculation(t, 1, -1, &opt{maxThreads: -1}) testThreadCalculation(t, 2, -1, &opt{maxThreads: -1, workers: oneWorkerThread}) testThreadCalculation(t, 2, -1, &opt{numThreads: 2, maxThreads: -1}) + testThreadCalculation(t, 3, -1, &opt{numThreads: 2, maxThreads: -1, workers: oneWorkerThread}) // max_threads should be thread minimum + sum of worker max_threads testThreadCalculation(t, 2, 6, &opt{workers: []workerOpt{{num: 1, maxThreads: 5}}})