What is QHturbo?QHturbo là gì?
QHturbo is a PHP distribution and runtime toolkit. Every speed claim in the project comes with a reproducible benchmark; experiments that did not help are recorded as NO-GO instead of hidden. It has three parts:
QHturbo là một bản phân phối PHP kèm bộ công cụ runtime. Mọi con số về tốc độ trong dự án đều đi kèm benchmark có thể chạy lại; các thử nghiệm không hiệu quả được ghi là NO-GO chứ không bị giấu đi. Dự án gồm ba phần:
Build systemHệ thống build
Builds PHP 7.4–8.5 from pinned, hash-verified php-src tags into /opt/qhturbo/<version>, side by side with the system PHP.
Build PHP 7.4–8.5 từ các tag php-src đã ghim và kiểm tra hash, cài vào /opt/qhturbo/<phiên-bản>, chạy song song với PHP của hệ thống.
Two run modesHai chế độ chạy
Compatible (tuned PHP-FPM, no code changes) and Performance (long-lived workers, event loop, async Redis/MySQL).
Compatible (PHP-FPM đã tinh chỉnh, không sửa code) và Performance (worker chạy lâu dài, event loop, Redis/MySQL bất đồng bộ).
Panels and toolingPanel và công cụ
aaPanel and DirectAdmin integration with per-site switching and automatic rollback, plus qht-doctor, fpm-tune and bench.py.
Tích hợp aaPanel và DirectAdmin, chuyển từng site một và tự động rollback, kèm qht-doctor, fpm-tune và bench.py.
Status (2026-10-22): the PHP binary itself is not faster than upstream yet (PGO was tried twice and rejected, and there are no Zend Engine patches yet). Today's speed-ups come from how PHP runs (workers, async I/O) and from configuration.
Trạng thái (22/10/2026): bản thân file thực thi PHP chưa nhanh hơn bản gốc (PGO đã thử hai lần và bị loại, chưa có bản vá Zend Engine). Tốc độ tăng hiện nay đến từ cách PHP chạy (worker, I/O bất đồng bộ) và từ cấu hình.
Key featuresTính năng chính
Pinned PHP buildsBuild PHP được ghim phiên bản
PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 from upstream tags verified against their commit hash. Profiles minimal and full (PHP-FPM + 51 modules). Each binary ships a BUILD-INFO.txt with sha256, flags and patch list.
PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 từ tag gốc, đối chiếu với hash commit. Hai profile minimal và full (PHP-FPM + 51 module). Mỗi bản build có file BUILD-INFO.txt ghi sha256, cờ biên dịch và danh sách bản vá.
Side-by-side installCài song song
Installs to /opt/qhturbo/<version> with its own socket and systemd unit. install.sh refuses /usr, /etc and /www/server/php. PHP 7.4/8.0 link a private OpenSSL 1.1.1 on OpenSSL 3 distros.
Cài vào /opt/qhturbo/<phiên-bản> với socket và unit systemd riêng. install.sh từ chối cài vào /usr, /etc và /www/server/php. PHP 7.4/8.0 dùng OpenSSL 1.1.1 riêng trên các distro dùng OpenSSL 3.
Long-lived workersWorker chạy lâu dài
The app boots once per process. A supervisor forks workers, recycles them after N requests or a memory limit, reloads with zero downtime on SIGHUP, and backs off on crash loops.
Ứng dụng chỉ khởi động một lần cho mỗi tiến trình. Supervisor fork các worker, thay mới sau N request hoặc khi vượt giới hạn RAM, reload không gián đoạn bằng SIGHUP và tự giãn thời gian khi worker crash liên tục.
Event loop + FibersEvent loop + Fiber
async(), await(), all(), delay(), timeout(), non-blocking TCP and an HTTP client. Pure PHP 8.1+, no extension required.
async(), await(), all(), delay(), timeout(), TCP không chặn và HTTP client. Viết thuần PHP 8.1+, không cần extension.
Async Redis and MySQLRedis và MySQL bất đồng bộ
RESP2 Redis client with pipelining; mysqlnd async MySQL/MariaDB with ? placeholders (including IN (?)), transactions and timeouts. Bounded pool per worker.
Redis client RESP2 có pipeline; MySQL/MariaDB bất đồng bộ qua mysqlnd, hỗ trợ tham số ? (kể cả IN (?)), transaction và timeout. Pool kết nối có giới hạn cho mỗi worker.
State guardBảo vệ trạng thái
Per-request isolation: reset hooks, removal of leaked globals, restore of changed ini settings and output buffers, detection of growing statics and memory, --strict-state for staging.
Cô lập từng request: hook reset, xoá biến global bị rò, khôi phục ini và output buffer, phát hiện static property và bộ nhớ tăng dần, chế độ --strict-state cho staging.
Laravel and SymfonyLaravel và Symfony
--framework=laravel|symfony runs an existing project unchanged. Guards, sessions, cookies, scoped instances, transactions, config and locale are reset after every request.
--framework=laravel|symfony chạy dự án có sẵn mà không sửa code. Guard, session, cookie, scoped instance, transaction, config và locale được reset sau mỗi request.
Panel integrationTích hợp panel
aaPanel and DirectAdmin: switch one site at a time, automatic rollback if the web server config test fails or the site returns 5xx, disable_functions copied from the panel's php.ini.
aaPanel và DirectAdmin: chuyển từng site một, tự rollback nếu kiểm tra cấu hình web server lỗi hoặc site trả về 5xx, sao chép disable_functions từ php.ini của panel.
Diagnostics and benchmarksChẩn đoán và benchmark
qht-doctor checks an install and scans code for worker-mode risks; fpm-tune sizes pm.max_children; bench.py runs interleaved benchmarks with A/A control and 95% CI.
qht-doctor kiểm tra bản cài và quét code tìm rủi ro khi chạy worker; fpm-tune tính pm.max_children; bench.py chạy benchmark xen kẽ có đối chứng A/A và khoảng tin cậy 95%.
Two run modesHai chế độ chạy
Pick per site. Compatible is the safe default for everything; Performance is for APIs and apps that follow the state rules.
Chọn theo từng site. Compatible là lựa chọn an toàn mặc định cho mọi thứ; Performance dành cho API và ứng dụng tuân thủ quy tắc quản lý trạng thái.
| Compatible | Performance | |
|---|---|---|
| ForDùng cho | Any site: WordPress, Laravel, legacy code, aaPanelMọi site: WordPress, Laravel, code cũ, aaPanel | APIs and new apps that need high throughputAPI và ứng dụng mới cần thông lượng cao |
| How it runsCách chạy | nginx/Apache → PHP-FPM | nginx → qhturbo-serve (workers, event loop, async I/O)nginx → qhturbo-serve (worker, event loop, I/O bất đồng bộ) |
| PHP | 7.4 → 8.5 | 8.1 → 8.5 (Fiber) |
| Code changesSửa code | NoneKhông | Follow the state rulesTuân thủ quy tắc trạng thái |
| Laravel 13 | 288–353 req/s | 1,924–5,150 req/s |
| Symfony 8.1 | 1,737 req/s | 11,160 req/s |
| 3 × 20 ms MySQL queries3 truy vấn MySQL × 20 ms | 1,011 req/s (64 FPM children)1,011 req/s (64 tiến trình FPM) | 1,724 req/s (async MySQL)1,724 req/s (MySQL bất đồng bộ) |
Compatible: what changesCompatible: khác gì PHP-FPM thường
- OPcache on: 256 MB, 50,000 files, 32 MB interned stringsBật OPcache: 256 MB, 50.000 file, 32 MB interned strings
validate_timestamps=1kept (checked every 2 s), so FTP edits and WordPress updates still applyGiữvalidate_timestamps=1(kiểm tra mỗi 2 giây), nên sửa file qua FTP hay cập nhật WordPress vẫn có hiệu lực- JIT off until you measureTắt JIT cho tới khi bạn đo đạc
realpath_cache4M/600s,pm.max_requests=1000, slowlog, status/ping pagesrealpath_cache4M/600s,pm.max_requests=1000, slowlog, trang status/ping
Performance: what you getPerformance: bạn nhận được gì
- No per-request framework boot: the biggest winKhông khởi động lại framework mỗi request: lợi ích lớn nhất
- Parallel I/O inside one request with
async()/all()I/O song song trong một request vớiasync()/all() - Zero-downtime deploys with
kill -HUP; a broken deploy is aborted and old workers keep servingDeploy không gián đoạn bằngkill -HUP; nếu code mới lỗi, reload bị huỷ và worker cũ vẫn phục vụ - Not for WordPress or code that uses
exit()for control flowKhông dùng cho WordPress hoặc code dùngexit()để điều khiển luồng
Requirements and versionsYêu cầu và phiên bản
Linux (Debian/Ubuntu with apt, RHEL family with dnf), or Windows through WSL2. Building takes 10–40 minutes per PHP version. Performance mode needs PHP 8.1+, plus pcntl/posix for multiple workers and mysqli for the MySQL client.
Linux (Debian/Ubuntu dùng apt, dòng RHEL dùng dnf), hoặc Windows qua WSL2. Mỗi phiên bản PHP build mất 10–40 phút. Chế độ Performance cần PHP 8.1+, thêm pcntl/posix để chạy nhiều worker và mysqli cho MySQL client.
| BranchNhánh | Upstream tagTag gốc | Upstream supportHỗ trợ từ PHP | JIT | Performance |
|---|---|---|---|---|
| 7.4 | php-7.4.33 | ⚠️ end of life⚠️ hết hỗ trợ | nokhông | nokhông |
| 8.0 | php-8.0.30 | ⚠️ end of life⚠️ hết hỗ trợ | yescó | nokhông |
| 8.1 | php-8.1.34 | ⚠️ end of life⚠️ hết hỗ trợ | yescó | yescó |
| 8.2 | php-8.2.35 | security fixes onlychỉ vá bảo mật | yescó | yescó |
| 8.3 | php-8.3.36 | security fixes onlychỉ vá bảo mật | yescó | yescó |
| 8.4 | php-8.4.27 | activeđang hỗ trợ | yescó | yescó |
| 8.5 | php-8.5.12 | activeđang hỗ trợ | yescó | yescó |
InstallationCài đặt
Choose the path that matches your server. Every path installs beside the PHP you already have; nothing is overwritten and every switch can be undone.
Chọn cách phù hợp với máy chủ của bạn. Mọi cách đều cài song song với PHP hiện có; không ghi đè gì cả và mọi thao tác chuyển đổi đều có thể hoàn tác.
Take a server snapshot first, and try one test site before production.
Hãy chụp snapshot máy chủ trước, và thử trên một site test trước khi áp dụng cho production.
Compatible mode, one site at a time. enable backs up the vhost, switches it to the QHturbo socket, runs nginx -t and checks the site over HTTP; if the test fails or the site returns 5xx it rolls back on its own. It also copies disable_functions and limits from aaPanel's php.ini.
Chế độ Compatible, từng site một. enable sao lưu vhost, chuyển sang socket của QHturbo, chạy nginx -t và kiểm tra site qua HTTP; nếu kiểm tra lỗi hoặc site trả 5xx thì tự động rollback. Lệnh cũng sao chép disable_functions và các giới hạn từ php.ini của aaPanel.
git clone https://github.com/qhsvn/QHTurbo.git /opt/qhturbo-src && cd /opt/qhturbo-src
sudo scripts/aapanel.sh check --php=8.5 # inspect only, changes nothing
sudo scripts/aapanel.sh install --php=8.5 # build + install + start PHP-FPM (10-40 min)
sudo scripts/aapanel.sh enable test.example.com --php=8.5 # switch one site; auto-rollback on errors
sudo scripts/aapanel.sh status # which sites use QHturbo
sudo scripts/aapanel.sh disable test.example.com # back to aaPanel's PHPPrefer a UI? Download qhturbo-aapanel.zip, then in aaPanel open App Store → Import and upload it. The plugin lists PHP versions and sites, switches a site in one click and streams the task log.
Muốn dùng giao diện? Tải qhturbo-aapanel.zip, vào aaPanel mở App Store → Import và tải file lên. Plugin liệt kê phiên bản PHP và các site, chuyển site bằng một cú nhấp và hiển thị log tác vụ trực tiếp.
Build from source on any Debian/Ubuntu server, then point nginx at the dedicated socket.
Build từ mã nguồn trên máy chủ Debian/Ubuntu bất kỳ, sau đó trỏ nginx tới socket riêng.
sudo apt install -y git build-essential autoconf bison re2c pkg-config python3 \
libxml2-dev libssl-dev libsqlite3-dev zlib1g-dev libcurl4-openssl-dev libzip-dev \
libonig-dev libicu-dev libsodium-dev libpng-dev libjpeg-dev libfreetype-dev libgmp-dev libreadline-dev
git clone https://github.com/qhsvn/QHTurbo.git && cd QHTurbo
export QHT_LAB=~/QHTurbo-lab QHT_PHP=8.5 # QHT_PHP: 7.4 ... 8.5
scripts/bootstrap.sh # fetch + verify the upstream tag
[ "$QHT_PHP" = 7.4 ] || [ "$QHT_PHP" = 8.0 ] && scripts/deps-openssl11.sh # 7.4/8.0 only
QHT_PROFILE=full scripts/build.sh baseline # PHP + PHP-FPM
sudo -E scripts/install.sh # -> /opt/qhturbo/8.5, beside the system PHP
sudo cp /opt/qhturbo/8.5/share/qhturbo/systemd/qhturbo-php85-fpm.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now qhturbo-php85-fpm
/opt/qhturbo/8.5/bin/php /opt/qhturbo/8.5/share/qhturbo/qht-doctor.phpIn the site's nginx vhost (on aaPanel this replaces include enable-php-85.conf;):
Trong vhost nginx của site (trên aaPanel, khối này thay cho include enable-php-85.conf;):
location ~ [^/]\.php(/|$) {
try_files $uri =404;
fastcgi_pass unix:/run/qhturbo/php85-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}Then nginx -t && nginx -s reload. To roll back, restore the backed-up vhost and reload. Use install.sh --user=www --group=www if nginx runs as www.
Sau đó chạy nginx -t && nginx -s reload. Để rollback, khôi phục vhost đã sao lưu rồi reload. Dùng install.sh --user=www --group=www nếu nginx chạy bằng user www.
Apache or nginx_apache with PHP-FPM. Each domain's user gets its own QHturbo pool running as that user, copied from DirectAdmin's pool (same open_basedir and limits). The switch is a marked block in the domain's Custom HTTPD Configuration; your own lines there are kept.
Apache hoặc nginx_apache với PHP-FPM. User của mỗi domain có pool QHturbo riêng chạy dưới chính user đó, sao chép từ pool của DirectAdmin (cùng open_basedir và giới hạn). Việc chuyển đổi là một khối có đánh dấu trong Custom HTTPD Configuration của domain; các dòng bạn tự viết vẫn được giữ.
git clone https://github.com/qhsvn/QHTurbo.git /opt/qhturbo-src && cd /opt/qhturbo-src
sudo scripts/directadmin.sh check --php=7.4
sudo scripts/directadmin.sh install --php=7.4 # 10-40 minutes
sudo scripts/directadmin.sh enable example.com --php=7.4
sudo scripts/directadmin.sh disable example.comPlugin: in Plugin Manager add https://github.com/qhsvn/QHTurbo/raw/main/plugins/directadmin/qhturbo.tar.gz (keep the file name). If the page says the agent is missing, run /opt/qhturbo-src/scripts/panel-agent.sh setup directadmin as root.
Plugin: trong Plugin Manager thêm https://github.com/qhsvn/QHTurbo/raw/main/plugins/directadmin/qhturbo.tar.gz (giữ nguyên tên file). Nếu trang báo thiếu agent, chạy /opt/qhturbo-src/scripts/panel-agent.sh setup directadmin bằng root.
QHturbo builds on Linux only. On Windows, use WSL2 and keep the source inside the Linux filesystem; building on a Windows drive is slow and breaks file permissions. Then follow the Linux tab.
QHturbo chỉ build trên Linux. Trên Windows, hãy dùng WSL2 và đặt mã nguồn trong hệ thống file Linux; build trên ổ đĩa Windows sẽ chậm và hỏng quyền file. Sau đó làm theo tab Linux.
wsl --install -d Ubuntu # in PowerShell (Administrator), then reboot
# inside Ubuntu: clone into ~/ (Linux filesystem), not /mnt/c or /mnt/d
cd ~ && git clone https://github.com/qhsvn/QHTurbo.gitBuild once, deploy many: package an installed tree as a stripped tarball (about 15 MB) for servers on the same distro release. setup.sh copies missing config files and sizes PHP-FPM for the target's CPU count; existing config is never overwritten.
Build một lần, triển khai nhiều nơi: đóng gói bản đã cài thành file tar (khoảng 15 MB, đã strip) cho các máy chủ cùng phiên bản distro. setup.sh chép các file cấu hình còn thiếu và tính kích thước PHP-FPM theo số CPU của máy đích; cấu hình đang có không bao giờ bị ghi đè.
QHT_PHP=8.5 scripts/package.sh --user=www --group=www
# -> $QHT_LAB/dist/qhturbo-php8.5-<distro>-<arch>.tar.gz (+ .sha256)
# on the target server (same distro release):
sudo tar -xzf qhturbo-php8.5-*.tar.gz -C /
sudo /opt/qhturbo/8.5/share/qhturbo/setup.shUsageHướng dẫn sử dụng
Compatible modeChế độ Compatible
Nothing to learn: after installing, the site runs on QHturbo's PHP-FPM exactly as before. Useful day-to-day commands:
Không cần học gì thêm: sau khi cài, site chạy trên PHP-FPM của QHturbo y như trước. Một số lệnh hay dùng:
/opt/qhturbo/8.5/bin/php -v # the QHturbo PHP binary
sudo systemctl status qhturbo-php85-fpm # FPM service
sudo /opt/qhturbo/8.5/sbin/php-fpm -t # test FPM config
python3 scripts/fpm-tune.py # suggest pm.max_children
sudo scripts/install-ext.sh redis --php=8.5 # add a PECL extension
/opt/qhturbo/8.5/bin/php /opt/qhturbo/8.5/share/qhturbo/qht-doctor.php # health checkPerformance mode: your first worker appChế độ Performance: ứng dụng worker đầu tiên
A Performance app is a PHP file that returns a closure. Code above the return runs once when the worker boots; the closure runs for every request and returns a Response, an array (sent as JSON) or a string (sent as HTML).
Ứng dụng Performance là một file PHP trả về một closure. Phần code phía trên return chạy một lần khi worker khởi động; closure chạy cho mỗi request và trả về Response, mảng (gửi dạng JSON) hoặc chuỗi (gửi dạng HTML).
<?php // app.php: this part runs ONCE per worker
use QHTurbo\Runtime\Http\{Request, Response};
$config = require __DIR__ . '/config.php'; // booted once, reused
return function (Request $request) use ($config): Response|array|string {
// this part runs for EACH request; request state lives in $request
if ($request->path() === '/health') {
return Response::text("ok\n");
}
return ['path' => $request->path(), 'env' => $config['env']]; // array -> JSON
};# development
php runtime/bin/qhturbo-serve app.php --port=8080 --strict-state --debug
# production: 1 worker per CPU, respawn after 10,000 requests or 256 MiB
qhturbo-serve app.php --workers=4 --concurrency=1 --max-requests=10000 --memory-limit=256M
# zero-downtime deploy: replace workers one by one
kill -HUP <master-pid>If the returned object implements ResetInterface, its reset() is called after every request. On SIGHUP, OPcache is invalidated for loaded files, new workers boot first, and old ones stop only after that, with up to 60 s to finish in-flight requests. If the new code fails to boot, the log says reload aborted and the old workers keep serving.
Nếu đối tượng được trả về implement ResetInterface, hàm reset() của nó được gọi sau mỗi request. Khi nhận SIGHUP, OPcache của các file đã nạp bị vô hiệu, worker mới khởi động trước và worker cũ chỉ dừng sau đó, có tối đa 60 giây để xử lý nốt request đang chạy. Nếu code mới khởi động lỗi, log ghi reload aborted và worker cũ tiếp tục phục vụ.
Laravel and SymfonyLaravel và Symfony
Point qhturbo-serve at the project directory. No code changes are needed (tested on Laravel 13 and Symfony 8.1, written for Laravel 9+ and Symfony 5.4+).
Trỏ qhturbo-serve vào thư mục dự án. Không cần sửa code (đã kiểm thử trên Laravel 13 và Symfony 8.1, viết cho Laravel 9+ và Symfony 5.4+).
qhturbo-serve /var/www/laravel --framework=laravel --workers=4 --max-requests=5000
qhturbo-serve /var/www/symfony --framework=symfony --workers=4| Laravel stateTrạng thái Laravel | Handled after every requestXử lý sau mỗi request |
|---|---|
| Authenticated userNgười dùng đã đăng nhập | forgetGuards() |
| Session | flush() + regenerate() on the shared storeflush() + regenerate() trên store dùng chung |
| Queued cookiesCookie trong hàng đợi | flushQueuedCookies() |
| Open DB transactionTransaction DB còn mở | rolled back and loggedrollback và ghi log |
| Runtime config, localeConfig lúc chạy, locale | restoredkhôi phục |
| Scoped instances, view sections, log context, Str cache, query logScoped instance, view section, log context, Str cache, query log | clearedxoá |
Use file or redis for SESSION_DRIVER and CACHE_STORE, never array (it lives in worker RAM and grows forever). Run php artisan optimize or cache:warmup, and configure trusted proxies since requests come from nginx. Symfony resets services tagged kernel.reset.
Dùng file hoặc redis cho SESSION_DRIVER và CACHE_STORE, không dùng array (dữ liệu nằm trong RAM của worker và tăng mãi). Chạy php artisan optimize hoặc cache:warmup, và cấu hình trusted proxies vì request đi qua nginx. Symfony tự reset các service gắn tag kernel.reset.
Behind nginx and systemdChạy sau nginx và systemd
nginx handles TLS, HTTP/2, static files and compression; qhturbo-serve speaks plain HTTP/1.1 on localhost. install.sh installs the unit qhturbo-php85-worker@<name>.service configured by /etc/qhturbo/worker-<name>.env.
nginx đảm nhận TLS, HTTP/2, file tĩnh và nén; qhturbo-serve chỉ nói HTTP/1.1 trên localhost. install.sh cài unit qhturbo-php85-worker@<tên>.service, cấu hình qua /etc/qhturbo/worker-<tên>.env.
upstream app { server 127.0.0.1:8080; keepalive 64; }
server {
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app;
}
}qhturbo-serve optionsTuỳ chọn của qhturbo-serve
| OptionTuỳ chọn | DefaultMặc định | MeaningÝ nghĩa |
|---|---|---|
--host=ADDR | 127.0.0.1 | Address to listen onĐịa chỉ lắng nghe |
--port=N | 8080 | TCP portCổng TCP |
--workers=N | 0 | Number of processes. 0 = one process, no pcntl needed, no automatic respawnSố tiến trình. 0 = một tiến trình, không cần pcntl, không tự khởi động lại |
--concurrency=C | 1 | Requests interleaved inside one worker. 1 is safe for any codeSố request xen kẽ trong một worker. 1 là an toàn cho mọi code |
--max-requests=N | 10000 | Respawn a worker after N requests (contains slow leaks)Thay worker sau N request (chặn rò rỉ chậm) |
--memory-limit=SIZE | 256M | Respawn a worker when its RAM exceeds the limitThay worker khi RAM vượt ngưỡng |
--superglobals | off | Fill $_GET/$_POST/$_COOKIE/$_SERVER for legacy code (requires concurrency 1)Điền $_GET/$_POST/$_COOKIE/$_SERVER cho code cũ (cần concurrency 1) |
--strict-state | off | State rule violations return 500 and are logged (dev/staging)Vi phạm quy tắc trạng thái trả về 500 và ghi log (dev/staging) |
--state-check-every=N | 100 | Check static properties and RAM every N requests (1 with --strict-state)Kiểm tra static property và RAM mỗi N request (bằng 1 khi có --strict-state) |
--framework=NAME | — | laravel or symfony: run a project directory (requires concurrency 1)laravel hoặc symfony: chạy thư mục dự án (cần concurrency 1) |
--max-body=SIZE | 8M | Maximum request size including uploads (512K, 8M, 1G)Kích thước request tối đa, kể cả upload (512K, 8M, 1G) |
--max-connections=N | 512 | Open connections per worker (max 1000, a stream_select limit)Số kết nối mở mỗi worker (tối đa 1000, giới hạn của stream_select) |
--debug | off | Verbose logging and error detailsGhi log chi tiết và hiện chi tiết lỗi |
State rules for Performance modeQuy tắc trạng thái cho chế độ Performance
In PHP-FPM everything disappears when a request ends. In a worker, whatever survives a request leaks into the next one, possibly another user's. These rules are mandatory:
Với PHP-FPM, mọi thứ tự biến mất khi request kết thúc. Với worker, bất cứ thứ gì còn sót lại sẽ rò sang request kế tiếp, có thể là của người dùng khác. Các quy tắc sau là bắt buộc:
| # | RuleQuy tắc | Runtime supportRuntime hỗ trợ |
|---|---|---|
| R1 | Do not keep request data in globals, static properties or static variables. Put it on the Request or a fresh object.Không lưu dữ liệu request trong biến global, static property hay biến static. Đặt nó trong Request hoặc một object tạo mới mỗi request. | New globals are deleted and logged; growing statics trigger a warningBiến global mới bị xoá và ghi log; static tăng dần sẽ bị cảnh báo |
| R2 | Services holding per-request state implement ResetInterface.Service giữ trạng thái theo request phải implement ResetInterface. | reset() is called after every requestreset() được gọi sau mỗi request |
| R3 | No exit()/die(): return a Response.Không dùng exit()/die(): hãy trả về Response. | The worker is restarted, but the in-flight request failsWorker được khởi động lại, nhưng request đang xử lý bị lỗi |
| R4 | No header(), setcookie() or echo for output: set them on the Response.Không dùng header(), setcookie() hay echo để trả kết quả: đặt chúng trên Response. | qht-doctor --scan lists themqht-doctor --scan liệt kê các chỗ cần sửa |
| R5 | Do not change ini_set, timezone or locale per request.Không đổi ini_set, múi giờ hay locale theo từng request. | Changed ini settings are restoredIni bị đổi sẽ được khôi phục |
| R6 | Close the output buffers you open.Đóng các output buffer bạn đã mở. | Leftover buffers are cleaned and loggedBuffer còn sót được dọn và ghi log |
| R7 | Open DB/Redis connections once, handle reconnects, never leave a transaction open.Mở kết nối DB/Redis một lần, xử lý kết nối lại, không bao giờ để transaction mở sang request sau. | Runtime clients pool connections and roll back open transactionsClient của runtime dùng pool và tự rollback transaction còn mở |
| R8 | With --concurrency > 1: no superglobals, and no blocking calls (PDO, sleep, file_get_contents on a URL); use async I/O only.Khi --concurrency > 1: không dùng superglobal và không gọi hàm chặn (PDO, sleep, file_get_contents với URL); chỉ dùng I/O bất đồng bộ. | --superglobals is rejected--superglobals bị từ chối |
| R9 | Do not register handlers per request (set_error_handler, register_shutdown_function).Không đăng ký handler theo từng request (set_error_handler, register_shutdown_function). | qht-doctor --scan warnsqht-doctor --scan cảnh báo |
Migration pathLộ trình chuyển đổi
php qht-doctor.php --scan=/var/www/applists R1–R9 violations (vendor/is skipped).php qht-doctor.php --scan=/var/www/appliệt kê vi phạm R1–R9 (bỏ quavendor/).- Run staging with
--concurrency=1 --superglobals --strict-state: violations show up as 500 errors with a log line.Chạy staging với--concurrency=1 --superglobals --strict-state: vi phạm hiện thành lỗi 500 kèm dòng log. - Fix them, turn off
--superglobalsand read input fromRequest.Sửa hết, tắt--superglobalsvà đọc dữ liệu từRequest. - Raise
--concurrencyonly once all I/O is async.Chỉ tăng--concurrencykhi toàn bộ I/O đã bất đồng bộ. - Go to production with a canary share of traffic, keeping Compatible (FPM) as the fallback.Đưa lên production với một phần lưu lượng (canary), giữ Compatible (FPM) làm phương án dự phòng.
API reference (runtime)Tài liệu hàm (runtime)
All classes live under the namespace QHTurbo\Runtime. Load them with Composer (qhturbo/runtime) or with runtime/src/autoload.php. They need PHP 8.1+ and no extension except mysqli for MySQL.
Mọi class nằm trong namespace QHTurbo\Runtime. Nạp bằng Composer (qhturbo/runtime) hoặc bằng runtime/src/autoload.php. Cần PHP 8.1+ và không cần extension nào ngoài mysqli cho MySQL.
Async functions · AsyncHàm bất đồng bộ · Async
Import with use function QHTurbo\Runtime\Async\{async, await, all, delay, timeout};. Outside a fiber (FPM, CLI) the same calls simply block.
Import bằng use function QHTurbo\Runtime\Async\{async, await, all, delay, timeout};. Ngoài fiber (FPM, CLI) các lời gọi này đơn giản là chạy chặn.
async(callable $fn, mixed ...$args): Future$fn in a new Fiber and returns a Future right away. Inside it, await(), delay(), read() and write() suspend only that fiber, so other requests keep running.Chạy $fn trong một Fiber mới và trả về Future ngay lập tức. Bên trong, await(), delay(), read(), write() chỉ tạm dừng fiber đó, các request khác vẫn tiếp tục chạy.await(Future $future): mixedall(array $futures): arraydelay(float $seconds): voidtimeout(Future $future, float $seconds): FutureTimeoutException if $future is not done in time.Trả về future sẽ lỗi với TimeoutException nếu $future không xong đúng hạn.connect(string $uri, float $timeoutSeconds = 10.0): resourcetcp://10.0.0.5:9000.Mở stream TCP không chặn, ví dụ tcp://10.0.0.5:9000.read($stream, int $length = 65536, ?float $timeoutSeconds = null): string$length bytes once the stream is readable; returns '' at end of stream.Đọc tối đa $length byte khi stream sẵn sàng; trả về '' khi hết stream.write($stream, string $data, ?float $timeoutSeconds = null): void$data, suspending while the socket buffer is full.Ghi toàn bộ $data, tạm dừng khi bộ đệm socket đầy.readable($stream, ?float $t = null) / writable($stream, ?float $t = null): voiduse QHTurbo\Runtime\Http\{Request, Client};
use function QHTurbo\Runtime\Async\{async, all, timeout, await, delay};
return function (Request $r): array {
// 3 backend calls in parallel: ~20 ms total instead of ~60 ms
[$a, $b, $c] = all([
async(fn () => Client::get('http://stats.internal/a')['body']),
async(fn () => Client::get('http://stats.internal/b')['body']),
async(fn () => Client::get('http://stats.internal/c')['body']),
]);
// give up after 0.5 s
$slow = await(timeout(async(fn () => Client::get('http://slow.internal/')), 0.5));
return compact('a', 'b', 'c') + ['slow' => $slow['status']];
};Future · Async\FutureFuture · Async\Future
Future::resolved(mixed $value = null): Futureresolve(mixed $value) · fail(Throwable $e)isDone(): bool · result(): mixed · await(): mixedawait($this).Kiểm tra trạng thái, lấy kết quả của future đã xong, hoặc await($this).onDone(callable $cb): voidfn(?Throwable $error, mixed $value) when the future completes.Callback fn(?Throwable $error, mixed $value) khi future hoàn tất.Event loop · EventLoop\LoopEvent loop · EventLoop\Loop
Loop::get(): Loop · Loop::reset(): voidreset() is for tests.Event loop dùng chung của worker (singleton); reset() dùng cho test.defer(callable $cb): voiddelay(float $s, callable $cb): string · repeat(float $interval, callable $cb): stringonReadable($stream, callable $cb): string · onWritable($stream, callable $cb): stringstream_select.Theo dõi stream bằng stream_select.cancel(string $id): voidrun() · runUntil(callable $done) · stop() · isRunning(): bool · stats(): arrayRequest · Http\RequestRequest · Http\Request
$request->method · uri · protocol · headers · body · server · queryquery is the parsed query string.Thuộc tính chỉ đọc. Tên header viết thường; query là query string đã phân tích.path(): stringheader(string $name, ?string $default = null): ?stringparsedBody(): arrayx-www-form-urlencoded or multipart/form-data fields.Dữ liệu JSON, x-www-form-urlencoded hoặc các trường multipart/form-data đã giải mã.uploadedFiles(): array$_FILES layout (including photos[]). Temp files are deleted after the response; move them with rename(), not move_uploaded_file().File upload theo cấu trúc $_FILES của PHP (kể cả photos[]). File tạm bị xoá sau khi trả response; dùng rename() để chuyển, không dùng move_uploaded_file().cookies(): arrayResponse · Http\ResponseResponse · Http\Response
new Response(int $status = 200, array $headers = [], string $body = '')Set-Cookie lines).Header ánh xạ tên tới một chuỗi hoặc danh sách chuỗi (nhiều dòng Set-Cookie).Response::json(mixed $data, int $status = 200): ResponseResponse::text(string $text, int $status = 200): Responsetext/plain; charset=utf-8.text/plain; charset=utf-8.Response::from(mixed $result): ResponseResponse as is, a string as HTML, an array or JsonSerializable as JSON.Cách server xử lý giá trị handler trả về: Response giữ nguyên, chuỗi thành HTML, mảng hoặc JsonSerializable thành JSON.HTTP client · Http\ClientHTTP client · Http\Client
Client::request(string $method, string $url, string $body = '', array $headers = [], float $timeout = 10.0): arrayhttp:// URLs. Returns ['status' => int, 'headers' => array, 'body' => string].HTTP/1.1 client bất đồng bộ cho URL http://. Trả về ['status' => int, 'headers' => array, 'body' => string].Client::get(string $url, float $timeout = 10.0): arrayMySQL / MariaDB · Mysql\ClientMySQL / MariaDB · Mysql\Client
new Mysql\Client(string|Config $config, ?Closure $log = null)mysql://user:pass@host:3306/db?charset=utf8mb4&pool=10&socket=/run/mysqld/mysqld.sock. Create it once per worker.Dạng URI mysql://user:pass@host:3306/db?charset=utf8mb4&pool=10&socket=/run/mysqld/mysqld.sock. Tạo một lần cho mỗi worker.query(string $sql, array $params = []): Result? placeholders; an array param expands for IN (?); DateTime is formatted. Values are escaped by charset (like PDO emulated prepares).Tham số ?; tham số dạng mảng được mở rộng cho IN (?); DateTime được định dạng. Giá trị được escape theo charset (giống emulated prepares của PDO).transaction(callable $fn): mixed$fn(Connection $c) runs between BEGIN and COMMIT on one connection; any exception rolls back and is re-thrown.$fn(Connection $c) chạy giữa BEGIN và COMMIT trên một kết nối; có exception thì rollback và ném lại.withConnection(callable $fn): mixedpoolStats(): array · close(): voidResult: $rows · $affectedRows · $insertId · first() · value() · count()value() is the first column of the first row. Iterable and countable.Mỗi dòng là mảng kết hợp với kiểu int/float gốc. value() là cột đầu tiên của dòng đầu tiên. Có thể duyệt và đếm.QueryException: getCode() · $sql · isConnectionError()Redis · Redis\ClientRedis · Redis\Client
new Redis\Client(string $uri = 'redis://127.0.0.1:6379', int $poolSize = 16, float $timeout = 5.0)redis://[user:pass@]host[:port][/db] or unix:///path/redis.sock?db=N&password=….redis://[user:pass@]host[:port][/db] hoặc unix:///path/redis.sock?db=N&password=….$redis->get('k') · set('k', 'v', 'EX', 30) · incr · hset · expire …__call).Gọi mọi lệnh Redis như một phương thức (qua __call).command(string|int|float ...$args): mixedcommand('ZADD', 'board', 10, 'alice').Gửi lệnh thô: command('ZADD', 'board', 10, 'alice').pipeline(array $commands): arraypipeline([['SET','a',1], ['INCR','a']]).Gửi nhiều lệnh trong một lượt: pipeline([['SET','a',1], ['INCR','a']]).poolStats(): array · close(): voidWRONGTYPE throw RedisException and keep the connection.Thống kê pool; đóng mọi kết nối. Lỗi như WRONGTYPE ném RedisException và kết nối vẫn dùng được.use QHTurbo\Runtime\Mysql\Client as Mysql;
use QHTurbo\Runtime\Redis\Client as Redis;
use function QHTurbo\Runtime\Async\{all, async};
$db = new Mysql('mysql://app:[email protected]:3306/shop?pool=16'); // once per worker
$redis = new Redis('redis://:[email protected]:6379/0', poolSize: 16);
return function (Request $r) use ($db, $redis): array {
if (($cached = $redis->get('home')) !== null) {
return json_decode($cached, true);
}
[$users, $orders, $visits] = all([ // 3 queries on 3 pooled connections
async(fn () => $db->query('SELECT id, name FROM users WHERE id IN (?)', [[1, 2, 3]])->rows),
async(fn () => $db->query('SELECT * FROM orders WHERE created_at > ?', [new DateTime('-1 day')])->rows),
async(fn () => $db->query('SELECT COUNT(*) FROM visits')->value()),
]);
$db->transaction(function ($c) {
$c->query('UPDATE stock SET qty = qty - 1 WHERE id = ?', [42]);
$c->query('INSERT INTO sales (item_id) VALUES (?)', [42]);
});
$redis->set('home', json_encode($data = compact('users', 'orders', 'visits')), 'EX', 30);
return $data;
};Connection pool · Pool\PoolPool kết nối · Pool\Pool
new Pool(Closure $factory, int $maxSize = 10, ?Closure $closer = null, ?Closure $check = null, float $acquireTimeout = 10.0)$check($conn, $idleSeconds) validates idle connections.Pool có giới hạn dùng chung cho Redis và MySQL client. $check($conn, $idleSeconds) kiểm tra kết nối đang rảnh.use(callable $fn, ?callable $isBroken = null): mixed$fn($conn), then release, or discard if broken. Callers wait when the pool is exhausted.Lấy kết nối, chạy $fn($conn), rồi trả lại, hoặc loại bỏ nếu hỏng. Khi pool hết, lời gọi sẽ chờ.acquire() · release($c) · discard($c) · stats() · close()State · StateTrạng thái · State
interface ResetInterface { public function reset(): void; }StateGuard::addResetter(ResetInterface|callable $r) · captureBaseline() · afterRequest(): arrayStateViolation--strict-state mode; the request returns 500.Ném ra ở chế độ --strict-state; request trả về 500.use QHTurbo\Runtime\Http\{Request, Response};
use QHTurbo\Runtime\State\ResetInterface;
final class App implements ResetInterface {
private ?array $user = null; // per-request state
public function __invoke(Request $r): Response {
$this->user = ['id' => (int) ($r->query['uid'] ?? 0)];
return Response::json($this->user, 200);
}
public function reset(): void { $this->user = null; } // called after every request
}
return new App();Framework bridges · BridgeCầu nối framework · Bridge
new Bridge\Laravel(string $basePath) · handle(Request): Response · reset()--framework=laravel uses.Thứ mà --framework=laravel sử dụng.new Bridge\Symfony(string $basePath) · handle(Request): Response--framework=symfony uses; sets APP_RUNTIME_MODE=web=1&worker=1.Thứ mà --framework=symfony sử dụng; đặt APP_RUNTIME_MODE=web=1&worker=1.HttpFoundation::toSymfony(Request $r): SymfonyRequest · fromSymfony(SymfonyResponse $r): ResponseScripts and toolsScript và công cụ
aaPanel and DirectAdminaaPanel và DirectAdmin
scripts/aapanel.sh and scripts/directadmin.sh share the same verbs (DirectAdmin takes a domain instead of a site and has no sync-ini). Web server: nginx or Apache, detected automatically (--web=nginx|apache to force).
scripts/aapanel.sh và scripts/directadmin.sh dùng chung các lệnh (DirectAdmin nhận tên domain thay cho site và không có sync-ini). Web server: nginx hoặc Apache, tự nhận diện (ép bằng --web=nginx|apache).
| CommandLệnh | What it doesChức năng |
|---|---|
check [--php=8.5] | Inspect the server and print a plan; changes nothingKiểm tra máy chủ và in kế hoạch; không thay đổi gì |
install [--php=8.5] | Build + install /opt/qhturbo/ |
enable <site> [--php=8.5] [--url=URL] | Switch ONE site to QHturbo; auto-rollback on errorsChuyển MỘT site sang QHturbo; tự rollback khi lỗi |
disable <site> | Switch the site back to the panel's PHPĐưa site về PHP của panel |
status | Sites on QHturbo and service healthCác site đang dùng QHturbo và tình trạng dịch vụ |
sync-ini [--php=8.5] | Re-copy limits and disable_functions from aaPanel php.iniSao chép lại giới hạn và disable_functions từ php.ini của aaPanel |
sync-ext [--php=8.5] | Give QHturbo the extensions of the panel's PHP of that version (NO_SYNC_EXT=1 to skip)Cấp cho QHturbo các extension của PHP cùng phiên bản trong panel (NO_SYNC_EXT=1 để bỏ qua) |
uninstall [--php=8.5] | Disable all sites, stop and remove the serviceTắt mọi site, dừng và gỡ dịch vụ |
versions | sites | Tab-separated lists for the panel pluginDanh sách phân tách bằng tab cho plugin panel |
qht-doctorqht-doctor
Checks a PHP install (OPcache sizing, Xdebug in production, EOL versions, missing extensions, realpath cache) and, with --scan, finds worker-mode risks: globals, statics, exit(), header(), sessions, superglobals, move_uploaded_file(). Runs on PHP 7.4+.
Kiểm tra bản cài PHP (kích thước OPcache, Xdebug trên production, phiên bản hết hỗ trợ, extension thiếu, realpath cache) và với --scan thì tìm rủi ro khi chạy worker: global, static, exit(), header(), session, superglobal, move_uploaded_file(). Chạy trên PHP 7.4+.
php qht-doctor.php # check the PHP running this script
php -c /path/php.ini qht-doctor.php # check with a specific php.ini
php qht-doctor.php --scan=/var/www/app # also scan code for worker-mode risks
php qht-doctor.php --json # machine-readable outputfpm-tunefpm-tune
Recommends pm.max_children and related settings from RAM and the measured memory of running FPM workers.
Đề xuất pm.max_children và các thiết lập liên quan dựa trên RAM và bộ nhớ đo được của các worker FPM đang chạy.
python3 scripts/fpm-tune.py # measure this host
python3 scripts/fpm-tune.py --ram 8192 --reserve 2048 --worker-rss 60 --pools 2 --cpus 4bench.py and web benchmarksbench.py và benchmark web
Interleaved, warmed-up runs with an A/A noise check, peak RSS, bootstrap 95% confidence intervals and output verification. Options: --php, --runs (7), --warmup (1), --size full|quick, --workloads, --modes interp,jit, --out.
Chạy xen kẽ, có khởi động trước, đối chứng nhiễu A/A, đo RSS đỉnh, khoảng tin cậy 95% bằng bootstrap và kiểm tra đầu ra. Tuỳ chọn: --php, --runs (7), --warmup (1), --size full|quick, --workloads, --modes interp,jit, --out.
python3 scripts/bench.py php --variants baseline,qhturbo --runs 10 # compare builds
python3 scripts/bench.py cross --php 7.4,8.5 --langs php # compare PHP versions
python3 scripts/bench.py cross --php 8.5 --langs python,node,go # compare languages
sudo benchmarks/web/run.sh # Compatible vs Performance over HTTPBuild, install and packageBuild, cài đặt và đóng gói
Environment: QHT_LAB (work directory), QHT_PHP (7.4 … 8.5, default 8.5), QHT_PROFILE (minimal | full).
Biến môi trường: QHT_LAB (thư mục làm việc), QHT_PHP (7.4 … 8.5, mặc định 8.5), QHT_PROFILE (minimal | full).
| CommandLệnh | What it doesChức năng |
|---|---|
scripts/bootstrap.sh [--all] | Clone php-src and fetch the pinned tag (QHT_PHP) or all of them, verifying each commitClone php-src và lấy tag đã ghim (QHT_PHP) hoặc tất cả, kiểm tra từng commit |
scripts/build.sh baseline|qhturbo|qhturbo-pgo | Build a variant; QHT_PROFILE=minimal|full chooses the extension setBuild một biến thể; QHT_PROFILE=minimal|full chọn bộ extension |
scripts/install.sh [--variant=] [--destdir=] [--user=] [--group=] | Install a full build with both run modes' config; never overwrites config (writes *.qhturbo-new)Cài bản full kèm cấu hình cho cả hai chế độ; không ghi đè cấu hình (ghi ra *.qhturbo-new) |
scripts/package.sh [--variant=] [--user=] [--group=] | Stripped .tar.gz + .sha256 for servers on the same distroFile .tar.gz đã strip + .sha256 cho máy chủ cùng distro |
scripts/deps-openssl11.sh | Private OpenSSL 1.1.1w for PHP 7.4/8.0OpenSSL 1.1.1w riêng cho PHP 7.4/8.0 |
scripts/install-ext.sh <ext> [--php=] [--version=] | Build and enable a PECL extension (redis, imagick, apcu…), ionCube, or a bundled one (pgsql, xsl…); falls back to GitHub when PECL is downBuild và bật extension PECL (redis, imagick, apcu…), ionCube hoặc extension đi kèm PHP (pgsql, xsl…); dùng GitHub khi PECL không truy cập được |
scripts/run-tests.sh <variant> [interp|opcache|jit] | Run the official PHPT suite and save a summaryChạy bộ test PHPT chính thức và lưu tóm tắt |
Measured resultsKết quả đo
4 vCPU, PHP 8.5.12, production config, nginx + wrk on the same shared cloud VM (±8% noise). Requests per second; grey is PHP-FPM (Compatible), colour is workers (Performance).
4 vCPU, PHP 8.5.12, cấu hình production, nginx + wrk trên cùng một VM cloud dùng chung (nhiễu ±8%). Đơn vị request/giây; màu xám là PHP-FPM (Compatible), màu nổi là worker (Performance).
| ChangeThay đổi | Speed-upMức tăng | NotesGhi chú |
|---|---|---|
| Workers instead of PHP-FPM, real Laravel 13 appWorker thay cho PHP-FPM, ứng dụng Laravel 13 thật | 6.7–14.6× | Removes the per-request framework bootBỏ chi phí khởi động framework mỗi request |
| Async MySQL instead of blocking PDOMySQL bất đồng bộ thay cho PDO chặn | 1.7× | When requests wait on the databaseKhi request phải chờ database |
| Async HTTP callsGọi HTTP bất đồng bộ | 2.5× | With half the RAMDùng một nửa RAM |
| Enable JIT on 8.5 (CPU workloads)Bật JIT trên 8.5 (tác vụ CPU) | 1.56× | ~0 for JSON/string/array work~0 với xử lý JSON/chuỗi/mảng |
| Upgrade 7.4 → 8.5 (interpreter only)Nâng 7.4 → 8.5 (chỉ trình thông dịch) | 1.05× | 1.69× with JIT1.69× khi bật JIT |
| PGO-optimised binaryBản build tối ưu PGO | +0.3% / −3.2% | NO-GO: no stable gainNO-GO: không có lợi ích ổn định |
Examples and performance testingVí dụ và đo hiệu năng
The examples/ folder next to this page has runnable PHP examples. Each one exists twice: fpm.php written the classic way (runs from the top on every request) and app.php for QHturbo workers. compare.sh starts both, loads them with bench.php (a load generator written in PHP) and prints the results side by side.
Thư mục examples/ cạnh trang này chứa các ví dụ PHP chạy được ngay. Mỗi ví dụ có hai bản: fpm.php viết kiểu truyền thống (chạy lại từ đầu ở mỗi request) và app.php cho worker QHturbo. compare.sh khởi động cả hai, tạo tải bằng bench.php (công cụ tạo tải viết bằng PHP) rồi in kết quả cạnh nhau.
| ExampleVí dụ | What it showsCho thấy điều gì |
|---|---|
01-hello | No boot cost: the baseline. Expect no big gain, because the speed-up comes from removing work.Không có chi phí khởi động: dùng làm mốc. Không kỳ vọng tăng nhiều, vì tốc độ đến từ việc bỏ bớt công việc. |
02-framework | A stand-in framework (300 classes, 40 configs, DI container, 200 routes) booted per request vs once per worker.Framework mô phỏng (300 class, 40 config, DI container, 200 route) khởi động mỗi request so với một lần mỗi worker. |
03-async-io | 3 backend calls of 20 ms each: sequential and blocking vs parallel with async() + all().3 lời gọi backend 20 ms: tuần tự và bị chặn so với song song bằng async() + all(). |
04-mysql | A full shop API (schema, seed data, .env, systemd, nginx) written twice over the same SQL: PDO vs the async MySQL client. Ready to upload to a real server; bench-server.sh compares route by route.API cửa hàng hoàn chỉnh (schema, dữ liệu mẫu, .env, systemd, nginx) viết hai lần trên cùng các câu SQL: PDO so với MySQL client bất đồng bộ. Sẵn sàng đưa lên máy chủ thật; bench-server.sh so sánh từng route. |
06-async | Async patterns: all, timeout, settle, race, mapLimit, retry, timers and a Redis cache. demo.php runs in the terminal without a server.Các mẫu async: all, timeout, settle, race, mapLimit, retry, hẹn giờ và cache Redis. demo.php chạy ngay trên terminal, không cần server. |
05-cpu-jit | CPU workloads: interpreter vs OPcache vs JIT, or two PHP binaries side by side.Tác vụ CPU: thông dịch, OPcache và JIT, hoặc so hai bản PHP với nhau. |
git clone https://github.com/qhsvn/QHTurbo.git && cd QHTurbo
cp -r /path/to/examples ./examples # next to runtime/
bash examples/compare.sh all # hello + framework + async-io
WORKERS=4 C=128 D=15 bash examples/compare.sh framework
PHP=/opt/qhturbo/8.5/bin/php bash examples/compare.sh async-io
php examples/05-cpu-jit/cpu.php --compare # interpreter vs OPcache vs JIT
php examples/06-async/demo.php # async patterns, no server needed
php examples/bench.php http://127.0.0.1:8080/ -c 64 -d 10 -p 2== 02-framework (2 processes each, 64 connections, 5s)
classic (php -S / FPM) 4,720.4 req/s p50 11.86 ms p99 74.91 ms errors 0
QHturbo workers (concurrency 1) 18,780.8 req/s p50 3.28 ms p99 7.61 ms errors 0
=> QHturbo is 4.0x the requests/s of classic PHPSample run (PHP 8.3.6, shared 4-vCPU container, 2 processes per side, 64 connections, client on the same machine). Treat it as an illustration and measure on your own server:
Kết quả chạy thử (PHP 8.3.6, container dùng chung 4 vCPU, mỗi bên 2 tiến trình, 64 kết nối, công cụ tạo tải cùng máy). Chỉ mang tính minh hoạ, hãy đo trên máy chủ của bạn:
| ExampleVí dụ | Classic (php -S)Truyền thống (php -S) | QHturbo workersWorker QHturbo | |
|---|---|---|---|
| 01 hello | 14,507 req/s · p50 4.2 ms | 9,083 req/s · p50 1.4 ms | 0.6× |
| 02 framework | 4,720 req/s · p50 11.9 ms | 18,781 req/s · p50 3.3 ms | 4.0× |
| 03 async I/O | 48 req/s · p50 1,321 ms | 1,618 req/s · p50 40 ms | 33.7× |
| 05 CPU | interpreter 1.00×thông dịch 1,00× | OPcache 1.25× · JIT 1.82× |
The MySQL kit on the same container with MariaDB 10.11. The more a route waits on I/O, the bigger the gain; when MySQL itself is saturated (dashboard), async does not help:
Bộ MySQL chạy trên cùng container với MariaDB 10.11. Route càng chờ I/O nhiều thì càng nhanh hơn; khi chính MySQL đã quá tải (dashboard) thì async không giúp được:
| Route (04-mysql)Route (04-mysql) | PHP-FPM + PDO | QHturbo + async MySQLQHturbo + MySQL bất đồng bộ | |
|---|---|---|---|
| /products/{id} | 2,749 req/s | 5,195 req/s | 1.9× |
| /orders/random | 2,326 req/s | 3,187 req/s | 1.4× |
| /slow (3 × 20 ms) | 81 req/s | 493 req/s | 6.1× |
| /dashboard | 16 req/s | 14 req/s | 0.9× (MySQL saturated)0.9× (MySQL quá tải) |
# on a test server, from examples/04-mysql
cp .env.example .env && nano .env # DB_PASS, WORKERS, CONCURRENCY ...
sudo bash deploy/setup.sh # database + test data + systemd worker
sudo cp deploy/nginx-shop.conf /etc/nginx/conf.d/qht-shop.conf && sudo nginx -s reload
bash bench-server.sh http://127.0.0.1/fpm http://127.0.0.1/workerHello world gets no faster, which is expected: a worker only wins when there is boot work to skip (02) or I/O to overlap (03, 04). To test your real nginx + PHP-FPM, put an example's fpm.php in a site and run with CLASSIC_URL=http://your-site/fpm.php.
Hello world không nhanh hơn là điều bình thường: worker chỉ thắng khi có phần khởi động để bỏ qua (02) hoặc I/O để chạy chồng lên nhau (03, 04). Để đo với nginx + PHP-FPM thật, đặt fpm.php của ví dụ vào một site rồi chạy với CLASSIC_URL=http://site-cua-ban/fpm.php.
Full instructions: examples/README.md.
Hướng dẫn đầy đủ: examples/README.vi.md.
Example source codeMã nguồn các ví dụ
Every file of the examples/ folder, with the commands to run it. Paths assume the folder sits inside a QHTurbo checkout, next to runtime/. Click a file name to expand it; each block has a copy button.
Toàn bộ file trong thư mục examples/ kèm lệnh chạy. Đường dẫn giả định thư mục nằm trong repo QHTurbo, cạnh runtime/. Bấm vào tên file để mở; mỗi khối code đều có nút sao chép.
The smallest possible app, written both ways. There is nothing to boot, so the worker is not faster here: use it as the baseline and to check that your setup works.
Ứng dụng nhỏ nhất, viết theo cả hai cách. Không có gì để khởi động nên worker không nhanh hơn ở đây: dùng làm mốc và để kiểm tra môi trường đã chạy đúng.
php runtime/bin/qhturbo-serve examples/01-hello/app.php --port=8080 --workers=2
curl localhost:8080/hello
bash examples/compare.sh helloexamples/01-hello/fpm.php5 linesdòng
<?php
// Classic PHP (PHP-FPM / php -S): this file runs from the top on every request.
header('Content-Type: application/json');
echo json_encode(['hello' => 'world', 'pid' => getmypid()]);examples/01-hello/app.php8 linesdòng
<?php
// QHturbo Performance: everything above "return" runs once per worker,
// the closure runs per request.
// php runtime/bin/qhturbo-serve examples/01-hello/app.php --port=8080 --workers=4
use QHTurbo\Runtime\Http\Request;
return static fn (Request $request): array => ['hello' => 'world', 'pid' => getmypid()];A stand-in framework: 300 autoloaded classes, 40 config files, a DI container and 200 compiled routes. fpm.php boots it on every request, app.php once per worker. Compare the boot_ms field in the two answers.
Framework mô phỏng: 300 class tự nạp, 40 file config, DI container và 200 route đã biên dịch. fpm.php khởi động nó ở mỗi request, app.php chỉ một lần cho mỗi worker. Hãy so trường boot_ms trong hai kết quả.
php runtime/bin/qhturbo-serve examples/02-framework/app.php --port=8080 --workers=2
curl localhost:8080/api/users/42
bash examples/compare.sh frameworkexamples/02-framework/app.php13 linesdòng
<?php
// QHturbo Performance: the same framework boots ONCE per worker.
// php runtime/bin/qhturbo-serve examples/02-framework/app.php --port=8080 --workers=4
use QHTurbo\Runtime\Http\Request;
require __DIR__ . '/../common/framework.php';
$t = hrtime(true);
$app = QhtExample\App::boot();
$boot = round((hrtime(true) - $t) / 1e6, 2);
return static fn (Request $r): array => $app->handle($r->method, $r->path()) + ['boot_ms' => 0, 'boot_once_ms' => $boot];examples/02-framework/fpm.php12 linesdòng
<?php
// Classic PHP: the framework boots on EVERY request (what PHP-FPM does).
require __DIR__ . '/../common/framework.php';
$t = hrtime(true);
$app = QhtExample\App::boot();
$boot = (hrtime(true) - $t) / 1e6;
$path = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/hello', PHP_URL_PATH);
header('Content-Type: application/json');
echo json_encode($app->handle($_SERVER['REQUEST_METHOD'] ?? 'GET', $path) + ['boot_ms' => round($boot, 2)]);examples/common/framework.php118 linesdòng
<?php
// A small stand-in for a real framework boot (Laravel/Symfony style):
// 300 service classes autoloaded, 40 config files read, a DI container
// filled and 200 routes compiled. Under PHP-FPM this whole boot runs on
// EVERY request; a QHturbo worker runs it ONCE.
//
// Generated class/config files are cached in sys_get_temp_dir()/qht-example-fw.
declare(strict_types=1);
namespace QhtExample;
const CLASSES = 300;
const CONFIGS = 40;
const ROUTES = 200;
function cacheDir(): string
{
$dir = sys_get_temp_dir() . '/qht-example-fw';
if (!is_file("$dir/ready")) {
@mkdir("$dir/src", 0777, true);
@mkdir("$dir/config", 0777, true);
for ($i = 0; $i < CLASSES; $i++) {
$deps = $i > 0 ? 'private Service' . ($i - 1) . ' $prev' : '';
file_put_contents("$dir/src/Service$i.php", "<?php\nnamespace QhtExample\\Gen;\n"
. "final class Service$i {\n"
. " public function __construct(" . ($i > 0 ? "public ?Service" . ($i - 1) . " \$prev = null" : '') . ") {}\n"
. " public function name(): string { return 'service-$i'; }\n"
. " public function work(int \$n): int { \$s = 0; for (\$k = 0; \$k < \$n; \$k++) { \$s += \$k * $i % 7; } return \$s; }\n"
. "}\n");
}
for ($i = 0; $i < CONFIGS; $i++) {
$cfg = [];
for ($k = 0; $k < 50; $k++) {
$cfg["key_$k"] = ['enabled' => $k % 2 === 0, 'value' => str_repeat('x', 20) . $k, 'list' => range(0, 9)];
}
file_put_contents("$dir/config/c$i.php", '<?php return ' . var_export($cfg, true) . ';');
}
touch("$dir/ready");
}
return $dir;
}
final class Container
{
/** @var array<string, \Closure> */
private array $factories = [];
/** @var array<string, object> */
private array $instances = [];
public function bind(string $id, \Closure $factory): void { $this->factories[$id] = $factory; }
public function get(string $id): object
{
return $this->instances[$id] ??= ($this->factories[$id])($this);
}
}
final class App
{
public Container $container;
/** @var array<string, array<string, mixed>> */
public array $config = [];
/** @var list<array{string, string, string}> regex, method, name */
public array $routes = [];
public static function boot(): self
{
$dir = cacheDir();
spl_autoload_register(static function (string $class) use ($dir): void {
if (str_starts_with($class, 'QhtExample\\Gen\\')) {
require "$dir/src/" . substr($class, strlen('QhtExample\\Gen\\')) . '.php';
}
});
$app = new self();
for ($i = 0; $i < CONFIGS; $i++) {
$app->config["c$i"] = require "$dir/config/c$i.php";
}
$app->container = new Container();
for ($i = 0; $i < CLASSES; $i++) {
$class = "QhtExample\\Gen\\Service$i";
$app->container->bind($class, static fn (Container $c) => $i > 0
? new $class($c->get('QhtExample\\Gen\\Service' . ($i - 1)))
: new $class());
}
// Eagerly resolve services, as providers do at boot.
$app->container->get('QhtExample\\Gen\\Service' . (CLASSES - 1));
for ($i = 0; $i < ROUTES; $i++) {
$pattern = "/api/r$i/{id}/items/{item}";
$regex = '#^' . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $pattern) . '$#';
$app->routes[] = [$regex, 'GET', "route$i"];
}
$app->routes[] = ['#^/api/users/(?P<id>\d+)$#', 'GET', 'user'];
$app->routes[] = ['#^/hello$#', 'GET', 'hello'];
return $app;
}
/** @return array<string, mixed> */
public function handle(string $method, string $path): array
{
foreach ($this->routes as [$regex, $m, $name]) {
if ($m === $method && preg_match($regex, $path, $args)) {
$svc = $this->container->get('QhtExample\\Gen\\Service42');
return [
'route' => $name,
'args' => array_filter($args, 'is_string', ARRAY_FILTER_USE_KEY),
'service' => $svc->name(),
'debug' => $this->config['c3']['key_7']['enabled'],
];
}
}
return ['error' => 'not found'];
}
}Each request calls 3 services that take 20 ms. The classic version waits ~60 ms with the process blocked; the worker uses async() + all() and answers in ~20 ms while serving other requests. backend.php is the fake slow service (?ms=N, ?fail=1).
Mỗi request gọi 3 service, mỗi service mất 20 ms. Bản truyền thống chờ ~60 ms và tiến trình bị chặn; bản worker dùng async() + all(), trả lời trong ~20 ms và vẫn phục vụ request khác. backend.php là service chậm giả lập (?ms=N, ?fail=1).
php runtime/bin/qhturbo-serve examples/03-async-io/backend.php --port=9000 --concurrency=1024 &
php runtime/bin/qhturbo-serve examples/03-async-io/app.php --port=8080 --workers=2 --concurrency=64
curl localhost:8080/
bash examples/compare.sh async-ioexamples/03-async-io/app.php20 linesdòng
<?php
// QHturbo Performance: the 3 backend calls run in parallel (~20 ms, not ~60 ms),
// and while they wait the worker serves other requests.
// php runtime/bin/qhturbo-serve examples/03-async-io/app.php --port=8080 --workers=4 --concurrency=64
use QHTurbo\Runtime\Http\Client;
use QHTurbo\Runtime\Http\Request;
use function QHTurbo\Runtime\Async\{all, async, timeout};
$backend = getenv('QHT_BACKEND') ?: 'http://127.0.0.1:9000';
return static function (Request $r) use ($backend): array {
$t = hrtime(true);
$calls = [];
foreach (['users', 'orders', 'stats'] as $name) {
$calls[$name] = timeout(async(static fn () => Client::get("$backend/$name")), 2.0);
}
$out = array_map(static fn (array $res) => json_decode($res['body'], true), all($calls));
return ['took_ms' => round((hrtime(true) - $t) / 1e6, 1), 'results' => $out];
};examples/03-async-io/fpm.php12 linesdòng
<?php
// Classic PHP: 3 backend calls one after another, the process is blocked
// while it waits (3 x 20 ms = ~60 ms per request).
$backend = getenv('QHT_BACKEND') ?: 'http://127.0.0.1:9000';
$t = hrtime(true);
$out = [];
foreach (['users', 'orders', 'stats'] as $name) {
$out[$name] = json_decode((string) file_get_contents("$backend/$name"), true);
}
header('Content-Type: application/json');
echo json_encode(['took_ms' => round((hrtime(true) - $t) / 1e6, 1), 'results' => $out]);examples/03-async-io/backend.php20 linesdòng
<?php
// A slow internal service: every call takes QHT_BACKEND_MS (default 20 ms),
// or ?ms=N for that call; ?fail=1 answers 500.
// Uses delay(), so one process answers many calls at once:
// php runtime/bin/qhturbo-serve examples/03-async-io/backend.php --port=9000 --concurrency=1024
use QHTurbo\Runtime\Http\Request;
use QHTurbo\Runtime\Http\Response;
use function QHTurbo\Runtime\Async\delay;
$default = (int) (getenv('QHT_BACKEND_MS') ?: 20);
return static function (Request $r) use ($default): Response|array {
$ms = isset($r->query['ms']) ? max(0, min(10000, (int) $r->query['ms'])) : $default;
delay($ms / 1000);
if (!empty($r->query['fail'])) {
return Response::json(['error' => 'backend failure'], 500);
}
return ['service' => $r->path(), 'ms' => $ms, 'value' => random_int(1, 1000)];
};A shop API ready for a real server. shop.php holds the routes and SQL shared by both sides; app.php runs independent queries in parallel on a connection pool and wraps orders in $db->transaction(); fpm.php does the same with PDO. Routes: /health, /products, /products/{id}, /dashboard, POST /orders, /orders/random, /slow.
API cửa hàng sẵn sàng cho máy chủ thật. shop.php chứa route và SQL dùng chung cho cả hai bên; app.php chạy song song các truy vấn độc lập trên pool kết nối và bọc việc tạo đơn trong $db->transaction(); fpm.php làm y hệt bằng PDO. Các route: /health, /products, /products/{id}, /dashboard, POST /orders, /orders/random, /slow.
cd examples/04-mysql
cp .env.example .env && nano .env
sudo bash deploy/setup.sh # database + data + systemd worker
sudo cp deploy/nginx-shop.conf /etc/nginx/conf.d/qht-shop.conf && sudo nginx -s reload
curl -X POST http://127.0.0.1/worker/orders -H 'Content-Type: application/json' \
-d '{"user_id":3,"items":[{"product_id":7,"qty":2}]}'
bash bench-server.sh http://127.0.0.1/fpm http://127.0.0.1/workerexamples/04-mysql/app.php107 linesdòng
<?php
// QHturbo Performance + async MySQL: the shop API.
// Queries that do not depend on each other run in parallel on pooled connections,
// and while MySQL works the worker serves other requests (--concurrency > 1).
//
// php /opt/qhturbo-src/runtime/bin/qhturbo-serve app.php --port=8080 --workers=4 --concurrency=32
// or as a service: deploy/qhturbo-shop.service
declare(strict_types=1);
use QHTurbo\Runtime\Http\Request;
use QHTurbo\Runtime\Http\Response;
use QHTurbo\Runtime\Mysql\Client as Mysql;
use QHTurbo\Runtime\Mysql\Connection;
use QHTurbo\Runtime\Mysql\QueryException;
use QhtShop as Shop;
use function QHTurbo\Runtime\Async\{all, async};
require_once __DIR__ . '/shop.php';
$config = require __DIR__ . '/config.php';
// Booted once per worker: the pool, and the id ranges used by /orders/random.
$db = new Mysql($config['mysql_uri']);
$slow = $config['slow_ms'] / 1000;
$maxUser = (int) $db->query('SELECT COALESCE(MAX(id), 1) FROM users')->value();
$maxProduct = (int) $db->query('SELECT COALESCE(MAX(id), 1) FROM products')->value();
$q = static fn (string $sql, array $params = []) => async(static fn () => $db->query(Shop\SQL[$sql], $params));
$handlers = [
'products' => static function (Request $r) use ($q): array {
[$limit, $offset] = Shop\paging($r->query);
[$rows, $count] = all([$q('products', [$limit, $offset]), $q('productCount')]);
return ['total' => (int) $count->value(), 'limit' => $limit, 'offset' => $offset, 'items' => $rows->rows];
},
'product' => static function (Request $r, array $args) use ($q): Response|array {
$id = (int) $args[0];
[$product, $related, $stats] = all([$q('product', [$id]), $q('related', [$id, $id]), $q('reviewStats', [$id])]);
if ($product->first() === null) {
return Response::json(['error' => 'not found'], 404);
}
return ['product' => $product->first(), 'reviews' => $stats->first(), 'related' => $related->rows];
},
'dashboard' => static function () use ($q): array {
$r = all([
'users' => $q('users'), 'sales24h' => $q('sales24h'), 'topProducts' => $q('topProducts'),
'latestOrders' => $q('latestOrders'), 'lowStock' => $q('lowStock'),
]);
return [
'users' => (int) $r['users']->value(),
'sales_24h' => $r['sales24h']->first(),
'top_products' => $r['topProducts']->rows,
'latest_orders' => $r['latestOrders']->rows,
'low_stock' => $r['lowStock']->rows,
];
},
'createOrder' => static function (Request $r) use ($db, $maxUser, $maxProduct): Response|array {
try {
$in = Shop\orderInput($r->path(), $r->parsedBody(), $maxUser, $maxProduct);
// One connection, BEGIN ... COMMIT; any exception rolls back.
return $db->transaction(static function (Connection $c) use ($in): array {
$rows = $c->query(Shop\SQL['lockProducts'], [array_keys($in['items'])])->rows;
$total = Shop\priceOrder($rows, $in['items']);
$orderId = (int) $c->query(Shop\SQL['insertOrder'], [$in['user_id'], $total])->insertId;
$prices = array_column($rows, 'price', 'id');
foreach ($in['items'] as $pid => $qty) {
$c->query(Shop\SQL['insertItem'], [$orderId, $pid, $qty, $prices[$pid]]);
$c->query(Shop\SQL['takeStock'], [$qty, $pid]);
}
return ['order_id' => $orderId, 'total' => $total, 'items' => $in['items']];
});
} catch (\InvalidArgumentException $e) {
return Response::json(['error' => $e->getMessage()], 422);
} catch (\DomainException $e) {
return Response::json(['error' => $e->getMessage()], 409);
}
},
'slow' => static function () use ($q, $slow): array {
$rows = all([$q('slow', [1, $slow]), $q('slow', [2, $slow]), $q('slow', [3, $slow])]);
return ['queries' => 3, 'each_s' => $slow, 'rows' => array_map(static fn ($res) => $res->first(), $rows)];
},
'health' => static fn () => ['ok' => true, 'mode' => 'qhturbo-worker', 'pid' => getmypid(), 'pool' => $db->poolStats()],
];
return static function (Request $r) use ($handlers): Response|array {
$match = Shop\route($r->method, $r->path());
if ($match === null) {
return Response::json(['error' => 'not found', 'routes' => array_map(static fn ($x) => "$x[0] " . trim($x[1], '#^$'), Shop\ROUTES)], 404);
}
$t = hrtime(true);
try {
$result = $handlers[$match[0]]($r, $match[1]);
} catch (QueryException $e) {
error_log('[shop] ' . $e->getMessage());
return Response::json(['error' => 'database error', 'code' => $e->getCode()], 500);
}
if ($result instanceof Response) {
return $result;
}
return ['took_ms' => round((hrtime(true) - $t) / 1e6, 2)] + $result;
};examples/04-mysql/shop.php116 linesdòng
<?php
// Routes and SQL shared by app.php (async worker) and fpm.php (PDO), so both
// sides run exactly the same queries.
declare(strict_types=1);
namespace QhtShop;
const ROUTES = [
['GET', '#^/products$#', 'products'], // ?page=1&per=20
['GET', '#^/products/(\d+)$#', 'product'], // product + related + review stats
['GET', '#^/dashboard$#', 'dashboard'], // 5 aggregate queries
['POST', '#^/orders$#', 'createOrder'], // JSON {"user_id":1,"items":[{"product_id":2,"qty":1}]}
['GET', '#^/orders/random$#', 'createOrder'], // benchmark helper: random order (writes to the DB!)
['GET', '#^/slow$#', 'slow'], // 3 x SLEEP(SLOW_MS): pure I/O wait
['GET', '#^/health$#', 'health'],
];
const SQL = [
'products' => 'SELECT p.id, p.name, p.price, p.stock,
(SELECT c.name FROM categories c WHERE c.id = p.category_id) AS category
FROM products p ORDER BY p.id LIMIT ? OFFSET ?',
'productCount' => 'SELECT COUNT(*) FROM products',
'product' => 'SELECT p.*, c.name AS category FROM products p JOIN categories c ON c.id = p.category_id WHERE p.id = ?',
'related' => 'SELECT id, name, price FROM products
WHERE category_id = (SELECT category_id FROM products WHERE id = ?) AND id <> ?
ORDER BY id DESC LIMIT 5',
'reviewStats' => 'SELECT COUNT(*) AS reviews, ROUND(AVG(rating), 2) AS rating FROM reviews WHERE product_id = ?',
'users' => 'SELECT COUNT(*) FROM users',
'sales24h' => 'SELECT COUNT(*) AS orders, COALESCE(SUM(total), 0) AS revenue
FROM orders WHERE created_at >= NOW() - INTERVAL 1 DAY',
'topProducts' => 'SELECT p.id, p.name, SUM(oi.qty) AS sold
FROM orders o JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id
WHERE o.created_at >= NOW() - INTERVAL 1 DAY
GROUP BY p.id, p.name ORDER BY sold DESC LIMIT 5',
'latestOrders' => 'SELECT o.id, (SELECT u.name FROM users u WHERE u.id = o.user_id) AS customer, o.total, o.created_at
FROM orders o ORDER BY o.id DESC LIMIT 10',
'lowStock' => 'SELECT id, name, stock FROM products WHERE stock < 5 ORDER BY stock, id LIMIT 10',
'slow' => 'SELECT ? AS n, SLEEP(?) AS slept',
// createOrder, inside one transaction:
'lockProducts' => 'SELECT id, price, stock FROM products WHERE id IN (?) FOR UPDATE',
'insertOrder' => "INSERT INTO orders (user_id, total, status) VALUES (?, ?, 'paid')",
'insertItem' => 'INSERT INTO order_items (order_id, product_id, qty, price) VALUES (?, ?, ?, ?)',
'takeStock' => 'UPDATE products SET stock = stock - ? WHERE id = ?',
];
/** @return array{0: string, 1: list<string>}|null handler name and captured args */
function route(string $method, string $path): ?array
{
foreach (ROUTES as [$m, $regex, $name]) {
if ($m === $method && preg_match($regex, $path, $args)) {
return [$name, array_slice($args, 1)];
}
}
return null;
}
/** @return array{0: int, 1: int} [limit, offset] */
function paging(array $query): array
{
$per = max(1, min(100, (int) ($query['per'] ?? 20)));
$page = max(1, (int) ($query['page'] ?? 1));
return [$per, ($page - 1) * $per];
}
/**
* The order to create: from the JSON body, or random for /orders/random.
*
* @return array{user_id: int, items: array<int, int>} product_id => qty
*/
function orderInput(string $path, array $body, int $maxUser, int $maxProduct): array
{
if ($path === '/orders/random') {
$items = [];
for ($i = random_int(1, 3); $i > 0; $i--) {
$items[random_int(1, $maxProduct)] = 1;
}
return ['user_id' => random_int(1, $maxUser), 'items' => $items];
}
$items = [];
foreach ((array) ($body['items'] ?? []) as $it) {
$pid = (int) ($it['product_id'] ?? 0);
$qty = (int) ($it['qty'] ?? 0);
if ($pid > 0 && $qty > 0 && $qty <= 100) {
$items[$pid] = ($items[$pid] ?? 0) + $qty;
}
}
if ($items === [] || (int) ($body['user_id'] ?? 0) < 1) {
throw new \InvalidArgumentException('expected {"user_id": N, "items": [{"product_id": N, "qty": N}]}');
}
ksort($items); // lock rows in a fixed order: avoids deadlocks between concurrent orders
return ['user_id' => (int) $body['user_id'], 'items' => $items];
}
/**
* Check stock for the locked rows and compute the total.
*
* @param list<array{id: int|string, price: string, stock: int|string}> $rows
* @param array<int, int> $items
*/
function priceOrder(array $rows, array $items): float
{
$byId = array_column($rows, null, 'id');
$total = 0.0;
foreach ($items as $pid => $qty) {
if (!isset($byId[$pid])) {
throw new \DomainException("product $pid does not exist");
}
if ((int) $byId[$pid]['stock'] < $qty) {
throw new \DomainException("product $pid is out of stock");
}
$total += (float) $byId[$pid]['price'] * $qty;
}
return round($total, 2);
}examples/04-mysql/fpm.php123 linesdòng
<?php
// The same shop API the classic way: PHP-FPM + PDO, every query waits for the
// previous one, and the process is blocked while MySQL works.
// nginx: SCRIPT_FILENAME = this file (see deploy/nginx-shop.conf). Requests
// may come with a "/fpm" prefix, which is stripped.
declare(strict_types=1);
use QhtShop as Shop;
require_once __DIR__ . '/shop.php';
$config = require __DIR__ . '/config.php';
$t = hrtime(true);
$path = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$path = preg_replace('#^/fpm(?=/|$)#', '', $path) ?: '/';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
function respond(int $status, array $data): never
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
$match = Shop\route($method, $path);
if ($match === null) {
respond(404, ['error' => 'not found', 'routes' => array_map(static fn ($x) => "$x[0] " . trim($x[1], '#^$'), Shop\ROUTES)]);
}
$pdo = new PDO($config['pdo_dsn'], $config['db_user'], $config['db_pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$run = static function (string $sql, array $params = []) use ($pdo): PDOStatement {
$st = $pdo->prepare(Shop\SQL[$sql]);
foreach (array_values($params) as $i => $v) {
$st->bindValue($i + 1, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$st->execute();
return $st;
};
$args = $match[1];
try {
$result = match ($match[0]) {
'products' => (static function () use ($run) {
[$limit, $offset] = Shop\paging($_GET);
$items = $run('products', [$limit, $offset])->fetchAll();
return ['total' => (int) $run('productCount')->fetchColumn(), 'limit' => $limit, 'offset' => $offset, 'items' => $items];
})(),
'product' => (static function () use ($run, $args) {
$id = (int) $args[0];
$product = $run('product', [$id])->fetch();
if ($product === false) {
respond(404, ['error' => 'not found']);
}
return ['product' => $product, 'reviews' => $run('reviewStats', [$id])->fetch(), 'related' => $run('related', [$id, $id])->fetchAll()];
})(),
'dashboard' => [
'users' => (int) $run('users')->fetchColumn(),
'sales_24h' => $run('sales24h')->fetch(),
'top_products' => $run('topProducts')->fetchAll(),
'latest_orders' => $run('latestOrders')->fetchAll(),
'low_stock' => $run('lowStock')->fetchAll(),
],
'createOrder' => (static function () use ($pdo, $run, $path) {
$body = json_decode((string) file_get_contents('php://input'), true) ?: [];
try {
$maxUser = (int) $pdo->query('SELECT COALESCE(MAX(id), 1) FROM users')->fetchColumn();
$maxProduct = (int) $pdo->query('SELECT COALESCE(MAX(id), 1) FROM products')->fetchColumn();
$in = Shop\orderInput($path, $body, $maxUser, $maxProduct);
} catch (InvalidArgumentException $e) {
respond(422, ['error' => $e->getMessage()]);
}
$ids = array_keys($in['items']);
// PDO cannot bind an array to IN (?): expand the placeholder.
$lock = $pdo->prepare(str_replace('IN (?)', 'IN (' . implode(',', array_fill(0, count($ids), '?')) . ')', Shop\SQL['lockProducts']));
$pdo->beginTransaction();
try {
$lock->execute($ids);
$rows = $lock->fetchAll();
$total = Shop\priceOrder($rows, $in['items']);
$run('insertOrder', [$in['user_id'], (string) $total]);
$orderId = (int) $pdo->lastInsertId();
$prices = array_column($rows, 'price', 'id');
foreach ($in['items'] as $pid => $qty) {
$run('insertItem', [$orderId, $pid, $qty, (string) $prices[$pid]]);
$run('takeStock', [$qty, $pid]);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
if ($e instanceof DomainException) {
respond(409, ['error' => $e->getMessage()]);
}
throw $e;
}
return ['order_id' => $orderId, 'total' => $total, 'items' => $in['items']];
})(),
'slow' => (static function () use ($run, $config) {
$s = $config['slow_ms'] / 1000;
$rows = [];
for ($i = 1; $i <= 3; $i++) {
$rows[] = $run('slow', [$i, (string) $s])->fetch();
}
return ['queries' => 3, 'each_s' => $s, 'rows' => $rows];
})(),
'health' => ['ok' => true, 'mode' => 'php-fpm', 'pid' => getmypid()],
};
} catch (PDOException $e) {
error_log('[shop] ' . $e->getMessage());
respond(500, ['error' => 'database error', 'code' => $e->getCode()]);
}
respond(200, ['took_ms' => round((hrtime(true) - $t) / 1e6, 2)] + $result);examples/04-mysql/config.php43 linesdòng
<?php
// Shared configuration for app.php (QHturbo worker) and fpm.php (PHP-FPM).
// Values come from .env next to this file, then from real environment variables.
declare(strict_types=1);
return (static function (): array {
$env = [];
$file = __DIR__ . '/.env';
if (is_file($file)) {
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if ($line[0] === '#' || !str_contains($line, '=')) {
continue;
}
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim(trim($v), "\"'");
}
}
$get = static fn (string $k, string $default = '') => getenv($k) !== false ? (string) getenv($k) : ($env[$k] ?? $default);
$host = $get('DB_HOST', '127.0.0.1');
$port = (int) $get('DB_PORT', '3306');
$name = $get('DB_NAME', 'qht_shop');
$user = $get('DB_USER', 'qht');
$pass = $get('DB_PASS');
$socket = $get('DB_SOCKET');
$pool = max(1, (int) $get('DB_POOL', '16'));
return [
// QHturbo async client: mysql://user:pass@host:port/db?pool=N[&socket=...]
'mysql_uri' => sprintf('mysql://%s:%s@%s:%d/%s?charset=utf8mb4&pool=%d%s',
rawurlencode($user), rawurlencode($pass), $host, $port, rawurlencode($name), $pool,
$socket !== '' ? '&socket=' . rawurlencode($socket) : ''),
// PDO for fpm.php and seed.php
'pdo_dsn' => $socket !== ''
? "mysql:unix_socket=$socket;dbname=$name;charset=utf8mb4"
: "mysql:host=$host;port=$port;dbname=$name;charset=utf8mb4",
'db_user' => $user,
'db_pass' => $pass,
'slow_ms' => max(0, (int) $get('SLOW_MS', '20')),
];
})();examples/04-mysql/.env.example17 linesdòng
# Copy to .env and edit. Read by config.php (both apps) and by the systemd unit.
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=qht_shop
DB_USER=qht
DB_PASS=change-me
# Unix socket instead of TCP, e.g. /run/mysqld/mysqld.sock or /tmp/mysql.sock (aaPanel). Empty = TCP.
DB_SOCKET=
# Async client: connections per worker (total = WORKERS x DB_POOL, keep under MySQL max_connections)
DB_POOL=16
# /slow route: each of its 3 queries runs SLEEP(SLOW_MS / 1000)
SLOW_MS=20
# qhturbo-serve (used by deploy/qhturbo-shop.service)
APP_PORT=8080
WORKERS=4
CONCURRENCY=32examples/04-mysql/schema.sql69 linesdòng
-- QHturbo shop example schema (MySQL 5.7+/8.x, MariaDB 10.3+).
-- mysql -u root -p < schema.sql
-- Then create the user the apps connect with (edit the password):
-- CREATE USER 'qht'@'localhost' IDENTIFIED BY 'change-me';
-- GRANT ALL ON qht_shop.* TO 'qht'@'localhost';
CREATE DATABASE IF NOT EXISTS qht_shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE qht_shop;
DROP TABLE IF EXISTS reviews, order_items, orders, products, categories, users;
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(190) NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE categories (
id SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
category_id SMALLINT UNSIGNED NOT NULL,
name VARCHAR(190) NOT NULL,
price DECIMAL(10,2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_category (category_id),
KEY idx_stock (stock),
CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES categories (id)
) ENGINE=InnoDB;
CREATE TABLE orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
total DECIMAL(12,2) NOT NULL,
status ENUM('pending','paid','shipped','cancelled') NOT NULL DEFAULT 'paid',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_created (created_at),
KEY idx_user (user_id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users (id)
) ENGINE=InnoDB;
CREATE TABLE order_items (
order_id INT UNSIGNED NOT NULL,
product_id INT UNSIGNED NOT NULL,
qty SMALLINT UNSIGNED NOT NULL,
price DECIMAL(10,2) NOT NULL,
PRIMARY KEY (order_id, product_id),
KEY idx_product (product_id),
CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
CONSTRAINT fk_items_product FOREIGN KEY (product_id) REFERENCES products (id)
) ENGINE=InnoDB;
CREATE TABLE reviews (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
rating TINYINT UNSIGNED NOT NULL,
body VARCHAR(500) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_product (product_id),
CONSTRAINT fk_reviews_product FOREIGN KEY (product_id) REFERENCES products (id),
CONSTRAINT fk_reviews_user FOREIGN KEY (user_id) REFERENCES users (id)
) ENGINE=InnoDB;examples/04-mysql/seed.php77 linesdòng
<?php
// Fill the qht_shop database with test data (run once, after schema.sql).
// php seed.php 10k users, 5k products, 50k orders, 20k reviews
// php seed.php --scale=0.2 a fifth of that (quick try)
// php seed.php --scale=5 5x (closer to a real shop)
// Uses .env / config.php. Orders are spread over the last 30 days.
declare(strict_types=1);
$opts = getopt('', ['scale:']);
$scale = max(0.01, (float) ($opts['scale'] ?? 1));
$n = static fn (int $base) => max(10, (int) round($base * $scale));
[$users, $products, $orders, $reviews] = [$n(10000), $n(5000), $n(50000), $n(20000)];
$config = require __DIR__ . '/config.php';
$pdo = new PDO($config['pdo_dsn'], $config['db_user'], $config['db_pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
foreach (['reviews', 'order_items', 'orders', 'products', 'categories', 'users'] as $t) {
$pdo->exec("TRUNCATE TABLE $t");
}
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
/** Multi-row INSERT in batches of 1000 rows. */
function insert(PDO $pdo, string $table, array $cols, int $count, callable $row): void
{
$t = microtime(true);
$one = '(' . implode(',', array_fill(0, count($cols), '?')) . ')';
for ($done = 0; $done < $count; $done += 1000) {
$batch = min(1000, $count - $done);
$values = [];
for ($i = 1; $i <= $batch; $i++) {
array_push($values, ...$row($done + $i));
}
$pdo->prepare("INSERT INTO $table (" . implode(',', $cols) . ') VALUES ' . implode(',', array_fill(0, $batch, $one)))
->execute($values);
}
printf(" %-12s %8s rows %5.1f s\n", $table, number_format($count), microtime(true) - $t);
}
$ago = static fn (int $maxDays) => date('Y-m-d H:i:s', time() - random_int(0, $maxDays * 86400));
$cats = ['Laptop', 'Phone', 'Tablet', 'Audio', 'Camera', 'Gaming', 'Network', 'Storage', 'Monitor', 'Accessory'];
echo "Seeding (scale $scale)\n";
$pdo->beginTransaction();
insert($pdo, 'categories', ['name'], count($cats), static fn (int $i) => [$cats[$i - 1]]);
insert($pdo, 'users', ['name', 'email', 'created_at'], $users,
static fn (int $i) => ["User $i", "[email protected]", $ago(365)]);
insert($pdo, 'products', ['category_id', 'name', 'price', 'stock', 'created_at'], $products,
static fn (int $i) => [random_int(1, count($cats)), $cats[$i % count($cats)] . " model $i",
number_format(random_int(500, 300000) / 100, 2, '.', ''), random_int(0, 50) < 2 ? random_int(0, 4) : random_int(50, 1000), $ago(365)]);
$pdo->commit();
// Orders and their items: prices are computed in PHP so the totals add up.
$pdo->beginTransaction();
$price = [];
foreach ($pdo->query('SELECT id, price FROM products') as $p) {
$price[(int) $p['id']] = (float) $p['price'];
}
$items = [];
insert($pdo, 'orders', ['user_id', 'total', 'status', 'created_at'], $orders, static function (int $i) use (&$items, $price, $products, $users, $ago) {
$total = 0.0;
$picked = [];
for ($k = random_int(1, 4); $k > 0; $k--) {
$picked[random_int(1, $products)] = random_int(1, 3);
}
foreach ($picked as $pid => $qty) {
$items[] = [$i, $pid, $qty, number_format($price[$pid], 2, '.', '')];
$total += $price[$pid] * $qty;
}
return [random_int(1, $users), number_format($total, 2, '.', ''), ['paid', 'paid', 'shipped', 'cancelled'][random_int(0, 3)], $ago(30)];
});
insert($pdo, 'order_items', ['order_id', 'product_id', 'qty', 'price'], count($items), static fn (int $i) => $items[$i - 1]);
insert($pdo, 'reviews', ['product_id', 'user_id', 'rating', 'body', 'created_at'], $reviews,
static fn (int $i) => [random_int(1, $products), random_int(1, $users), random_int(1, 5), "Review $i: " . str_repeat('good ', random_int(2, 20)), $ago(180)]);
$pdo->commit();
echo "Done.\n";examples/04-mysql/deploy/setup.sh31 linesdòng
#!/usr/bin/env bash
# One-shot setup of the shop example on a test server (run as root from 04-mysql/).
# sudo bash deploy/setup.sh uses .env (created from .env.example if missing)
# SCALE=0.2 sudo -E bash deploy/setup.sh smaller test data
# Steps: create database + user, load schema, seed data, install + start the
# systemd worker. nginx is left to you (deploy/nginx-shop.conf).
set -euo pipefail
cd "$(dirname "$0")/.."
[ -f .env ] || { cp .env.example .env; sed -i "s/^DB_PASS=.*/DB_PASS=$(head -c 12 /dev/urandom | base64 | tr -dc A-Za-z0-9)/" .env; echo "created .env with a random DB_PASS"; }
set -a; . ./.env; set +a
PHP="${PHP:-/opt/qhturbo/8.5/bin/php}"; [ -x "$PHP" ] || PHP=php
MYSQL="${MYSQL:-mysql}" # aaPanel: /www/server/mysql/bin/mysql ; add -p if root needs a password
echo "== database $DB_NAME, user $DB_USER"
$MYSQL < schema.sql
$MYSQL -e "CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';
CREATE USER IF NOT EXISTS '$DB_USER'@'127.0.0.1' IDENTIFIED BY '$DB_PASS';
GRANT ALL ON \`$DB_NAME\`.* TO '$DB_USER'@'localhost';
GRANT ALL ON \`$DB_NAME\`.* TO '$DB_USER'@'127.0.0.1'; FLUSH PRIVILEGES;"
echo "== test data"
"$PHP" seed.php --scale="${SCALE:-1}"
echo "== worker service"
sed "s#/opt/qhturbo/8.5/bin/php#$("$PHP" -r 'echo PHP_BINARY;')#" deploy/qhturbo-shop.service > /etc/systemd/system/qhturbo-shop.service
id www-data >/dev/null 2>&1 || sed -i 's/^User=www-data/User=www/; s/^Group=www-data/Group=www/' /etc/systemd/system/qhturbo-shop.service
chmod 640 .env; chgrp "$(sed -n 's/^Group=//p' /etc/systemd/system/qhturbo-shop.service)" .env
systemctl daemon-reload && systemctl enable --now qhturbo-shop && sleep 2
curl -fsS "http://127.0.0.1:$APP_PORT/health" && echo && echo "worker OK on 127.0.0.1:$APP_PORT"
echo "Next: add deploy/nginx-shop.conf to nginx, then: bash bench-server.sh http://<host>/fpm http://<host>/worker"examples/04-mysql/deploy/nginx-shop.conf37 linesdòng
# nginx: one site serving the same shop API two ways, for side-by-side tests.
# /fpm/... -> PHP-FPM running fpm.php (classic, PDO)
# /worker/... -> qhturbo-serve running app.php (async MySQL)
# Copy to /etc/nginx/conf.d/qht-shop.conf (aaPanel: add the two location blocks
# to the site's config), edit server_name, the paths and the FPM socket,
# then: nginx -t && nginx -s reload
upstream qht_shop_worker {
server 127.0.0.1:8080; # APP_PORT in .env
keepalive 64;
}
server {
listen 80;
server_name shop-test.example.com; # or an IP; restrict access while testing
# Optional: only allow your own IP while benchmarking
# allow 203.0.113.10; deny all;
location /fpm/ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /opt/qhturbo-src/examples/04-mysql/fpm.php;
# QHturbo PHP-FPM: /run/qhturbo/php85-fpm.sock
# aaPanel PHP 8.3: /tmp/php-cgi-83.sock Ubuntu php8.3-fpm: /run/php/php8.3-fpm.sock
fastcgi_pass unix:/run/qhturbo/php85-fpm.sock;
}
location /worker/ {
proxy_pass http://qht_shop_worker/; # trailing slash strips the /worker prefix
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
}
}examples/04-mysql/deploy/qhturbo-shop.service29 linesdòng
# systemd unit for the async shop worker.
# sudo cp deploy/qhturbo-shop.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now qhturbo-shop
# journalctl -u qhturbo-shop -f # logs
# sudo systemctl reload qhturbo-shop # zero-downtime reload after editing app.php
# Edit the paths if your PHP or checkout live elsewhere. Settings come from .env.
[Unit]
Description=QHturbo shop example (async MySQL worker)
After=network.target mysql.service mariadb.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/qhturbo-src/examples/04-mysql
EnvironmentFile=/opt/qhturbo-src/examples/04-mysql/.env
ExecStart=/opt/qhturbo/8.5/bin/php /opt/qhturbo-src/runtime/bin/qhturbo-serve /opt/qhturbo-src/examples/04-mysql/app.php \
--host=127.0.0.1 --port=${APP_PORT} --workers=${WORKERS} --concurrency=${CONCURRENCY} \
--max-requests=100000 --memory-limit=256M
ExecReload=/bin/kill -HUP $MAINPID
KillSignal=SIGTERM
TimeoutStopSec=70
Restart=on-failure
RestartSec=2
LimitNOFILE=65536
[Install]
WantedBy=multi-user.targetexamples/04-mysql/bench-server.sh39 linesdòng
#!/usr/bin/env bash
# Benchmark the shop API on a real server: PHP-FPM + PDO vs QHturbo workers + async MySQL.
#
# bash bench-server.sh http://127.0.0.1/fpm http://127.0.0.1/worker
# C=128 D=20 P=2 WRITE=1 bash bench-server.sh <FPM_URL> <WORKER_URL>
#
# C = connections (64), D = seconds per test (15), P = client processes (1),
# WRITE=1 also benchmarks /orders/random (creates real orders in the database).
# Run it from another machine on the same network if you can; otherwise keep
# in mind that the client uses CPU on the server too.
set -euo pipefail
cd "$(dirname "$0")"
FPM_URL="${1:?usage: bench-server.sh FPM_URL WORKER_URL}"; WORKER_URL="${2:?usage: bench-server.sh FPM_URL WORKER_URL}"
PHP="${PHP:-php}"; C="${C:-64}"; D="${D:-15}"; P="${P:-1}"
BENCH="${BENCH:-../bench.php}"
MAXP=$("$PHP" -r '$c = require "config.php"; $p = new PDO($c["pdo_dsn"], $c["db_user"], $c["db_pass"]); echo $p->query("SELECT MAX(id) FROM products")->fetchColumn();' 2>/dev/null || echo 5000)
ROUTES=("/health" "/products?page={rand:50}" "/products/{rand:$MAXP}" "/dashboard" "/slow")
[ "${WRITE:-0}" = 1 ] && ROUTES+=("/orders/random")
for base in "$FPM_URL" "$WORKER_URL"; do
curl -fsS "$base/health" >/dev/null || { echo "cannot reach $base/health"; exit 1; }
done
printf "\n%-26s | %-36s | %-36s | %s\n" "route ($C conn, ${D}s)" "PHP-FPM + PDO" "QHturbo + async MySQL" "speed-up"
printf -- "-%.0s" {1..118}; echo
for r in "${ROUTES[@]}"; do
a=$("$PHP" "$BENCH" "$FPM_URL$r" -c "$C" -d "$D" -p "$P" --json)
b=$("$PHP" "$BENCH" "$WORKER_URL$r" -c "$C" -d "$D" -p "$P" --json)
"$PHP" -r '
[$r, $a, $b] = [$argv[1], json_decode($argv[2], true), json_decode($argv[3], true)];
$f = fn ($j) => sprintf("%9s req/s p99 %7.1f ms e%d", number_format($j["rps"], 1), $j["latency_ms"]["p99"] ?? 0,
$j["errors"] + array_sum(array_filter($j["status"], fn ($k) => $k >= 500, ARRAY_FILTER_USE_KEY)));
printf("%-26s | %-36s | %-36s | %5.1fx\n", $r, $f($a), $f($b), $b["rps"] / max(0.1, $a["rps"]));
' "$r" "$a" "$b"
done
echo
echo "e = transport errors + 5xx answers. Run each test 2-3 times; numbers on shared VMs move by ~10%."Pure CPU work (recursion, float math, loops, arrays, strings, JSON, objects). --compare re-runs itself as interpreter, OPcache and JIT; --php=a,b compares two PHP binaries.
Tác vụ thuần CPU (đệ quy, tính số thực, vòng lặp, mảng, chuỗi, JSON, object). --compare tự chạy lại ở ba chế độ thông dịch, OPcache và JIT; --php=a,b so hai bản PHP.
php examples/05-cpu-jit/cpu.php --compare
php examples/05-cpu-jit/cpu.php --compare --php=/usr/bin/php,/opt/qhturbo/8.5/bin/phpexamples/05-cpu-jit/cpu.php151 linesdòng
<?php
// CPU micro-benchmarks: measure the PHP interpreter itself, with or without JIT,
// or compare two PHP binaries (e.g. the system PHP vs /opt/qhturbo/8.5/bin/php).
//
// php cpu.php run once with the current settings
// php cpu.php --compare interpreter vs OPcache vs JIT (tracing), same binary
// php cpu.php --compare --php=/usr/bin/php8.3,/opt/qhturbo/8.5/bin/php
// php cpu.php --runs=5 --quick --json
declare(strict_types=1);
$opts = getopt('', ['compare', 'php:', 'runs:', 'quick', 'json', 'only:']);
$runs = max(1, (int) ($opts['runs'] ?? 3));
$scale = isset($opts['quick']) ? 0.25 : 1.0;
/** @return array<string, \Closure(): mixed> */
function workloads(float $scale): array
{
return [
'fib (recursion)' => static function () use ($scale) {
$fib = static function (int $n) use (&$fib): int { return $n < 2 ? $n : $fib($n - 1) + $fib($n - 2); };
return $fib($scale < 1 ? 25 : 28);
},
'nbody (float math)' => static function () use ($scale) {
$b = [[0, 0, 0, 0, 0, 0, 39.47], [4.84, -1.16, -0.10, 0.60, 2.81, -0.02, 0.037],
[8.34, 4.12, -0.40, -1.01, 1.82, 0.008, 0.011], [12.89, -15.11, -0.22, 1.08, 0.86, -0.01, 0.0017],
[15.37, -25.91, 0.17, 0.97, 0.59, -0.03, 0.002]];
$n = count($b);
for ($s = 0, $steps = (int) (200000 * $scale); $s < $steps; $s++) {
for ($i = 0; $i < $n; $i++) {
for ($j = $i + 1; $j < $n; $j++) {
$dx = $b[$i][0] - $b[$j][0]; $dy = $b[$i][1] - $b[$j][1]; $dz = $b[$i][2] - $b[$j][2];
$d2 = $dx * $dx + $dy * $dy + $dz * $dz; $mag = 0.01 / ($d2 * sqrt($d2));
$b[$i][3] -= $dx * $b[$j][6] * $mag; $b[$i][4] -= $dy * $b[$j][6] * $mag; $b[$i][5] -= $dz * $b[$j][6] * $mag;
$b[$j][3] += $dx * $b[$i][6] * $mag; $b[$j][4] += $dy * $b[$i][6] * $mag; $b[$j][5] += $dz * $b[$i][6] * $mag;
}
}
foreach ($b as &$p) { $p[0] += 0.01 * $p[3]; $p[1] += 0.01 * $p[4]; $p[2] += 0.01 * $p[5]; }
unset($p);
}
return round($b[0][0], 6);
},
'mandelbrot (loops)' => static function () use ($scale) {
$size = (int) (400 * sqrt($scale)); $inside = 0;
for ($y = 0; $y < $size; $y++) {
for ($x = 0; $x < $size; $x++) {
$cr = 2.0 * $x / $size - 1.5; $ci = 2.0 * $y / $size - 1.0; $zr = $zi = 0.0; $i = 0;
while ($i < 50 && $zr * $zr + $zi * $zi < 4.0) { $t = $zr * $zr - $zi * $zi + $cr; $zi = 2.0 * $zr * $zi + $ci; $zr = $t; $i++; }
if ($i === 50) { $inside++; }
}
}
return $inside;
},
'hash map (arrays)' => static function () use ($scale) {
$m = [];
for ($i = 0, $n = (int) (1000000 * $scale); $i < $n; $i++) { $k = 'k' . ($i % 50000); $m[$k] = ($m[$k] ?? 0) + $i; }
ksort($m);
return count($m);
},
'strings' => static function () use ($scale) {
$s = '';
for ($i = 0, $n = (int) (300000 * $scale); $i < $n; $i++) { $s .= strtoupper(substr(md5((string) $i), 0, 8)) . ','; }
return strlen(str_replace('A', 'b', $s)) + count(explode(',', $s));
},
'json encode/decode' => static function () use ($scale) {
$data = [];
for ($i = 0; $i < 2000; $i++) { $data[] = ['id' => $i, 'name' => "user$i", 'tags' => ['a', 'b', 'c'], 'score' => $i / 3]; }
$len = 0;
for ($r = 0, $n = (int) (60 * $scale); $r < $n; $r++) { $len += count(json_decode(json_encode($data), true)); }
return $len;
},
'objects (OOP)' => static function () use ($scale) {
$sum = 0;
for ($i = 0, $n = (int) (1000000 * $scale); $i < $n; $i++) { $p = new ArrayObject([$i, $i + 1]); $sum += $p[1] - $p[0]; }
return $sum;
},
];
}
function runAll(int $runs, float $scale, ?string $only): array
{
$out = [];
foreach (workloads($scale) as $name => $fn) {
if ($only !== null && stripos($name, $only) === false) { continue; }
$fn(); // warm-up (also lets the JIT compile)
$best = INF;
for ($r = 0; $r < $runs; $r++) {
$t = hrtime(true); $fn(); $best = min($best, (hrtime(true) - $t) / 1e6);
}
$out[$name] = round($best, 2);
}
return $out;
}
function mode(): string
{
$oc = function_exists('opcache_get_status') ? @opcache_get_status(false) : false;
$jit = is_array($oc) && !empty($oc['jit']['on']);
return PHP_VERSION . ($jit ? ' JIT ' . ini_get('opcache.jit') : (is_array($oc) && $oc['opcache_enabled'] ? ' OPcache' : ' interpreter'));
}
if (!isset($opts['compare'])) {
$res = runAll($runs, $scale, $opts['only'] ?? null);
if (isset($opts['json'])) { echo json_encode(['mode' => mode(), 'ms' => $res]), "\n"; exit; }
printf("PHP %s, best of %d runs (ms, lower is better)\n", mode(), $runs);
foreach ($res as $n => $ms) { printf(" %-22s %10.2f\n", $n, $ms); }
exit;
}
// --compare: re-run this script in child processes with different settings.
$bins = isset($opts['php']) ? explode(',', $opts['php']) : [PHP_BINARY];
$configs = [];
foreach ($bins as $bin) {
$configs[] = [$bin, ['-d', 'opcache.enable_cli=0']];
$configs[] = [$bin, ['-d', 'opcache.enable_cli=1', '-d', 'opcache.jit=disable']];
$configs[] = [$bin, ['-d', 'opcache.enable_cli=1', '-d', 'opcache.jit_buffer_size=64M', '-d', 'opcache.jit=tracing']];
}
$pass = ['--json', "--runs=$runs"];
if (isset($opts['quick'])) { $pass[] = '--quick'; }
if (isset($opts['only'])) { $pass[] = '--only=' . $opts['only']; }
$cols = [];
foreach ($configs as [$bin, $ini]) {
$cmd = array_merge([$bin], $ini, [__FILE__], $pass);
fwrite(STDERR, '> ' . implode(' ', $cmd) . "\n");
$p = proc_open($cmd, [1 => ['pipe', 'w'], 2 => STDERR], $pipes);
$json = stream_get_contents($pipes[1]); proc_close($p);
$row = json_decode((string) $json, true);
if (!is_array($row)) { fwrite(STDERR, " failed: $json\n"); continue; }
$label = $row['mode'] . (count($bins) > 1 ? ' (' . basename(dirname($bin, 2)) . '/' . basename($bin) . ')' : '');
$cols[$label] = $row['ms'];
}
if (!$cols) { exit(1); }
$labels = array_keys($cols);
$base = $cols[$labels[0]];
printf("\n%-22s", 'ms (speed-up)');
foreach ($labels as $l) { printf(" | %-24s", substr($l, 0, 24)); }
echo "\n", str_repeat('-', 22 + 27 * count($labels)), "\n";
$geo = array_fill_keys($labels, 0.0);
foreach ($base as $name => $ms0) {
printf('%-22s', $name);
foreach ($labels as $l) {
$ms = $cols[$l][$name]; $geo[$l] += log($ms0 / $ms);
printf(' | %9.1f (%5.2fx) ', $ms, $ms0 / $ms);
}
echo "\n";
}
printf('%-22s', 'geometric mean');
foreach ($labels as $l) { printf(' | %5.2fx ', exp($geo[$l] / count($base))); }
echo "\n";lib.php adds settle(), race(), mapLimit() and retry() on top of the runtime's Future. demo.php shows every pattern in the terminal with timings; app.php uses them in web routes (/fanout, /settle, /race, /timeout, /limit, /retry, /cache with Redis).
lib.php bổ sung settle(), race(), mapLimit() và retry() dựa trên Future của runtime. demo.php trình diễn từng mẫu trên terminal kèm thời gian; app.php dùng chúng trong các route web (/fanout, /settle, /race, /timeout, /limit, /retry, /cache với Redis).
php examples/06-async/demo.php
php runtime/bin/qhturbo-serve examples/03-async-io/backend.php --port=9000 --concurrency=1024 &
QHT_REDIS=redis://127.0.0.1:6379/0 php runtime/bin/qhturbo-serve examples/06-async/app.php --port=8080 --concurrency=64
curl localhost:8080/raceQHturbo async demo (PHP 8.3.6)
1. sequential: 3 calls x 100 ms 300.6 ms
2. all(): the same 3 calls in parallel 100.3 ms
3. timeout(): give up after 150 ms 150.4 ms "timed out: Timed out after 0.150 s"
4. all() rethrows the error of a failed call 80.4 ms "caught: bad failed"
5. settle(): keep every result, errors too 80.4 ms {"users":{"ok":true,...},"ads":{"ok":false,...}}
6. race(): fastest replica wins 30.4 ms ["replica-2","replica-2 (30 ms)"]
7. mapLimit(): 20 jobs x 50 ms, 5 at a time 201.1 ms
8. retry(): fails twice, then succeeds 210.9 ms "attempt 3 (20 ms)"
9. Loop timers: repeat() every 25 ms, 4 ticks 101.0 ms [1,2,3,4]examples/06-async/lib.php105 linesdòng
<?php
// Small helpers built on QHturbo's Future, for patterns the runtime does not
// ship as functions: settle, race, mapLimit, retry.
declare(strict_types=1);
namespace QhtExample\Async;
use QHTurbo\Runtime\Async\Future;
use function QHTurbo\Runtime\Async\{async, await, delay};
if (!\function_exists(__NAMESPACE__ . '\\settle')) {
/**
* Like all(), but never throws: waits for every future and reports each one
* as ['ok' => true, 'value' => ...] or ['ok' => false, 'error' => '...'].
*
* @param array<array-key, Future> $futures
* @return array<array-key, array{ok: bool, value?: mixed, error?: string}>
*/
function settle(array $futures): array
{
$out = [];
foreach ($futures as $key => $future) {
try {
$out[$key] = ['ok' => true, 'value' => await($future)];
} catch (\Throwable $e) {
$out[$key] = ['ok' => false, 'error' => $e::class . ': ' . $e->getMessage()];
}
}
return $out;
}
/**
* The first future to finish wins (value or error). Returns [key, value].
* The others keep running in the background; their results are ignored.
*
* @param array<array-key, Future> $futures
* @return array{0: array-key, 1: mixed}
*/
function race(array $futures): array
{
$winner = new Future();
foreach ($futures as $key => $future) {
$future->onDone(static function (?\Throwable $error, mixed $value) use ($winner, $key): void {
if ($winner->isDone()) {
return;
}
$error === null ? $winner->resolve([$key, $value]) : $winner->fail($error);
});
}
return await($winner);
}
/**
* Run $fn over $items with at most $limit calls in flight at once
* (protects a backend or a connection pool). Keys are preserved.
*
* @template T
* @param array<array-key, mixed> $items
* @param callable(mixed, array-key): T $fn
* @return array<array-key, T>
*/
function mapLimit(array $items, int $limit, callable $fn): array
{
$keys = array_keys($items);
$next = 0;
$results = [];
$lanes = [];
for ($lane = 0; $lane < max(1, $limit); $lane++) {
$lanes[] = async(static function () use (&$next, $keys, $items, $fn, &$results): void {
while ($next < count($keys)) {
$key = $keys[$next++];
$results[$key] = $fn($items[$key], $key);
}
});
}
foreach ($lanes as $l) {
await($l);
}
$ordered = [];
foreach ($keys as $key) {
$ordered[$key] = $results[$key];
}
return $ordered;
}
/**
* Call $fn up to $attempts times, waiting $backoff, 2*$backoff, ... seconds
* between failures (non-blocking). Rethrows the last error.
*/
function retry(callable $fn, int $attempts = 3, float $backoff = 0.05): mixed
{
for ($i = 1; ; $i++) {
try {
return $fn($i);
} catch (\Throwable $e) {
if ($i >= $attempts) {
throw $e;
}
delay($backoff * 2 ** ($i - 1));
}
}
}
}examples/06-async/demo.php107 linesdòng
<?php
// Async basics from the command line, no web server needed.
// php examples/06-async/demo.php (from the QHTurbo repository root)
// QHT_RUNTIME=/path/to/QHTurbo/runtime php demo.php
//
// Outside a fiber, await() drives the event loop itself, so plain CLI scripts
// can use async() too.
declare(strict_types=1);
$runtime = getenv('QHT_RUNTIME') ?: __DIR__ . '/../../runtime';
require $runtime . '/src/autoload.php';
require __DIR__ . '/lib.php';
use QHTurbo\Runtime\Async\TimeoutException;
use QHTurbo\Runtime\EventLoop\Loop;
use function QHTurbo\Runtime\Async\{all, async, await, delay, timeout};
use function QhtExample\Async\{mapLimit, race, retry, settle};
/** A fake service call that takes $ms milliseconds (non-blocking). */
function fakeCall(string $name, int $ms, bool $fail = false): string
{
delay($ms / 1000);
if ($fail) {
throw new RuntimeException("$name failed");
}
return "$name ($ms ms)";
}
function step(string $title, callable $fn): void
{
// Timed inside the fiber: that is when a request handler would get the result.
await(async(static function () use ($title, $fn): void {
$t = hrtime(true);
$result = $fn();
printf("%-46s %7.1f ms %s\n", $title, (hrtime(true) - $t) / 1e6, json_encode($result, JSON_UNESCAPED_UNICODE));
}));
}
echo "QHturbo async demo (PHP " . PHP_VERSION . ")\n\n";
step('1. sequential: 3 calls x 100 ms', fn () => [
fakeCall('a', 100), fakeCall('b', 100), fakeCall('c', 100),
]);
step('2. all(): the same 3 calls in parallel', fn () => all([
'a' => async(fn () => fakeCall('a', 100)),
'b' => async(fn () => fakeCall('b', 100)),
'c' => async(fn () => fakeCall('c', 100)),
]));
// Note: timeout() stops waiting, it does not cancel the slow call, which
// finishes in the background (the script waits for it before step 4).
step('3. timeout(): give up after 150 ms', function () {
try {
return await(timeout(async(fn () => fakeCall('slow', 1000)), 0.15));
} catch (TimeoutException $e) {
return 'timed out: ' . $e->getMessage();
}
});
step('4. all() rethrows the error of a failed call', function () {
try {
return all([async(fn () => fakeCall('ok', 50)), async(fn () => fakeCall('bad', 80, true))]);
} catch (RuntimeException $e) {
return 'caught: ' . $e->getMessage();
}
});
step('5. settle(): keep every result, errors too', fn () => settle([
'users' => async(fn () => fakeCall('users', 60)),
'ads' => async(fn () => fakeCall('ads', 40, true)),
'stats' => async(fn () => fakeCall('stats', 80)),
]));
step('6. race(): fastest replica wins', fn () => race([
'replica-1' => async(fn () => fakeCall('replica-1', 120)),
'replica-2' => async(fn () => fakeCall('replica-2', 30)),
'replica-3' => async(fn () => fakeCall('replica-3', 90)),
]));
step('7. mapLimit(): 20 jobs x 50 ms, 5 at a time', fn () => count(
mapLimit(range(1, 20), 5, fn (int $n) => fakeCall("job$n", 50))
) . ' jobs done (~200 ms = 20 / 5 x 50)');
step('8. retry(): fails twice, then succeeds', function () {
return retry(function (int $attempt) {
return fakeCall("attempt $attempt", 20, $attempt < 3);
}, attempts: 3, backoff: 0.05);
});
step('9. Loop timers: repeat() every 25 ms, 4 ticks', function () {
$ticks = [];
$done = new QHTurbo\Runtime\Async\Future();
$id = Loop::get()->repeat(0.025, function () use (&$ticks, &$id, $done) {
$ticks[] = count($ticks) + 1;
if (count($ticks) === 4) {
Loop::get()->cancel($id);
$done->resolve();
}
});
await($done);
return $ticks;
});
echo "\nIn a web worker, every await() above lets the same process serve other requests meanwhile.\n";examples/06-async/app.php108 linesdòng
<?php
// Async patterns inside a web worker. Needs the backend from 03-async-io:
// php runtime/bin/qhturbo-serve examples/03-async-io/backend.php --port=9000 --concurrency=1024 &
// php runtime/bin/qhturbo-serve examples/06-async/app.php --port=8080 --workers=2 --concurrency=64
// Optional Redis cache for /cache: QHT_REDIS=redis://127.0.0.1:6379/0
//
// curl localhost:8080/fanout 3 calls in parallel with all()
// curl localhost:8080/settle partial results when one call fails
// curl localhost:8080/race fastest of 3 replicas
// curl localhost:8080/timeout a slow call cut at 100 ms, with a fallback
// curl "localhost:8080/limit?n=20&limit=5" 20 calls, at most 5 at a time
// curl localhost:8080/retry retries a flaky call with back-off
// curl localhost:8080/cache Redis cache-aside (needs QHT_REDIS)
use QHTurbo\Runtime\Async\TimeoutException;
use QHTurbo\Runtime\Http\Client;
use QHTurbo\Runtime\Http\Request;
use QHTurbo\Runtime\Http\Response;
use QHTurbo\Runtime\Redis\Client as Redis;
use function QHTurbo\Runtime\Async\{all, async, await, timeout};
use function QhtExample\Async\{mapLimit, race, retry, settle};
require __DIR__ . '/lib.php';
$backend = rtrim(getenv('QHT_BACKEND') ?: 'http://127.0.0.1:9000', '/');
$redis = getenv('QHT_REDIS') ? new Redis(getenv('QHT_REDIS'), poolSize: 16) : null;
/** GET the backend and decode JSON; throws on transport errors and non-2xx answers. */
$call = static function (string $path) use ($backend): array {
$res = Client::get($backend . $path, 2.0);
if ($res['status'] >= 300) {
throw new RuntimeException("$path answered {$res['status']}");
}
return json_decode($res['body'], true);
};
$routes = [
'/fanout' => static fn () => all([
'users' => async($call, '/users'),
'orders' => async($call, '/orders'),
'stats' => async($call, '/stats'),
]),
'/settle' => static fn () => settle([
'users' => async($call, '/users'),
'ads' => async($call, '/ads?fail=1'), // this one fails, the page still renders
'stats' => async($call, '/stats'),
]),
'/race' => static function () use ($call) {
[$winner, $value] = race([
'replica-a' => async($call, '/item?ms=' . random_int(5, 60)),
'replica-b' => async($call, '/item?ms=' . random_int(5, 60)),
'replica-c' => async($call, '/item?ms=' . random_int(5, 60)),
]);
return ['winner' => $winner, 'answer' => $value];
},
'/timeout' => static function () use ($call) {
try {
return ['recommendations' => await(timeout(async($call, '/recommendations?ms=500'), 0.1))];
} catch (TimeoutException) {
return ['recommendations' => [], 'note' => 'slow service skipped after 100 ms'];
}
},
'/limit' => static function (Request $r) use ($call) {
$n = max(1, min(200, (int) ($r->query['n'] ?? 20)));
$limit = max(1, min(50, (int) ($r->query['limit'] ?? 5)));
$items = mapLimit(range(1, $n), $limit, static fn (int $i) => $call("/item/$i")['value']);
return ['items' => count($items), 'limit' => $limit, 'sum' => array_sum($items)];
},
'/retry' => static function () use ($call) {
$log = [];
$value = retry(static function (int $attempt) use ($call, &$log) {
$log[] = "attempt $attempt";
return $call('/flaky' . ($attempt < 3 ? '?fail=1' : '')); // fails twice
}, attempts: 3, backoff: 0.02);
return ['value' => $value, 'log' => $log];
},
'/cache' => static function () use ($call, $redis) {
if ($redis === null) {
return Response::json(['error' => 'set QHT_REDIS=redis://127.0.0.1:6379/0'], 501);
}
if (($hit = $redis->get('qht:example:dashboard')) !== null) {
return ['cache' => 'hit', 'data' => json_decode($hit, true)];
}
$data = all(['users' => async($call, '/users'), 'stats' => async($call, '/stats')]);
$redis->set('qht:example:dashboard', json_encode($data), 'EX', 5);
return ['cache' => 'miss', 'data' => $data];
},
];
return static function (Request $r) use ($routes): Response|array {
$route = $routes[$r->path()] ?? null;
if ($route === null) {
return ['routes' => array_keys($routes)];
}
$t = hrtime(true);
$result = $route($r);
if ($result instanceof Response) {
return $result;
}
return ['took_ms' => round((hrtime(true) - $t) / 1e6, 1)] + $result;
};bench.php is the load generator (concurrency, duration, client processes, {rand:N} in URLs, JSON output). compare.sh starts the classic and worker versions of an example and benchmarks both.
bench.php là công cụ tạo tải (số kết nối, thời gian, số tiến trình client, {rand:N} trong URL, xuất JSON). compare.sh khởi động bản truyền thống và bản worker của một ví dụ rồi đo cả hai.
php examples/bench.php "http://127.0.0.1:8080/products/{rand:5000}" -c 64 -d 10 -p 2
WORKERS=4 C=128 D=15 bash examples/compare.sh allexamples/bench.php115 linesdòng
<?php
// A small HTTP load generator in PHP (needs the curl extension; pcntl for -p > 1).
// Keeps C requests in flight for D seconds and reports requests/s and latency.
//
// php bench.php http://127.0.0.1:8080/hello -c 64 -d 10
// php bench.php http://127.0.0.1:8080/ -c 128 -d 15 -p 4 # 4 client processes
// php bench.php URL --json # machine-readable
// php bench.php "http://127.0.0.1:8080/products/{rand:5000}" # random id 1..5000 per request
//
// For more than ~10-20k req/s the client itself becomes the limit: use more
// processes (-p) or a native tool such as wrk. Run it on another machine, or
// keep the server's worker count below the CPU count, so client and server do
// not fight for the same CPUs.
declare(strict_types=1);
if (!extension_loaded('curl')) { fwrite(STDERR, "bench.php needs the curl extension\n"); exit(1); }
$args = $argv; array_shift($args);
$url = null; $c = 32; $d = 10.0; $procs = 1; $json = false; $warm = 1.0;
while ($args) {
$a = array_shift($args);
match (true) {
$a === '-c' => $c = max(1, (int) array_shift($args)),
$a === '-d' => $d = max(0.5, (float) array_shift($args)),
$a === '-p' => $procs = max(1, (int) array_shift($args)),
$a === '-w' => $warm = max(0.0, (float) array_shift($args)),
$a === '--json' => $json = true,
default => $url = $a,
};
}
if ($url === null) {
fwrite(STDERR, "usage: php bench.php URL [-c concurrency=32] [-d seconds=10] [-p processes=1] [-w warmup=1] [--json]\n");
exit(1);
}
/** Runs the load from one process; returns [latencies ms, status counts, errors, bytes]. */
function load(string $url, int $conc, float $seconds, float $warm): array
{
$mh = curl_multi_init();
curl_multi_setopt($mh, CURLMOPT_MAX_HOST_CONNECTIONS, $conc);
$lat = []; $codes = []; $errors = 0; $bytes = 0;
$start = microtime(true); $measureFrom = $start + $warm; $end = $measureFrom + $seconds;
$handles = [];
$add = static function () use ($mh, $url, &$handles): void {
$h = curl_init(preg_replace_callback('/\{rand:(\d+)\}/', static fn ($m) => (string) random_int(1, max(1, (int) $m[1])), $url));
curl_setopt_array($h, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_TCP_NODELAY => true,
CURLOPT_HTTPHEADER => ['Connection: keep-alive'], CURLOPT_FORBID_REUSE => false]);
$handles[(int) $h] = [$h, hrtime(true)];
curl_multi_add_handle($mh, $h);
};
for ($i = 0; $i < $conc; $i++) { $add(); }
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh, 0.05);
while ($info = curl_multi_info_read($mh)) {
$h = $info['handle']; [, $t0] = $handles[(int) $h]; unset($handles[(int) $h]);
$now = microtime(true);
if ($now >= $measureFrom && $now < $end) {
if ($info['result'] === CURLE_OK) {
$code = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
$codes[$code] = ($codes[$code] ?? 0) + 1;
$lat[] = (hrtime(true) - $t0) / 1e6;
$bytes += strlen((string) curl_multi_getcontent($h));
} else {
$errors++;
}
}
curl_multi_remove_handle($mh, $h); curl_close($h);
if ($now < $end) { $add(); }
}
} while ($handles);
curl_multi_close($mh);
return [$lat, $codes, $errors, $bytes];
}
$per = intdiv($c, $procs) ?: 1;
if ($procs === 1) {
$results = [load($url, $c, $d, $warm)];
} else {
if (!function_exists('pcntl_fork')) { fwrite(STDERR, "-p needs the pcntl extension\n"); exit(1); }
$files = [];
for ($i = 0; $i < $procs; $i++) {
$files[$i] = tempnam(sys_get_temp_dir(), 'qhtb');
if (pcntl_fork() === 0) {
file_put_contents($files[$i], serialize(load($url, $per, $d, $warm)));
exit(0);
}
}
while (pcntl_wait($status) > 0);
$results = array_map(static fn ($f) => unserialize((string) file_get_contents($f)) ?: [[], [], 1, 0], $files);
array_map('unlink', $files);
}
$lat = []; $codes = []; $errors = 0; $bytes = 0;
foreach ($results as [$l, $cs, $e, $b]) {
array_push($lat, ...$l); $errors += $e; $bytes += $b;
foreach ($cs as $k => $v) { $codes[$k] = ($codes[$k] ?? 0) + $v; }
}
sort($lat);
$n = count($lat);
$pct = static fn (float $p) => $n ? round($lat[min($n - 1, (int) floor($p / 100 * $n))], 2) : null;
$out = [
'url' => $url, 'concurrency' => $procs > 1 ? $per * $procs : $c, 'processes' => $procs, 'seconds' => $d,
'requests' => $n, 'rps' => round($n / $d, 1), 'errors' => $errors, 'status' => $codes,
'latency_ms' => ['avg' => $n ? round(array_sum($lat) / $n, 2) : null, 'p50' => $pct(50), 'p90' => $pct(90), 'p99' => $pct(99), 'max' => $n ? round(end($lat), 2) : null],
'kb_per_s' => round($bytes / 1024 / $d, 1),
];
if ($json) { echo json_encode($out), "\n"; exit; }
ksort($codes);
printf("%s (%d connections, %d process%s, %.0f s)\n", $url, $out['concurrency'], $procs, $procs > 1 ? 'es' : '', $d);
printf(" Requests/s : %s\n", number_format($out['rps'], 1));
printf(" Latency ms : avg %.2f p50 %.2f p90 %.2f p99 %.2f max %.2f\n", ...array_values($out['latency_ms']));
printf(" Status : %s errors: %d\n", implode(' ', array_map(static fn ($k, $v) => "$k x$v", array_keys($codes), $codes)) ?: '-', $errors);examples/compare.sh69 linesdòng
#!/usr/bin/env bash
# Compare "classic" PHP (the script runs from the top on every request, like
# PHP-FPM) with QHturbo Performance (long-lived workers) on the same example.
#
# ./compare.sh hello | framework | async-io | mysql | all
#
# Environment:
# PHP=php PHP binary (8.1+), e.g. /opt/qhturbo/8.5/bin/php
# QHT_SERVE=... path to runtime/bin/qhturbo-serve
# (default: ../runtime/bin/qhturbo-serve, i.e. examples/ inside the repo)
# WORKERS=2 PHP processes on each side (keep below the CPU count)
# C=64 D=10 P=1 bench.php concurrency, seconds, client processes
# CLASSIC_URL=http://... benchmark your real nginx + PHP-FPM instead of "php -S"
# (point it at the example's fpm.php)
set -euo pipefail
cd "$(dirname "$0")"
EX="${1:-all}"
PHP="${PHP:-php}"
QHT_SERVE="${QHT_SERVE:-../runtime/bin/qhturbo-serve}"
WORKERS="${WORKERS:-2}"; C="${C:-64}"; D="${D:-10}"; P="${P:-1}"
OPC=(-d opcache.enable_cli=1 -d opcache.validate_timestamps=0 -d memory_limit=512M)
PIDS=()
cleanup() { for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null || true; done; wait 2>/dev/null || true; }
trap cleanup EXIT
[ -f "$QHT_SERVE" ] || { echo "qhturbo-serve not found at $QHT_SERVE (set QHT_SERVE)"; exit 1; }
wait_port() { for _ in $(seq 1 100); do (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && return 0; sleep 0.1; done; echo "port $1 did not open"; exit 1; }
bench() { "$PHP" bench.php "$1" -c "$C" -d "$D" -p "$P" --json; }
row() { # name json
"$PHP" -r '$j=json_decode($argv[2],true); printf(" %-34s %10s req/s p50 %7.2f ms p99 %8.2f ms errors %d\n",
$argv[1], number_format($j["rps"],1), $j["latency_ms"]["p50"], $j["latency_ms"]["p99"], $j["errors"]);' "$1" "$2"
}
speedup() { "$PHP" -r '$a=json_decode($argv[1],true); $b=json_decode($argv[2],true); printf(" => QHturbo is %.1fx the requests/s of classic PHP\n\n", $b["rps"]/max(0.1,$a["rps"]));' "$1" "$2"; }
run_one() { # dir path concurrency extra-serve-args
local dir=$1 path=$2 conc=$3; shift 3
echo "== $dir ($WORKERS processes each, $C connections, ${D}s)"
local classic_url="${CLASSIC_URL:-}"
if [ -z "$classic_url" ]; then
PHP_CLI_SERVER_WORKERS=$WORKERS "$PHP" "${OPC[@]}" -S 127.0.0.1:8081 "$dir/fpm.php" >/dev/null 2>&1 & PIDS+=($!)
wait_port 8081; classic_url="http://127.0.0.1:8081$path"
fi
"$PHP" "${OPC[@]}" "$QHT_SERVE" "$dir/app.php" --port=8082 --workers="$WORKERS" --concurrency="$conc" "$@" >/dev/null 2>&1 & PIDS+=($!)
wait_port 8082
local a b
a=$(bench "$classic_url"); row "classic (php -S / FPM)" "$a"
b=$(bench "http://127.0.0.1:8082$path"); row "QHturbo workers (concurrency $conc)" "$b"
speedup "$a" "$b"
cleanup; PIDS=()
}
start_backend() {
"$PHP" "${OPC[@]}" "$QHT_SERVE" 03-async-io/backend.php --port=9000 --concurrency=1024 --max-connections=1000 >/dev/null 2>&1 & PIDS+=($!)
wait_port 9000
}
case "$EX" in
hello) run_one 01-hello /hello 1 ;;
framework) run_one 02-framework /api/users/42 1 ;;
async-io) start_backend; run_one 03-async-io / 64 ;;
mysql) run_one 04-mysql /slow 32 ;; # needs 04-mysql/.env + schema.sql + seed.php first
all) run_one 01-hello /hello 1; run_one 02-framework /api/users/42 1; start_backend; run_one 03-async-io / 64 ;;
*) echo "usage: $0 hello|framework|async-io|mysql|all"; exit 1 ;;
esacTips and limitationsMẹo và giới hạn
Apply to your servers todayÁp dụng ngay cho máy chủ
- Upgrade to PHP 8.3+ if your app allows it.Nâng lên PHP 8.3+ nếu ứng dụng cho phép.
- Size OPcache (
memory_consumption,max_accelerated_files); usevalidate_timestamps=0only if every deploy reloads FPM.Cấu hình đủ OPcache (memory_consumption,max_accelerated_files); chỉ dùngvalidate_timestamps=0nếu mỗi lần deploy đều reload FPM. - Enable JIT for compute-heavy code; measure first for typical web apps.Bật JIT cho code nặng tính toán; với web thông thường hãy đo trước.
- Use preload for large frameworks.Dùng preload cho framework lớn.
- Most latency is MySQL/Redis/I/O: fix queries, indexes and caching, then consider Performance mode.Phần lớn độ trễ nằm ở MySQL/Redis/I/O: tối ưu truy vấn, index và cache, sau đó cân nhắc chế độ Performance.
Current limitationsGiới hạn hiện tại
- The built-in server has no TLS, HTTP/2 or chunked request bodies: keep nginx in front.Server tích hợp không có TLS, HTTP/2 hay request body dạng chunked: luôn đặt nginx phía trước.
- Uploads and responses are held in RAM; let nginx serve large files (
X-Accel-Redirect).Upload và response nằm trong RAM; để nginx phục vụ file lớn (X-Accel-Redirect). - PDO, Eloquent and Doctrine still block; use the async clients for I/O-heavy paths.PDO, Eloquent và Doctrine vẫn chạy chặn; dùng client bất đồng bộ cho các đoạn nặng I/O.
- Redis: no Pub/Sub, Cluster or Sentinel yet. MySQL: connect is blocking, no server-side prepares.Redis: chưa có Pub/Sub, Cluster, Sentinel. MySQL: bước kết nối vẫn chặn, chưa có prepare phía server.
- WordPress and code using
exit()for control flow should stay on Compatible.WordPress và code dùngexit()để điều khiển luồng nên giữ ở Compatible.
FAQCâu hỏi thường gặp
Will it break the PHP my panel installed?Nó có làm hỏng PHP mà panel đã cài không?
No. QHturbo installs under /opt/qhturbo with its own socket and service, and refuses to install into system or panel paths. disable switches a site back in seconds.
Không. QHturbo cài dưới /opt/qhturbo với socket và dịch vụ riêng, và từ chối cài vào đường dẫn của hệ thống hay panel. disable đưa site về như cũ trong vài giây.
Can I run several PHP versions at once?Có chạy nhiều phiên bản PHP cùng lúc được không?
Yes. Each version has its own prefix, socket (php74-fpm.sock, php85-fpm.sock…) and systemd unit.
Được. Mỗi phiên bản có prefix, socket (php74-fpm.sock, php85-fpm.sock…) và unit systemd riêng.
Does Performance mode need Swoole or another extension?Chế độ Performance có cần Swoole hay extension khác không?
No. It is pure PHP 8.1+ using Fibers. pcntl/posix are needed only for multiple workers, mysqli only for the MySQL client.
Không. Nó viết thuần PHP 8.1+ dùng Fiber. Chỉ cần pcntl/posix khi chạy nhiều worker, và mysqli khi dùng MySQL client.
How does it compare to Octane, FrankenPHP or RoadRunner?So với Octane, FrankenPHP hay RoadRunner thì sao?
Those are mature options worth considering for large deployments. QHturbo Performance needs no external extension or binary and is small enough to read and control.
Đó là các giải pháp trưởng thành, đáng cân nhắc cho hệ thống lớn. QHturbo Performance không cần extension hay binary bên ngoài và đủ gọn để đọc hiểu và kiểm soát.