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..f06291862c 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 once the workers have their threads, one at the very least. 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/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 281f05dc75..3dc5a9cbbe 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, 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. 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/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/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..efbb4e36f6 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 } @@ -275,8 +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 { - // Start at least as many threads as workers, and keep a free thread to handle requests in non-worker mode opt.numThreads = numWorkers + 1 } else { opt.numThreads = maxProcs @@ -287,11 +292,11 @@ 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 { + 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) } @@ -402,7 +407,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..5566ed41cf 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 @@ -124,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 3ae65e68b9..d8a5b6742d 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -330,31 +330,35 @@ 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}) + testThreadCalculation(t, maxProcs+2, maxProcs+2, &opt{numThreads: 2, workers: []workerOpt{{}}}) // 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}) 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}}}) 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{}),