diff --git a/README.md b/README.md index 600f2428..ce934530 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,9 @@ libuvcpp/ │ └── expand/ # Memory pool tests ├── examples/ # Runnable examples (webapp_demo) ├── doc/ # Documentation +│ ├── benchmark.md # Measured per-connection memory, throughput, stability │ ├── ci-guide.md # CI maintenance guidelines +│ ├── http2-status.md # HTTP/2 support status │ └── webapp-guide.md # Web app framework guide ├── cmake/ # CMake config templates ├── .github/workflows/ # CI pipeline @@ -508,6 +510,12 @@ contributor, [@sercebr](https://github.com/sercebr). blocks (`nbufs = 2`) (`1.1.28`); the variant table stores and returns handles instead of whole bodies (`1.1.31`) +**Measured, not estimated** — see [doc/benchmark.md](doc/benchmark.md): **4.62 KiB per idle +connection** (least squares over eight tiers, R² = 0.999987 → 1 M connections ≈ 4.42 GiB), +75 k RPS on a single event loop, and a 10-minute soak at 38 M requests / 0 errors. That page +also compares the per-connection figure against [Hical](https://github.com/Hical61/Hical)'s +own report, and states the caveats that comparison carries. + ### Configuration, packaging & CI - Enable macros ship with the package, and a macro set that disagrees with the DLL is now a diff --git a/README.zh.md b/README.zh.md index 43acd5a4..ddfb0820 100644 --- a/README.zh.md +++ b/README.zh.md @@ -443,7 +443,9 @@ libuvcpp/ │ └── expand/ # 内存池测试 ├── examples/ # 可运行示例(webapp_demo) ├── doc/ # 文档 +│ ├── benchmark.md # 每连接内存、吞吐与稳定性实测 │ ├── ci-guide.md # CI 维护指南 +│ ├── http2-status.md # HTTP/2 支持现状 │ └── webapp-guide.md # web 应用框架指南 ├── cmake/ # CMake 配置模板 ├── .github/workflows/ # CI 流水线 @@ -518,6 +520,11 @@ libuvcpp/ - 响应体零拷贝接管,并与头一起作为两块写出去(`nbufs = 2`)(`1.1.28`); 变体表按句柄存/取,不再整份拷体(`1.1.31`) +**实测数据(不是估算)** —— 见 [doc/benchmark.md](doc/benchmark.md):每条空闲连接 +**4.62 KiB**(4 734 B,八档最小二乘,R² = 0.999987,外推 100 万连接 ≈ 4.42 GiB)、单事件循环 +75 k RPS、10 分钟长跑 3 840 万请求 0 错误。那一页还把每连接这个数与 +[Hical](https://github.com/Hical61/Hical) 自己的报告做了对照,并写明了该对照带的口径问题。 + ### 配置、打包与 CI - 使能宏随包发出;宏集与 DLL 不一致从静默垃圾值改为编译期硬失败(`1.1.27`) diff --git a/doc/benchmark.md b/doc/benchmark.md new file mode 100644 index 00000000..fc96b43b --- /dev/null +++ b/doc/benchmark.md @@ -0,0 +1,282 @@ +# libuvcpp 每连接内存与吞吐实测 + +这一页是一次**可复现的靶场实测记录**,被测对象是**用本库构建出来的一个真实 HTTP 服务**, +不是微基准,也不是估算值。写它的直接动机是回答一个具体问题: + +> 一台机器上一百万条空闲长连接,要吃掉多少内存? + +| 项 | 值 | +|---|---| +| **测量版本** | **1.1.33**(`534e4ed`) | +| 撰写时 `master` | `1.1.35-dev`(`d46b6eb`) | +| 被测服务 | 靶场:静态文件 + 动态路由 + 上传下载 + WebSocket + TLS 的完整 webapp | +| 平台 | Windows 10 Pro 1903(18363)/ MSVC x64 / Release | +| 运行时 | 单事件循环线程(下文另注明) | + +**三个读数**(方法、分档与原始数据见下文各节): + +- **每连接内存 4.62 KiB**(4 734 B,最小二乘,**R² = 0.999987**)⇒ 外推 100 万连接 ≈ **4.42 GiB** +- 单核动态路由 **75 k RPS**(流水线 10–50 深可到 **88 k**);静态 **44 k RPS / 107 MB/s** +- 10 分钟长跑 **3 840 万请求、0 错误**,RSS 漂移 **0.2%** + +--- + +## 1. 为什么这是「1.1.33 的读数」,以及它为什么对后面几版仍然成立 + +测量发生在 `534e4ed`(1.1.33)。写明这件事是**必要条件**:一个不带 commit 的读数值 +不是结论,是传闻。 + +`534e4ed` 之后到本文撰写时的 `d46b6eb`(1.1.35-dev)之间,**`src/` 下只动了三个文件**: + +``` +src/uvcpp/uvcpp_version.h | 2 +- ← 版本号 +src/web/uvcpp_http_server.cpp | 31 +++++----- ← 压缩变体表的字节账 +src/web/uvcpp_http_server.h | 10 ++++++- +``` + +其余全是文档、CI、测试与发布打包。逐个说清它们为什么不在本文的测量路径上: + +| commit | 动的东西 | 在本文测量的路径上吗 | +|---|---|---| +| `4710479` | 版本号 1.1.33 → 1.1.34 | 不涉及 | +| `d56c2a4` | 压缩变体表的字节账:把会漂移的计数字段换成从容器派生的求和,并补上「字节上限」那条腿 | **不在** —— 见下 | +| `d46b6eb` | 发布包同时提供 Debug 档动态库与 PDB(`uvcppd.dll` / `libuvcppd.so`),只改打包器与 CI 判据 | 不涉及 Release 运行时 | + +`d56c2a4` 只影响「变体表超过上限时淘汰谁」,且只在**压缩缓存的冷路径**上;本文测的是连接 +内存、动态/静态吞吐、长跑稳定性,都不经过那条路径(本文这一版连压缩都没开)。 + +**因此:下面的读数对 1.1.33 – 1.1.35-dev 这条线成立**;版本号只升不改功能时(1.2.0 属此 +列)同样成立。真去动了上面那三条路径的话,这一页需要重测 —— 复现方法在第 8 节。 + +--- + +## 2. 方法:为什么是外推,而不是真的造满一百万 + +**真造不满。**本机在 **54 852** 条时开始出现 `ENOBUFS` —— 那是动态端口与内核缓冲到顶, +不是服务端拒绝。换平台也一样有这条墙,只是位置不同(对照项目在自己的 Linux 环境里撞到的 +是同一量级的另一个数)。所以百万级只能外推,而外推要立得住,就必须有判据。 + +做法是**多档位建立连接 + 最小二乘回归 + R²**:每档记「建立 N 条连接后的进程 RSS」,对 N +做一次线性拟合,斜率就是每连接内存,再外推到 1e6。判据不是"斜率好看",是**R² 必须接近 +1**,且每档残差要小 —— 否则那条直线根本不存在,外推就是编。 + +- 采样对象是**进程 RSS**(`WorkingSet64`),即**用户态**每连接开销;内核侧的 TCP 缓冲 + (每连接约 4 KB 量级)不在其中,也不受本库控制 —— 第 6 节比较时会回到这一点。 +- 每档建立连接后**逐条复检**该连接可服务(不是"连上就算")。 +- 关闭后复检句柄数回落,确认没有把连接泄漏成别的形状。 + +--- + +## 3. 结果一:每连接内存 + +下表有**两列 RSS,是两个不同的量**,别混: + +- **建立后 RSS** —— 连接全部建好、攥住时读的。**拟合用的就是这一列。** +- **关闭后 RSS** —— 全部断开之后再读的。它是**另一条线**,只用来佐证连接被回收干净 + (本节末单说)。 + +八档采样(原点 + 7 个非零档): + +| 目标并发 | 实际建立 | 连接失败 | 探测成功 | **建立后 RSS**(拟合用) | 关闭后 RSS / 句柄 | +|---:|---:|---:|---:|---:|---| +| 0(基线) | — | — | — | **9.402 MiB** | — | +| 8 000 | 8 000 | 0 | 8 000 | 46.277 MiB | 17.398 MiB / 184 | +| 16 000 | 16 000 | 0 | 16 000 | 82.602 MiB | 24.320 MiB / 184 | +| 24 000 | 24 000 | 0 | 24 000 | 118.477 MiB | 31.324 MiB / 184 | +| 32 000 | 32 000 | 0 | 32 000 | 154.238 MiB | 38.531 MiB / 184 | +| 40 000 | 40 000 | 0 | 40 000 | 190.652 MiB | 45.133 MiB / 184 | +| 48 000 | 48 000 | 0 | 48 000 | 226.727 MiB | 52.715 MiB / 184 | +| 55 000 | **54 852** | 148(`ENOBUFS`) | 全部 54 852 | 257.270 MiB | 59.094 MiB / **184** | + +- **零连接失败**直到 48 000。 +- 每一档都逐条复检过 8 000 – 54 852 条连接**全部可服务**。 +- 全部关闭后句柄回落到 **184**(与基线一致,无泄漏)。 +- 原点那档是**进程空转时的基线**(9 859 072 B),即 8 个点里的第 8 个。 + +**拟合(8 个点,y 单位 MiB)**: + +``` +RSS(MiB) = 0.0045143 × N + 9.954 R² = 0.9999869 + = 4.62 KiB/连接 × N + 9.95 MiB +``` + +逐点残差(**这一列要能让人自己算**): + +| N | 建立后 RSS (MiB) | 拟合预测 (MiB) | 残差 | 残差 % | +|---:|---:|---:|---:|---:| +| 0 | 9.402 | 9.954 | −0.551 | −5.86% | +| 8 000 | 46.277 | 46.068 | +0.210 | +0.45% | +| 16 000 | 82.602 | 82.182 | +0.420 | **+0.51%** | +| 24 000 | 118.477 | 118.296 | +0.180 | +0.15% | +| 32 000 | 154.238 | 154.410 | −0.172 | −0.11% | +| 40 000 | 190.652 | 190.525 | +0.128 | +0.07% | +| 48 000 | 226.727 | 226.639 | +0.088 | +0.04% | +| 54 852 | 257.270 | 257.571 | −0.301 | −0.12% | + +- **除原点外每点 |残差| ≤ 0.51%**;而上表能看出这些残差**没有走向**(在零点附近交替), + 不是单向偏。 +- 原点那点自己偏 −5.86%:它是**空转基线**这一次单独的读数,不落在拟合线上 —— 拟合出的 + 截距 9.954 MiB 比它高 0.55 MiB。写出来是因为"除原点外"这句话要是没有上面那张表,读者 + 没法知道偏多少。 +- 截距 9.95 MiB 是进程的固定开销(静态缓存、会话表、已初始化的协议层等),不是每连接的。 +- **外推 100 万连接 ≈ 4.4085 GiB(斜率)+ 0.0097 GiB(截距)≈ 4.42 GiB。** +- 每连接 **4 733.6 B = 4.6226 KiB**,本文按 4.62 KiB 记。 + +**为什么能这么低**:本库**没有每连接常驻的读缓冲**。读是 +`read_start([](uvcpp_buf* b){ … })` 的按需形状 —— 有数据才给缓冲,空闲连接上不预置任何 +一块固定大小的读缓冲。这一条是第 6 节那个对照的全部原因。 + +### 「关闭后」那一列是另一条线,不能拿去验上面的拟合式 + +上表最右列(关闭后 RSS)**自己对 N 回归**,出来的是: + +``` +RSS(MiB) = 0.0008870 × N + 10.120 R² = 0.9997352 + = 930 B/连接 +``` + +斜率是 **5.09×** 的差(4 733.6 ÷ 930.1),R² 也不是上面那个 0.9999869。**这两个数都对, +但它们量的不是同一件事**:关闭后读到的是一条正在被回收的服务端连接表的残余占用,不是 +「多一条连接要多多少内存」。**要判定容量,看的是「建立后」那条线。** + +这一列自己的价值在另一头:它的**截距 10.120 MiB 与空转基线 9.402 MiB 只差 0.72 MiB** +(约 7.7%)。这才是"连接被回收干净"的证据 —— 8 000 到 54 852 条连接全关掉之后,进程回到 +离基线不远的地方,且**句柄一律回到 184**。所以这一列留着,但它是**证据**,不是**斜率**。 + + +--- + +## 4. 结果二:吞吐与延迟 + +动态路由(内存型响应),单事件循环: + +| 用例 | 并发 | 流水线 | RPS | p50 | p99 | max | 错误/超时/503 | +|---|---:|---:|---:|---:|---:|---:|---:| +| json | 1 | 1 | 44 946 | 0 ms | 0 ms | 1 ms | 0 | +| json | 10 | 1 | **75 791** | 0 ms | 0 ms | 1 ms | 0 | +| json | 400 | 1 | 71 614 | 5 ms | 6 ms | 27 ms | 0 | +| json | 1 600 | 1 | 63 278 | 25 ms | 28 ms | 101 ms | 0 | +| json | 100 | **10** | **88 134** | 10 ms | 16 ms | 28 ms | 0 | +| json | 100 | **50** | 87 874 | 56 ms | 65 ms | 94 ms | 0 | + +静态文件(走工作线程池):`44 379 RPS / 107 MB/s`(并发 100)、`43 154 RPS / 104 MB/s` +(并发 400)。256 KB 大响应:`1 608 RPS / 402 MB/s`。 + +**要点**:到并发 1 600 仍零错误、零超时、零 503,延迟线性劣化但不掉请求;流水线 +(10–50 深)把单核推到 88 k RPS。 + +**静态服务没有并发悬崖**:1 → 200 并发共 12 档打静态文件,**每一档 503 占比都是 0%**, +成功 RPS 从 15 879 升到峰值 **45 044**(并发 12)后平在 43 563(并发 200)。作为对照, +同一台机器上纯内存路由是 44 501 → 73 480。 + +--- + +## 5. 结果三:稳定性 + +10 分钟持续压测(动态路由 + 连接 churn + WebSocket 混合): + +| 项 | 读数 | +|---|---| +| 总请求 | **38 404 681,0 错误** | +| RPS | 64 008 | +| 延迟 | p50 **1 ms** / p90 1 ms / p99 **2 ms** / max **14 ms** | +| RSS 漂移 | **0.2%**(90.3 → 90.5 MB) | +| 句柄 | 252 → 254 | +| 连接 churn | 9 810 次建连 / **0 失败** | +| WebSocket | **7 804 条会话、23 412 帧 / 0 失败**,回收计数与建连数完全对上 | + +另外单独做了**分段归因**(每段只做一件事,各自算 RSS 斜率):只发内存型请求 +(每请求新建连接)**−0.01 MB/min**、只建连/断开(3 757 次)**+0.01 MB/min**、只做 +WebSocket 建连/回显/断开(2 733 轮)**+0.07 MB/min**、完全不发请求 **0.00 MB/min**。 +四段全平 ⇒ 没有随时间累积的增长,长跑那条 0.2% 是分配器膨胀后收敛,不是斜坡。 + +> 这份归因第一次跑出来是 `19.52 MB/min` 的"持续增长"。真因不在被测对象:采样是按 +> **进程名**取第一条匹配,而当时机器上还跑着另外两台同名的被测进程,样本里不存 pid ⇒ +> 采样在进程之间跳了。证据是那条序列呈**单次 34 MB 台阶后永久平**(跳变,不是斜坡), +> 换成**按 pid 锚定**的采样后全程 9.47 MB 不动。写在这里是因为这类装置缺陷会让读数 +> 骗人,而它长得和真泄漏一模一样。 + +--- + +## 6. 与 Hical 的对照 + +对照对象是 **[Hical61/Hical](https://github.com/Hical61/Hical)** —— 一个 C++20/26 的 +Asio 框架,同样面向「百万空闲长连接」。它自己维护了一份性能报告(该仓库 +`docs/performance_report.md`),其中 **§2.8「空闲长连接内存优化」** 给出的正是同一量: +**每条空闲连接的内存**。所以两边不用在两套口径之间换算,可以直接比。 + +Hical 自己在 §2.8 里给的账(v2.6.4 → v2.6.5): + +| 优化项 | 优化前 | 优化后 | 每连接节省 | +|---|---|---|---| +| `readBuf` 借还 | 连接常驻 8 KB `std::string` | `ReadBufferPool` 按需借还,空闲时不持有 | ~7.5 KB | +| `inputBuffer_` 懒分配 | 连接建立即分配 `PmrBuffer`(~2 KB) | `std::optional`,第一次读才创建 | ~2 KB | +| **合计** | **~17.44 KB** | **~8 KB**(省约 55%) | | + +同表给出百万连接量级:**~16.6 GB → ~7–8 GB**。 +§2.9(v2.6.6)又加了一档:空闲连接 **+256 B**(协程帧栈),换来 `epoll_ctl` 调用数降 71.8%。 + +| 实现 | 每空闲连接 | 百万连接 | +|---|---|---| +| Hical v2.6.4(优化前) | ~17.44 KB | ~16.6 GB | +| Hical v2.6.5 / v2.6.6 | ~8 KB(v2.6.6 再 +256 B) | ~7–8 GB | +| **libuvcpp 1.1.33** | **4.62 KiB**(4 734 B) | **4.42 GiB** | + +> Hical 那两格沿用**它的原文标法**(`KB` / `GB`);它实际是二进单位,见下面第一条。 + +- 对本库 vs Hical **优化前**:**轻约 3.8×**(每连接少约 12.8 KiB)。 +- 对本库 vs Hical **优化后**:**轻约 1.7×**(4.62 KiB vs ~8 KiB)。 + +**⚠ 这个 1.7× 有一处口径不确定,写清楚免得被读成强结论。** Hical 在那句话里同时注明 +「内核侧 TCP 缓冲 ~4 KB/连接不可优化」。**本文的 4.62 KiB 是进程 RSS 增量,只含用户态**; +如果 Hical 的 ~8 KB 里已经含了那 ~4 KB 内核缓冲,那么同一口径下它剩下的用户态部分约 +4 KB,两者**接近持平** —— 差别是本库的机制里压根没有常驻读缓冲,而它需要一套 +`ReadBufferPool` 借还来达到同一格。该文档没有把这个口径写明,所以两种读法都列在这里, +不单选对本库有利的那个。**优化前那一格(17.44 KB)没有这个歧义。** + +**其余可比性说明**: + +- 两边都是**外推**(都撞端口/内核墙),所以都是"斜率可信、截距不可跨机器比"。截距差异 + 明显(本机 9.95 MB,因为这台还带着静态缓存、会话表与已初始化的协议层)。**斜率才是 + 可比的量**,上表据此下结论。 +- **单位口径其实同一个**(这里先前写成"不同",是我没查):Hical 标的是 `KB` / `GB`,但它 + 那几个数**只在二进读法下自洽** —— `17.44 × 1024 × 10⁶ B = 16.63 GiB`,正好对上它写的 + **~16.6 GB**;按十进读成 `17.44 KB` 算出来是 `16.24`,对不上。所以它的 `KB` 是 KiB、 + `GB` 是 GiB,与本文同口径,**上面那两个倍数不用折算**。 +- **平台不同**:Hical 的数据出自它自己文档所述环境(Linux VM),本文是 Windows / MSVC + / 环回。跨平台比较请带上这一条。 + +--- + +## 7. 这一页不是什么 + +- **不是跨平台的定论**,也不是跨项目的绝对排名 —— 见第 6 节最后三条。 +- **不是单核之外的成绩**。上面所有吞吐都是**一个事件循环线程**(静态那几条走工作池)。 + 要横向扩就起 N 个进程。别把 75 k 当成"每核 75 k 且不可再扩"。 +- **不是 TLS 读数**。TLS 的稳态代价另有一组(约 −23% ~ −30% RPS,256 KB 响应受 AES-GCM + 单核吞吐限制降到 119 MB/s);那一组的基线对照**归因未闭合**,所以没有放进这一页。 + +--- + +## 8. 复现方法 + +测量装置是一组独立脚本(Node + Python,跑在靶场进程之外),不随库发布。方法本身足够 +简单,按下面三步就能复刻: + +1. **建连**:分档建立 N 条 TCP 连接(N = 0 / 8k / 16k / 24k / 32k / 40k / 48k / 55 000), + 每条建好后发一个请求并读回响应 —— **逐条复检**,失败的连接不计入 N 且要单独报数。 +2. **采样**:每档稳定后读被测进程的 RSS(Windows 是 `WorkingSet64`,Linux 读 + `/proc//status` 的 `VmRSS`)。**按 pid 采样,不要按进程名** —— 见第 5 节那个坑。 +3. **拟合**:对 (N, RSS) 做最小二乘,报**斜率、截距、R²、每点残差**。R² 不接近 1 就说明 + 这条直线不成立,此时不要外推。 + +吞吐那一组用 `autocannon`(15 s/档,同机);长跑那组是 10 分钟的自建压测循环,同时统计 +错误、延迟分位、句柄数与 RSS 漂移。 + +--- + +## 9. 出处 + +- **libuvcpp 侧**的全部数字:本库在 `534e4ed`(1.1.33)上构建出的靶场,方法如上。 +- **Hical 侧**的数字:`Hical61/Hical` 仓库的 `docs/performance_report.md`,§2.8 / §2.9。 + 引用的是该项目的**自报**数据,本文没有在其平台上复跑过;其测试环境见该文档 §1。